diff --git a/Cargo.lock b/Cargo.lock index e0f10c2..5ec04c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -264,15 +264,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "convert_case" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -306,28 +297,10 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.9.1", - "crossterm_winapi", - "derive_more", - "document-features", "futures-core", "mio", "parking_lot", - "rustix 1.0.7", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -397,27 +370,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "diff" version = "0.1.13" @@ -435,21 +387,23 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "document-features" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" -dependencies = [ - "litrs", -] - [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "duplicate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97af9b5f014e228b33e77d75ee0e6e87960124f0f4b16337b586a6bec91867b1" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "proc-macro2-diagnostics", +] + [[package]] name = "either" version = "1.15.0" @@ -1149,12 +1103,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" - [[package]] name = "lock_api" version = "0.4.13" @@ -1187,7 +1135,8 @@ dependencies = [ "chrono", "cli-clipboard", "color-eyre", - "crossterm 0.29.0", + "crossterm", + "duplicate", "graphql_client", "insta", "open", @@ -1196,6 +1145,7 @@ dependencies = [ "serde", "tokio", "tokio-stream", + "tui-input", "tui-markdown", ] @@ -1572,6 +1522,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "version_check", + "yansi", +] + [[package]] name = "pulldown-cmark" version = "0.13.0" @@ -1624,7 +1587,7 @@ dependencies = [ "bitflags 2.9.1", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools 0.13.0", @@ -2419,6 +2382,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-input" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "tui-markdown" version = "0.3.5" @@ -2511,6 +2484,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index e31a314..67d7385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,8 @@ license = "MIT" chrono = "0.4.41" cli-clipboard = "0.4.0" color-eyre = "0.6.3" -crossterm = { version = "0.29.0", features = ["event-stream"] } +crossterm = { version = "0.28.1", features = ["event-stream"] } +duplicate = "2.0.0" graphql_client = {version = "0.14.0", features = [ "reqwest", "reqwest-rustls"] } open = "5.3.2" ratatui = "0.29.0" @@ -22,6 +23,7 @@ reqwest = {version = "0.11", features = ["blocking", "json"]} serde = {version = "1.0.219", features = ["derive"]} tokio = { version = "1.44.2", features = ["rt-multi-thread", "macros"] } tokio-stream = "0.1.17" +tui-input = "0.14.0" tui-markdown = { version = "0.3.5", features = ["highlight-code"] } [dev-dependencies] diff --git a/README.md b/README.md index 567ceac..e74562d 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ * Press `y` to yank (copy) the git branch name to the clipboard * Press `o` to open the full issue in Linear desktop or web, whichever you have installed. * **New in 0.0.4**: View switcher (`Tab`/`Shift+Tab`) - switch between custom views as defined in your Linear app +* **New in 0.0.6**: Now available to install view Homebrew (see **Installation**) +* **New in 0.0.7**: Search issues (`/`) - search all issues by simple search term ### Planned Features * Faster loading via cacheing * Richer markdown presentation -* Tighter local git integration -* Brew/Packager Manager installation +* Brew/Packager Manager installation improvements ### Installation diff --git a/src/iconmap.rs b/src/iconmap.rs index 9cf90c8..63e86f6 100644 --- a/src/iconmap.rs +++ b/src/iconmap.rs @@ -21,6 +21,7 @@ pub fn ico_to_nf(name: &str) -> String { "Android" => " ", "Page" => "󰈙 ", "Robot" => " ", + "Magnify" => " ", "Chat" => "󰭹 ", "LightBulb" => "󰌵 ", "Alert" => "󰀨 ", diff --git a/src/main.rs b/src/main.rs index b0e9d24..a55bf46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,11 @@ mod api; mod iconmap; mod queries; mod widgets; +use crossterm::event::EventStream; +use duplicate::duplicate_item; use serde::{Deserialize, Serialize}; use widgets::{MyIssuesWidget, SelectedIssueWidget, TabWidget}; -use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind}; use std::{ fmt::{self}, time::Duration, @@ -13,6 +14,7 @@ use std::{ use color_eyre::eyre::Result; +use crossterm::event::{Event, KeyCode, KeyEventKind}; use queries::*; use ratatui::{ DefaultTerminal, Frame, @@ -34,17 +36,26 @@ async fn main() -> Result<()> { app_result } +#[derive(Clone, Debug, Default, PartialEq)] +pub enum InputMode { + #[default] + Normal, + Editing, +} + /* Events for widget communication */ #[derive(Debug, PartialEq)] -pub enum LtEvent { +pub enum LtEvent<'a> { None, SelectIssue, + SearchIssues(&'a str), } #[derive(Debug, PartialEq)] pub enum TabChangeEvent { None, FetchCustomViewIssues(custom_views_query::ViewFragment), + SearchIssues, FetchMyIssues, } @@ -112,27 +123,43 @@ impl App { fn handle_event(&mut self, event: &Event) { if let Event::Key(key) = event { if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => { + match (key.code, self.issue_list_widget.input_mode.clone()) { + (KeyCode::Char('q') | KeyCode::Esc, InputMode::Normal) => { self.should_quit = true; } - KeyCode::Tab | KeyCode::BackTab => { + + (KeyCode::Tab | KeyCode::BackTab, _) => { self.issue_list_widget .run(self.tab_widget.handle_event(event)); + if self.issue_list_widget.show_search_input { + self.issue_list_widget.toggle_search_mode(); + } + } + (KeyCode::Char('/'), InputMode::Normal) => { + self.issue_list_widget.toggle_search_mode(); + } + (KeyCode::Esc, InputMode::Editing) => { + self.issue_list_widget.toggle_search_mode(); } _ => { self.selected_issue_widget.handle_event(event); - if let LtEvent::SelectIssue = self.issue_list_widget.handle_event(event) { - let issue_list_widget_state = - self.issue_list_widget.state.write().unwrap(); - let selected_issue: Option = issue_list_widget_state - .list_state - .selected() - .map(|index| { - issue_list_widget_state.issue_map[&self.issue_list_widget.selected_view_id][index].clone() - }); - self.selected_issue_widget - .set_selected_issue(selected_issue); + match self.issue_list_widget.handle_event(event) { + LtEvent::SelectIssue => { + let issue_list_widget_state = + self.issue_list_widget.state.write().unwrap(); + let selected_issue: Option = + issue_list_widget_state.list_state.selected().map(|index| { + issue_list_widget_state.issue_map + [&issue_list_widget_state.selected_view_id][index] + .clone() + }); + self.selected_issue_widget + .set_selected_issue(selected_issue); + } + LtEvent::SearchIssues(_) => { + self.tab_widget.show_and_select_search_tab(); + } + _ => (), } } }; @@ -177,29 +204,14 @@ pub struct IssueFragment { pub description: Option, } -impl From for IssueFragment { - fn from(item: my_issues_query::IssueFragment) -> Self { - Self { - title: item.title, - identifier: item.identifier, - url: item.url, - estimate: item.estimate, - state: item.state.into(), - created_at: item.created_at, - priority: item.priority, - priority_label: item.priority_label, - branch_name: item.branch_name, - description: item.description, - labels: item.labels.into(), - assignee: item.assignee.map(|assignee| assignee.into()), - creator: item.creator.map(|creator| creator.into()), - project: item.project.map(|project| project.into()), - } - } -} - -impl From for IssueFragment { - fn from(item: custom_view_query::IssueFragment) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragment] [ IssueFragment ]; + [ my_issues_query::IssueFragment] [ IssueFragment ]; + [ search_query::IssueFragment] [ IssueFragment ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { title: item.title, identifier: item.identifier, @@ -227,18 +239,14 @@ pub struct IssueFragmentState { pub type_: String, } -impl From for IssueFragmentState { - fn from(item: my_issues_query::IssueFragmentState) -> Self { - Self { - name: item.name, - color: item.color, - type_: item.type_, - } - } -} - -impl From for IssueFragmentState { - fn from(item: custom_view_query::IssueFragmentState) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentState ] [ IssueFragmentState ]; + [ my_issues_query::IssueFragmentState ] [ IssueFragmentState ]; + [ search_query::IssueFragmentState ] [ IssueFragmentState ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { name: item.name, color: item.color, @@ -255,17 +263,14 @@ pub struct IssueFragmentAssignee { pub display_name: String, } -impl From for IssueFragmentAssignee { - fn from(item: my_issues_query::IssueFragmentAssignee) -> Self { - Self { - is_me: item.is_me, - display_name: item.display_name, - } - } -} - -impl From for IssueFragmentAssignee { - fn from(item: custom_view_query::IssueFragmentAssignee) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentAssignee ] [ IssueFragmentAssignee ]; + [ my_issues_query::IssueFragmentAssignee ] [ IssueFragmentAssignee ]; + [ search_query::IssueFragmentAssignee ] [ IssueFragmentAssignee ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { is_me: item.is_me, display_name: item.display_name, @@ -281,17 +286,14 @@ pub struct IssueFragmentCreator { pub display_name: String, } -impl From for IssueFragmentCreator { - fn from(item: my_issues_query::IssueFragmentCreator) -> Self { - Self { - is_me: item.is_me, - display_name: item.display_name, - } - } -} - -impl From for IssueFragmentCreator { - fn from(item: custom_view_query::IssueFragmentCreator) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentCreator ] [ IssueFragmentCreator ]; + [ my_issues_query::IssueFragmentCreator ] [ IssueFragmentCreator ]; + [ search_query::IssueFragmentCreator ] [ IssueFragmentCreator ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { is_me: item.is_me, display_name: item.display_name, @@ -306,18 +308,14 @@ pub struct IssueFragmentProject { pub color: String, } -impl From for IssueFragmentProject { - fn from(item: my_issues_query::IssueFragmentProject) -> Self { - Self { - name: item.name, - icon: item.icon, - color: item.color, - } - } -} - -impl From for IssueFragmentProject { - fn from(item: custom_view_query::IssueFragmentProject) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentProject ] [ IssueFragmentProject ]; + [ my_issues_query::IssueFragmentProject ] [ IssueFragmentProject ]; + [ search_query::IssueFragmentProject ] [ IssueFragmentProject ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { name: item.name, icon: item.icon, @@ -331,20 +329,14 @@ pub struct IssueFragmentLabels { pub edges: Vec, } -impl From for IssueFragmentLabels { - fn from(item: my_issues_query::IssueFragmentLabels) -> Self { - Self { - edges: item - .edges - .iter() - .map(|edge| edge.to_owned().into()) - .collect(), - } - } -} - -impl From for IssueFragmentLabels { - fn from(item: custom_view_query::IssueFragmentLabels) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentLabels ] [ IssueFragmentLabels ]; + [ my_issues_query::IssueFragmentLabels ] [ IssueFragmentLabels ]; + [ search_query::IssueFragmentLabels ] [ IssueFragmentLabels ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { edges: item .edges @@ -360,16 +352,14 @@ pub struct IssueFragmentLabelsEdges { pub node: IssueFragmentLabelsEdgesNode, } -impl From for IssueFragmentLabelsEdges { - fn from(item: my_issues_query::IssueFragmentLabelsEdges) -> Self { - Self { - node: item.node.into(), - } - } -} - -impl From for IssueFragmentLabelsEdges { - fn from(item: custom_view_query::IssueFragmentLabelsEdges) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentLabelsEdges ] [ IssueFragmentLabelsEdges ]; + [ my_issues_query::IssueFragmentLabelsEdges ] [ IssueFragmentLabelsEdges ]; + [ search_query::IssueFragmentLabelsEdges ] [ IssueFragmentLabelsEdges ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { node: item.node.into(), } @@ -382,17 +372,14 @@ pub struct IssueFragmentLabelsEdgesNode { pub name: String, } -impl From for IssueFragmentLabelsEdgesNode { - fn from(item: my_issues_query::IssueFragmentLabelsEdgesNode) -> Self { - Self { - color: item.color, - name: item.name, - } - } -} - -impl From for IssueFragmentLabelsEdgesNode { - fn from(item: custom_view_query::IssueFragmentLabelsEdgesNode) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentLabelsEdgesNode ] [ IssueFragmentLabelsEdgesNode ]; + [ my_issues_query::IssueFragmentLabelsEdgesNode ] [ IssueFragmentLabelsEdgesNode ]; + [ search_query::IssueFragmentLabelsEdgesNode ] [ IssueFragmentLabelsEdgesNode ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { color: item.color, name: item.name, diff --git a/src/queries.rs b/src/queries.rs index 2c13b74..4af9ac3 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -27,5 +27,15 @@ pub struct CustomViewsQuery; pub struct CustomViewQuery; +#[derive(Debug, Default, GraphQLQuery)] +#[graphql( + schema_path = "src/schemas/linear.graphql", + query_path = "src/queries/search.graphql", + response_derives = "serde::Serialize,Default,Debug,Clone" +)] +pub struct SearchQuery; + + + diff --git a/src/queries/search.graphql b/src/queries/search.graphql new file mode 100644 index 0000000..1e1f221 --- /dev/null +++ b/src/queries/search.graphql @@ -0,0 +1,45 @@ +fragment IssueFragment on IssueSearchResult { + title + identifier + state { + name + color + type + } + url + assignee { + isMe + displayName + } + creator { + isMe + displayName + } + estimate + project { + name + icon + color + } + createdAt + priorityLabel + priority + labels { + edges { + node { + color + name + } + } + } + branchName + description +} + +query SearchQuery($term: String!) { + searchIssues(term: $term) { + nodes { + ...IssueFragment + } + } +} diff --git a/src/widgets/issue_list.rs b/src/widgets/issue_list.rs index 70a91da..1251988 100644 --- a/src/widgets/issue_list.rs +++ b/src/widgets/issue_list.rs @@ -3,27 +3,30 @@ use std::sync::{Arc, RwLock}; use crossterm::event::{Event, KeyCode, KeyEventKind}; use ratatui::{ buffer::Buffer, - layout::Rect, + layout::{Constraint, Layout, Rect}, style::{ - Modifier, Style, Stylize, + Color, Modifier, Style, Stylize, palette::{ material::{AMBER, BLUE_GRAY}, tailwind::SLATE, }, }, text::{Line, Span, Text}, - widgets::{Block, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap}, + widgets::{Block, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap}, }; +use tui_input::Input; +use tui_input::backend::crossterm::EventHandler; use std::collections::HashMap; use crate::{ - IssueFragment, LoadingState, LtEvent, TabChangeEvent, + InputMode, IssueFragment, LoadingState, LtEvent, TabChangeEvent, api::LinearClient, iconmap, queries::{ - CustomViewQuery, MyIssuesQuery, custom_view_query, custom_views_query, + CustomViewQuery, MyIssuesQuery, SearchQuery, custom_view_query, custom_views_query, my_issues_query::{self}, + search_query, }, }; @@ -31,18 +34,23 @@ use crate::{ pub struct MyIssuesWidgetState { loading_state: LoadingState, pub list_state: ListState, + pub selected_view_id: String, pub issue_map: HashMap>, } #[derive(Debug, Clone, Default)] pub struct MyIssuesWidget { pub state: Arc>, - pub selected_view_id: String, + input: Input, + pub search_input_value: String, + pub show_search_input: bool, + pub input_mode: InputMode, } impl MyIssuesWidget { async fn fetch_my_issues(self) { self.set_loading_state(LoadingState::Loading); + self.set_selected_view(String::from("my_issues")); let linear_api_token = std::env::var("LINEAR_API_TOKEN").expect("Missing LINEAR_API_TOKEN env var"); let client = LinearClient::new(linear_api_token).unwrap(); @@ -69,6 +77,7 @@ impl MyIssuesWidget { async fn fetch_custom_view(self, view: custom_views_query::ViewFragment) { self.set_loading_state(LoadingState::Loading); + self.set_selected_view(view.id.clone()); let linear_api_token = std::env::var("LINEAR_API_TOKEN").expect("Missing LINEAR_API_TOKEN env var"); let client = LinearClient::new(linear_api_token).unwrap(); @@ -96,6 +105,50 @@ impl MyIssuesWidget { self.set_loading_state(LoadingState::Loaded); } + async fn search_issues(self, search_term: String) { + self.set_loading_state(LoadingState::Loading); + self.set_selected_view(String::from("search_results")); + let linear_api_token = + std::env::var("LINEAR_API_TOKEN").expect("Missing LINEAR_API_TOKEN env var"); + let client = LinearClient::new(linear_api_token).unwrap(); + let variables = search_query::Variables { + term: search_term.to_string(), + }; + match client.query(SearchQuery, variables).await { + Ok(data) => { + self.state.write().unwrap().issue_map.insert( + "search_results".to_string(), + data.search_issues + .nodes + .iter() + .map(|issue| issue.to_owned().into()) + .collect(), + ); + self.state.write().unwrap().list_state.select(None); + } + Err(e) => { + self.set_loading_state(LoadingState::Error(e.to_string())); + return; + } + } + self.set_loading_state(LoadingState::Loaded); + } + + pub fn toggle_search_mode(&mut self) { + if self.show_search_input { + self.show_search_input = false; + self.input_mode = InputMode::Normal; + } else { + self.show_search_input = true; + self.input.reset(); + self.input_mode = InputMode::Editing; + } + } + + fn set_selected_view(&self, id: String) { + self.state.write().unwrap().selected_view_id = id; + } + fn set_loading_state(&self, state: LoadingState) { self.state.write().unwrap().loading_state = state; } @@ -108,7 +161,7 @@ impl MyIssuesWidget { let mut state = self.state.write().unwrap(); if let (Some(index), Some(map)) = ( state.list_state.selected(), - state.issue_map.get(&self.selected_view_id), + state.issue_map.get(&state.selected_view_id), ) { if index >= map.len() - 1 { return state.list_state.select_first(); @@ -122,7 +175,7 @@ impl MyIssuesWidget { match ( state.list_state.selected(), - state.issue_map.get(&self.selected_view_id), + state.issue_map.get(&state.selected_view_id), ) { (Some(0) | None, Some(map)) => { let max_index = map.len() - 1; @@ -136,7 +189,7 @@ impl MyIssuesWidget { let state = self.state.read().unwrap(); if let (Some(index), Some(map)) = ( state.list_state.selected(), - state.issue_map.get(&self.selected_view_id), + state.issue_map.get(&state.selected_view_id), ) { let branch_name = map[index].branch_name.clone(); cli_clipboard::set_contents(branch_name).unwrap(); @@ -147,7 +200,7 @@ impl MyIssuesWidget { let state = self.state.read().unwrap(); if let (Some(index), Some(map)) = ( state.list_state.selected(), - state.issue_map.get(&self.selected_view_id), + state.issue_map.get(&state.selected_view_id), ) { let url = map[index].url.clone(); open::that(&url) @@ -156,44 +209,59 @@ impl MyIssuesWidget { } } - pub fn run(&mut self, tab_change_event: TabChangeEvent) { + pub fn run(&self, tab_change_event: TabChangeEvent) { let this = self.clone(); match tab_change_event { TabChangeEvent::FetchMyIssues => { - self.selected_view_id = String::from("my_issues"); tokio::spawn(this.fetch_my_issues()); } TabChangeEvent::FetchCustomViewIssues(view) => { - self.selected_view_id = view.id.clone(); tokio::spawn(this.fetch_custom_view(view)); } + TabChangeEvent::SearchIssues => { + self.set_selected_view(String::from("search_results")); + } _ => (), } } - pub fn handle_event(&self, event: &Event) -> LtEvent { + pub fn handle_event(&mut self, event: &Event) -> LtEvent { if self.get_loading_state() != LoadingState::Loaded { return LtEvent::None; } if let Event::Key(key) = event { if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Char('j') => { + use InputMode::{Editing, Normal}; + + match (self.input_mode.clone(), key.code) { + (Normal, KeyCode::Char('j')) => { self.scroll_down(); return LtEvent::SelectIssue; } - KeyCode::Char('k') => { + (Normal, KeyCode::Char('k')) => { self.scroll_up(); return LtEvent::SelectIssue; } - KeyCode::Char('o') => { + (Normal, KeyCode::Char('o')) => { let _ = self.open_url(); return LtEvent::None; } - KeyCode::Char('c') | KeyCode::Char('y') => { + (Normal, KeyCode::Char('c') | KeyCode::Char('y')) => { self.copy_branch_name(); return LtEvent::None; } + (Editing, KeyCode::Enter) => { + self.input_mode = InputMode::Normal; + // tab? + tokio::spawn(self.clone().search_issues(String::from(self.input.value()))); + return LtEvent::SearchIssues(self.input.value()); + } + (Editing, _) => { + if self.show_search_input { + self.input.handle_event(event); + } + return LtEvent::None; + } _ => { return LtEvent::None; } @@ -207,9 +275,24 @@ const SELECTED_STYLE: Style = Style::new().bg(SLATE.c100).fg(BLUE_GRAY.c900); impl Widget for &MyIssuesWidget { fn render(self, area: Rect, buf: &mut Buffer) { + use Constraint::{Length, Min}; + + let (search_area, body_area): (Option, Rect) = { + if self.show_search_input { + let vertical = Layout::vertical([Length(3), Min(0)]); + let [search_area, body_area] = vertical.areas(area); + (Some(search_area), body_area) + } else { + (None, area) + } + }; + let mut block = Block::bordered().title_bottom(Line::from(vec![ - Span::from(" ").blue(), + Span::from(" / ").blue(), Span::from("to select issue "), + Span::from(" ── "), + Span::from(" < / > ").blue(), + Span::from("to search "), ])); if let LoadingState::Loading = self.get_loading_state() { @@ -228,7 +311,7 @@ impl Widget for &MyIssuesWidget { } let mut state = self.state.write().unwrap(); let area_width = area.width; - let rows: Vec = match state.issue_map.get(&self.selected_view_id) { + let rows: Vec = match state.issue_map.get(&state.selected_view_id) { Some(issues) => issues .iter() .map(|item| { @@ -262,20 +345,36 @@ impl Widget for &MyIssuesWidget { .highlight_style(SELECTED_STYLE) .highlight_symbol(highlight_symbol) .block(block); - StatefulWidget::render(list, area, buf, &mut state.list_state); + StatefulWidget::render(list, body_area, buf, &mut state.list_state); + + if let Some(search_area) = search_area { + let block2 = Block::bordered().padding(Padding::ZERO); + let value = if self.input_mode == InputMode::Editing { + (self.input.value().to_owned() + "|").to_owned() + } else { + self.input.value().to_string() + }; + let input = Paragraph::new(value).style(Color::Yellow).block(block2); + input.render(search_area, buf); + } } } #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::{Arc, RwLock}}; + use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + }; use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers}; use insta::assert_snapshot; use ratatui::{Terminal, backend::TestBackend, widgets::ListState}; + use tui_input::Input; use crate::{ - widgets::{self, selected_issue::tests::make_issue, MyIssuesWidget}, LtEvent + InputMode, LtEvent, + widgets::{self, MyIssuesWidget, selected_issue::tests::make_issue}, }; fn create_key_event(key: char) -> crossterm::event::Event { @@ -289,8 +388,8 @@ mod tests { #[test] fn test_empty_state() { - let app = MyIssuesWidget::default(); - let mut terminal = Terminal::new(TestBackend::new(40, 20)).unwrap(); + let mut app = MyIssuesWidget::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); terminal .draw(|frame| frame.render_widget(&app, frame.area())) .unwrap(); @@ -307,15 +406,19 @@ mod tests { make_issue("Ticket One", "TEST-1"), make_issue("Ticket Two", "TEST-2"), ]; - let app = MyIssuesWidget { - selected_view_id: String::from("my_issues"), + let mut app = MyIssuesWidget { + show_search_input: false, + input: Input::default(), + input_mode: InputMode::Normal, + search_input_value: String::from(""), state: Arc::new(RwLock::new(widgets::issue_list::MyIssuesWidgetState { loading_state: crate::LoadingState::Loaded, + selected_view_id: String::from("my_issues"), list_state: ListState::default(), issue_map: HashMap::from([(String::from("my_issues"), issues)]), })), }; - let mut terminal = Terminal::new(TestBackend::new(40, 20)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); terminal .draw(|frame| frame.render_widget(&app, frame.area())) .unwrap(); @@ -345,7 +448,21 @@ mod tests { assert_eq!(app.state.read().unwrap().list_state.selected(), Some(1)); // test that yanks the branch name to the clipboard - app.handle_event(&create_key_event('y')); - assert_eq!(cli_clipboard::get_contents().unwrap(), "test-1-branch-name"); + // disabling because CI can't run this + //app.handle_event(&create_key_event('y')); + //assert_eq!(cli_clipboard::get_contents().unwrap(), "test-1-branch-name"); + + assert!(!app.show_search_input); + assert_eq!(app.input_mode, InputMode::Normal); + app.toggle_search_mode(); + assert!(app.show_search_input); + assert_eq!(app.input_mode, InputMode::Editing); + terminal + .draw(|frame| frame.render_widget(&app, frame.area())) + .unwrap(); + assert_snapshot!(terminal.backend()); + app.toggle_search_mode(); + assert!(!app.show_search_input); + assert_eq!(app.input_mode, InputMode::Normal); } } diff --git a/src/widgets/selected_issue.rs b/src/widgets/selected_issue.rs index 928cb94..f2479a7 100644 --- a/src/widgets/selected_issue.rs +++ b/src/widgets/selected_issue.rs @@ -2,9 +2,6 @@ use crate::IssueFragment; use crate::LtEvent; use crate::iconmap; -use crossterm::event::Event; -use crossterm::event::KeyCode; -use crossterm::event::KeyEventKind; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::palette::tailwind::SLATE; @@ -19,6 +16,7 @@ use std::sync::{Arc, RwLock}; use chrono::DateTime; +use crossterm::event::{Event, KeyCode, KeyEventKind}; use ratatui::buffer::Buffer; use ratatui::{ layout::{Constraint, Layout, Rect}, diff --git a/src/widgets/snapshots/lt__widgets__issue_list__tests__empty_state.snap b/src/widgets/snapshots/lt__widgets__issue_list__tests__empty_state.snap index f7ba3cb..4766c55 100644 --- a/src/widgets/snapshots/lt__widgets__issue_list__tests__empty_state.snap +++ b/src/widgets/snapshots/lt__widgets__issue_list__tests__empty_state.snap @@ -2,23 +2,23 @@ source: src/widgets/issue_list.rs expression: terminal.backend() --- -"┌──────────────────────────────────────┐" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"└ to select issue ───────────────┘" +"┌──────────────────────────────────────────────────────────┐" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└ / to select issue ── < / > to search ────────────┘" diff --git a/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-2.snap b/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-2.snap index a918ff9..755cce2 100644 --- a/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-2.snap +++ b/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-2.snap @@ -2,23 +2,23 @@ source: src/widgets/issue_list.rs expression: terminal.backend() --- -"┌──────────────────────────────────────┐" -"│ Ticket One │" -"│ TEST-1 󱥸 󰀧│" -"│>Ticket Two │" -"│ TEST-2 󱥸 󰀧│" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"└ to select issue ───────────────┘" +"┌──────────────────────────────────────────────────────────┐" +"│ Ticket One │" +"│ TEST-1 󱥸 󰀧│" +"│>Ticket Two │" +"│ TEST-2 󱥸 󰀧│" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└ / to select issue ── < / > to search ────────────┘" diff --git a/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-3.snap b/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-3.snap new file mode 100644 index 0000000..6780150 --- /dev/null +++ b/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-3.snap @@ -0,0 +1,24 @@ +--- +source: src/widgets/issue_list.rs +expression: terminal.backend() +--- +"┌──────────────────────────────────────────────────────────┐" +"│| │" +"└──────────────────────────────────────────────────────────┘" +"┌──────────────────────────────────────────────────────────┐" +"│ Ticket One │" +"│ TEST-1 󱥸 󰀧│" +"│>Ticket Two │" +"│ TEST-2 󱥸 󰀧│" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└ / to select issue ── < / > to search ────────────┘" diff --git a/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues.snap b/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues.snap index 15e6086..216104d 100644 --- a/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues.snap +++ b/src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues.snap @@ -2,23 +2,23 @@ source: src/widgets/issue_list.rs expression: terminal.backend() --- -"┌──────────────────────────────────────┐" -"│Ticket One │" -"│TEST-1 󱥸 󰀧 │" -"│Ticket Two │" -"│TEST-2 󱥸 󰀧 │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"└ to select issue ───────────────┘" +"┌──────────────────────────────────────────────────────────┐" +"│Ticket One │" +"│TEST-1 󱥸 󰀧 │" +"│Ticket Two │" +"│TEST-2 󱥸 󰀧 │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"└ / to select issue ── < / > to search ────────────┘" diff --git a/src/widgets/snapshots/lt__widgets__tab_widget__tests__empty_state.snap b/src/widgets/snapshots/lt__widgets__tab_widget__tests__empty_state.snap index d414ab4..0093c10 100644 --- a/src/widgets/snapshots/lt__widgets__tab_widget__tests__empty_state.snap +++ b/src/widgets/snapshots/lt__widgets__tab_widget__tests__empty_state.snap @@ -2,5 +2,5 @@ source: src/widgets/tab_widget.rs expression: terminal.backend() --- -" to change view:  My Issues " -" " +" to change view:  My Issues " +" " diff --git a/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs-2.snap b/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs-2.snap new file mode 100644 index 0000000..2878e75 --- /dev/null +++ b/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs-2.snap @@ -0,0 +1,6 @@ +--- +source: src/widgets/tab_widget.rs +expression: terminal.backend() +--- +" to change view:  My Issues  Custom A  Custom B  Search Results " +" " diff --git a/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs.snap b/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs.snap index 0fc9ec3..2835c99 100644 --- a/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs.snap +++ b/src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs.snap @@ -2,5 +2,5 @@ source: src/widgets/tab_widget.rs expression: terminal.backend() --- -" to change view:  My Issues  Custom A  Custom B " -" " +" to change view:  My Issues  Custom A  Custom B " +" " diff --git a/src/widgets/tab_widget.rs b/src/widgets/tab_widget.rs index b6b56dd..dc91bd1 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -33,6 +33,7 @@ impl Default for TabWidget { selected_index: 0, tabs: vec![Tab { title: String::from("My Issues"), + tab_type: TabType::MyIssues, custom_view: None, }], })), @@ -40,9 +41,18 @@ impl Default for TabWidget { } } +#[derive(Clone, Debug, PartialEq, Default)] +enum TabType { + #[default] + MyIssues, + CustomView, + SearchResults, +} + #[derive(Debug, Clone, Default)] struct Tab { title: String, + tab_type: TabType, custom_view: Option, } @@ -63,6 +73,7 @@ impl TabWidget { for custom_view in data.custom_views.nodes.iter() { state.tabs.push(Tab { title: custom_view.name.clone(), + tab_type: TabType::CustomView, custom_view: Some(custom_view.clone()), }); } @@ -73,18 +84,32 @@ impl TabWidget { } } } - pub fn next(&self) { + pub fn next(&self) -> usize { let mut state = self.state.write().unwrap(); if state.selected_index < state.tabs.len() - 1 { state.selected_index += 1; } + state.selected_index } - pub fn prev(&self) { + pub fn prev(&self) -> usize { let mut state = self.state.write().unwrap(); if state.selected_index > 0 { state.selected_index -= 1; } + state.selected_index + } + + pub fn show_and_select_search_tab(&self) { + let mut state = self.state.write().unwrap(); + if state.tabs[state.tabs.len() - 1].tab_type != TabType::SearchResults { + state.tabs.push(Tab { + title: String::from("Search Results"), + tab_type: TabType::SearchResults, + custom_view: None, + }); + } + state.selected_index = state.tabs.len() - 1; } pub fn handle_event(&self, event: &Event) -> crate::TabChangeEvent { @@ -92,13 +117,19 @@ impl TabWidget { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Tab => { - self.next(); + let index = self.next(); let state = self.state.read().unwrap(); - match &state.tabs[state.selected_index].custom_view { + match &state.tabs[index].custom_view { Some(custom_view) => { return TabChangeEvent::FetchCustomViewIssues(custom_view.clone()); } - _ => return TabChangeEvent::FetchMyIssues, + _ => { + return match state.tabs[index].tab_type { + TabType::MyIssues => TabChangeEvent::FetchMyIssues, + TabType::SearchResults => TabChangeEvent::SearchIssues, + _ => TabChangeEvent::None, + }; + } } } KeyCode::BackTab => { @@ -108,7 +139,13 @@ impl TabWidget { Some(custom_view) => { return TabChangeEvent::FetchCustomViewIssues(custom_view.clone()); } - _ => return TabChangeEvent::FetchMyIssues, + _ => { + return match state.tabs[state.selected_index].tab_type { + TabType::MyIssues => TabChangeEvent::FetchMyIssues, + TabType::SearchResults => TabChangeEvent::SearchIssues, + _ => TabChangeEvent::None, + }; + } } } _ => return TabChangeEvent::None, @@ -148,11 +185,15 @@ impl Widget for &TabWidget { _ => "#ffffff".to_string(), }, ) + } else if tab.tab_type == TabType::SearchResults { + (iconmap::ico_to_nf("Magnify"), Color::Yellow.to_string()) } else { (iconmap::ico_to_nf("Home"), String::from("#FFFFFF")) }; let project_color = Color::from_str(&color).unwrap(); - Span::from(format!("{} {}", icon, tab.title.clone().bold())).fg(project_color).bold() + Span::from(format!("{} {}", icon, tab.title.clone().bold())) + .fg(project_color) + .bold() }) .collect::>(), ) @@ -171,7 +212,11 @@ mod tests { use insta::assert_snapshot; use ratatui::{Terminal, backend::TestBackend}; - use crate::{TabChangeEvent, queries::custom_views_query, widgets::TabWidget}; + use crate::{ + TabChangeEvent, + queries::custom_views_query, + widgets::{TabWidget, tab_widget::TabType}, + }; use super::{Tab, TabWidgetState}; @@ -187,7 +232,7 @@ mod tests { #[test] fn test_empty_state() { let app = TabWidget::default(); - let mut terminal = Terminal::new(TestBackend::new(75, 2)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(100, 2)).unwrap(); terminal .draw(|frame| frame.render_widget(&app, frame.area())) .unwrap(); @@ -209,10 +254,12 @@ mod tests { tabs: vec![ Tab { title: String::from("My Issues"), + tab_type: TabType::MyIssues, custom_view: None, }, Tab { title: String::from("Custom A"), + tab_type: TabType::CustomView, custom_view: Some(custom_views_query::ViewFragment { slug_id: Some("sluga".into()), color: Some("#fa0faf".to_string()), @@ -223,6 +270,7 @@ mod tests { }, Tab { title: String::from("Custom B"), + tab_type: TabType::CustomView, custom_view: Some(custom_views_query::ViewFragment { slug_id: Some("slugb".into()), id: "slugb".into(), @@ -235,7 +283,7 @@ mod tests { })), }; - let mut terminal = Terminal::new(TestBackend::new(75, 2)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(125, 2)).unwrap(); terminal .draw(|frame| frame.render_widget(&app, frame.area())) .unwrap(); @@ -249,8 +297,8 @@ mod tests { TabChangeEvent::FetchCustomViewIssues(custom_views_query::ViewFragment { name: "sluga".into(), slug_id: Some("sluga".into()), - color: Some("#fa0faf".to_string()), - icon: Some("Education".to_string()), + color: Some("#fa0faf".to_string()), + icon: Some("Education".to_string()), id: String::from("sluga") }) ); @@ -262,8 +310,8 @@ mod tests { TabChangeEvent::FetchCustomViewIssues(custom_views_query::ViewFragment { name: "slugb".into(), slug_id: Some("slugb".into()), - color: Some("#fa0faf".to_string()), - icon: Some("Education".to_string()), + color: Some("#fa0faf".to_string()), + icon: Some("Education".to_string()), id: String::from("slugb") }) ); @@ -275,8 +323,8 @@ mod tests { TabChangeEvent::FetchCustomViewIssues(custom_views_query::ViewFragment { name: "slugb".into(), slug_id: Some("slugb".into()), - color: Some("#fa0faf".to_string()), - icon: Some("Education".to_string()), + color: Some("#fa0faf".to_string()), + icon: Some("Education".to_string()), id: String::from("slugb") }) ); @@ -288,13 +336,26 @@ mod tests { TabChangeEvent::FetchCustomViewIssues(custom_views_query::ViewFragment { name: "sluga".into(), slug_id: Some("sluga".into()), - color: Some("#fa0faf".to_string()), - icon: Some("Education".to_string()), + color: Some("#fa0faf".to_string()), + icon: Some("Education".to_string()), id: String::from("sluga") }) ); let ev = app.handle_event(&create_key_event(KeyCode::BackTab)); assert_eq!(ev, TabChangeEvent::FetchMyIssues); + + + app.show_and_select_search_tab(); + terminal + .draw(|frame| frame.render_widget(&app, frame.area())) + .unwrap(); + + // make sure we've selected the last tab + assert_eq!(app.state.read().unwrap().selected_index, app.state.read().unwrap().tabs.len() - 1); + // make sure the search tab is there + assert_snapshot!(terminal.backend()); + + } }