From 27bf6c58ee006092e90f5c3318eb9c482cc116ec Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Wed, 9 Jul 2025 22:31:44 -0500 Subject: [PATCH 01/11] refactor from/to blocks to use duplicate macro --- Cargo.lock | 31 ++++++++++ Cargo.toml | 1 + src/main.rs | 161 +++++++++++++++++++--------------------------------- 3 files changed, 90 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e0f10c2..61ebf5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,6 +450,17 @@ 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" @@ -1188,6 +1199,7 @@ dependencies = [ "cli-clipboard", "color-eyre", "crossterm 0.29.0", + "duplicate", "graphql_client", "insta", "open", @@ -1572,6 +1584,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" @@ -2511,6 +2536,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..f440ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ chrono = "0.4.41" cli-clipboard = "0.4.0" color-eyre = "0.6.3" crossterm = { version = "0.29.0", 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" diff --git a/src/main.rs b/src/main.rs index b0e9d24..d0e71fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod iconmap; mod queries; mod widgets; +use duplicate::duplicate_item; use serde::{Deserialize, Serialize}; use widgets::{MyIssuesWidget, SelectedIssueWidget, TabWidget}; @@ -177,29 +178,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { title: item.title, identifier: item.identifier, @@ -227,18 +212,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { name: item.name, color: item.color, @@ -255,17 +235,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { is_me: item.is_me, display_name: item.display_name, @@ -281,17 +257,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { is_me: item.is_me, display_name: item.display_name, @@ -306,18 +278,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { name: item.name, icon: item.icon, @@ -331,20 +298,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { edges: item .edges @@ -360,16 +320,13 @@ 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 ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { node: item.node.into(), } @@ -382,8 +339,13 @@ pub struct IssueFragmentLabelsEdgesNode { pub name: String, } -impl From for IssueFragmentLabelsEdgesNode { - fn from(item: my_issues_query::IssueFragmentLabelsEdgesNode) -> Self { +#[duplicate_item( + from_type to_type; + [ custom_view_query::IssueFragmentLabelsEdgesNode ] [ IssueFragmentLabelsEdgesNode ]; + [ my_issues_query::IssueFragmentLabelsEdgesNode ] [ IssueFragmentLabelsEdgesNode ]; +)] +impl From for to_type { + fn from(item: from_type) -> Self { Self { color: item.color, name: item.name, @@ -391,11 +353,4 @@ impl From for IssueFragmentLabels } } -impl From for IssueFragmentLabelsEdgesNode { - fn from(item: custom_view_query::IssueFragmentLabelsEdgesNode) -> Self { - Self { - color: item.color, - name: item.name, - } - } -} + From 24f49c5420e8d4bc530698a92bfcfb43598a8acd Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Wed, 9 Jul 2025 22:39:01 -0500 Subject: [PATCH 02/11] add search query and from/to helpers --- src/main.rs | 7 +++++++ src/queries.rs | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main.rs b/src/main.rs index d0e71fa..6a1865e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,6 +216,7 @@ pub struct IssueFragmentState { 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 { @@ -239,6 +240,7 @@ pub struct IssueFragmentAssignee { 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 { @@ -261,6 +263,7 @@ pub struct IssueFragmentCreator { 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 { @@ -282,6 +285,7 @@ pub struct IssueFragmentProject { 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 { @@ -302,6 +306,7 @@ pub struct IssueFragmentLabels { 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 { @@ -324,6 +329,7 @@ pub struct IssueFragmentLabelsEdges { 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 { @@ -343,6 +349,7 @@ pub struct IssueFragmentLabelsEdgesNode { 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 { 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; + + + From 2259d1ea8ba765a033fba92033a9d25671d66df8 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Fri, 11 Jul 2025 22:19:07 -0500 Subject: [PATCH 03/11] working but need new tab and a lot of refactoring --- Cargo.lock | 92 ++++++------------- Cargo.toml | 4 +- src/main.rs | 59 +++++++++---- src/widgets/issue_list.rs | 160 ++++++++++++++++++++++++++++------ src/widgets/selected_issue.rs | 4 +- src/widgets/tab_widget.rs | 18 +++- 6 files changed, 218 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61ebf5d..a7a5150 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,15 +387,6 @@ 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" @@ -1160,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" @@ -1198,7 +1135,7 @@ dependencies = [ "chrono", "cli-clipboard", "color-eyre", - "crossterm 0.29.0", + "crossterm", "duplicate", "graphql_client", "insta", @@ -1208,7 +1145,9 @@ dependencies = [ "serde", "tokio", "tokio-stream", + "tui-input", "tui-markdown", + "tui-textarea", ] [[package]] @@ -1649,7 +1588,7 @@ dependencies = [ "bitflags 2.9.1", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools 0.13.0", @@ -2444,6 +2383,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" @@ -2460,6 +2409,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "unicase" version = "2.8.1" diff --git a/Cargo.toml b/Cargo.toml index f440ded..9499b67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ 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" @@ -23,7 +23,9 @@ 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"] } +tui-textarea = "0.7.0" [dev-dependencies] insta = "1.43.1" diff --git a/src/main.rs b/src/main.rs index 6a1865e..569d2bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +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, @@ -14,6 +14,7 @@ use std::{ use color_eyre::eyre::Result; +use crossterm::event::{Event, KeyCode, KeyEventKind}; use queries::*; use ratatui::{ DefaultTerminal, Frame, @@ -35,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(String), FetchMyIssues, } @@ -113,27 +123,41 @@ 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)); } + (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(term) => { + self.issue_list_widget + .run(TabChangeEvent::SearchIssues(String::from(term))); + }*/ + _ => (), } } }; @@ -182,6 +206,7 @@ pub struct IssueFragment { 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 { @@ -359,5 +384,3 @@ impl From for to_type { } } } - - diff --git a/src/widgets/issue_list.rs b/src/widgets/issue_list.rs index 70a91da..7b60800 100644 --- a/src/widgets/issue_list.rs +++ b/src/widgets/issue_list.rs @@ -3,46 +3,49 @@ 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, - api::LinearClient, - iconmap, - queries::{ - CustomViewQuery, MyIssuesQuery, custom_view_query, custom_views_query, - my_issues_query::{self}, - }, + api::LinearClient, iconmap, queries::{ + custom_view_query, custom_views_query, my_issues_query::{self}, search_query, CustomViewQuery, MyIssuesQuery, SearchQuery + }, InputMode, IssueFragment, LoadingState, LtEvent, TabChangeEvent }; #[derive(Debug, Default)] 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 +72,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 +100,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 +156,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 +170,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 +184,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 +195,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 +204,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(term) => { + tokio::spawn(this.search_issues(term)); + } _ => (), } } - 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::{Normal, Editing}; + + 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,6 +270,18 @@ 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("to select issue "), @@ -228,7 +303,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 +337,47 @@ 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); + // Do I need to store this at the parent level so I can pass events via handle_event? + /*let mut search_input = TextArea::new(vec![self.search_input_value.clone()]); + search_input.set_cursor_line_style(Style::default()); + search_input.set_placeholder_text("Search here"); + search_input.set_block(block2); + search_input.render(search_area, buf);*/ + input.render(search_area, buf); + // Ratatui hides the cursor unless it's explicitly set. Position the cursor past the + // end of the input text and one line down from the border to the input line + //let x = self.input.visual_cursor().max(scroll) - scroll + 1; + //.set_cursor_position((search_area.x + x as u16, search_area.y + 1)); + } } } #[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 crate::{ - widgets::{self, selected_issue::tests::make_issue, MyIssuesWidget}, LtEvent + LtEvent, + widgets::{self, MyIssuesWidget, selected_issue::tests::make_issue}, }; fn create_key_event(key: char) -> crossterm::event::Event { @@ -309,6 +411,8 @@ mod tests { ]; let app = MyIssuesWidget { selected_view_id: String::from("my_issues"), + show_search_input: false, + search_input_value: String::from(""), state: Arc::new(RwLock::new(widgets::issue_list::MyIssuesWidgetState { loading_state: crate::LoadingState::Loaded, list_state: ListState::default(), 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/tab_widget.rs b/src/widgets/tab_widget.rs index b6b56dd..f12a191 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -31,9 +31,16 @@ impl Default for TabWidget { TabWidget { state: Arc::new(RwLock::new(TabWidgetState { selected_index: 0, - tabs: vec![Tab { + tabs: vec![ + Tab { + title: String::from("Search Results"), + custom_view: None, + visible: false, + } + ,Tab { title: String::from("My Issues"), custom_view: None, + visible: true, }], })), } @@ -44,6 +51,7 @@ impl Default for TabWidget { struct Tab { title: String, custom_view: Option, + visible: bool } impl TabWidget { @@ -63,6 +71,7 @@ impl TabWidget { for custom_view in data.custom_views.nodes.iter() { state.tabs.push(Tab { title: custom_view.name.clone(), + visible: true, custom_view: Some(custom_view.clone()), }); } @@ -136,7 +145,10 @@ impl Widget for &TabWidget { .unwrap() .tabs .iter() - .map(|tab| { + .filter_map(|tab| { + if !tab.visible { + return None + } let (icon, color) = if let Some(view) = &tab.custom_view { ( match &view.icon { @@ -152,7 +164,7 @@ impl Widget for &TabWidget { (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() + Some(Span::from(format!("{} {}", icon, tab.title.clone().bold())).fg(project_color).bold()) }) .collect::>(), ) From 7a6e0e01b19acfd1dd07c1f050cb0b9eabc4aa08 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Fri, 11 Jul 2025 23:15:24 -0500 Subject: [PATCH 04/11] ux almost there --- src/iconmap.rs | 1 + src/main.rs | 9 ++-- src/widgets/issue_list.rs | 17 ++++--- src/widgets/tab_widget.rs | 99 +++++++++++++++++++++++++++------------ 4 files changed, 84 insertions(+), 42 deletions(-) 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 569d2bb..d0c6974 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,7 +55,7 @@ pub enum LtEvent<'a> { pub enum TabChangeEvent { None, FetchCustomViewIssues(custom_views_query::ViewFragment), - SearchIssues(String), + SearchIssues, FetchMyIssues, } @@ -153,10 +153,9 @@ impl App { self.selected_issue_widget .set_selected_issue(selected_issue); } - /*LtEvent::SearchIssues(term) => { - self.issue_list_widget - .run(TabChangeEvent::SearchIssues(String::from(term))); - }*/ + LtEvent::SearchIssues(term) => { + self.tab_widget.show_and_select_search_tab(); + } _ => (), } } diff --git a/src/widgets/issue_list.rs b/src/widgets/issue_list.rs index 7b60800..4c8c81b 100644 --- a/src/widgets/issue_list.rs +++ b/src/widgets/issue_list.rs @@ -206,6 +206,7 @@ impl MyIssuesWidget { pub fn run(&self, tab_change_event: TabChangeEvent) { let this = self.clone(); + //println!("Tab {:#?}", tab_change_event); match tab_change_event { TabChangeEvent::FetchMyIssues => { tokio::spawn(this.fetch_my_issues()); @@ -213,8 +214,8 @@ impl MyIssuesWidget { TabChangeEvent::FetchCustomViewIssues(view) => { tokio::spawn(this.fetch_custom_view(view)); } - TabChangeEvent::SearchIssues(term) => { - tokio::spawn(this.search_issues(term)); + TabChangeEvent::SearchIssues => { + self.set_selected_view(String::from("search_results")); } _ => (), } @@ -374,10 +375,10 @@ mod tests { use crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers}; use insta::assert_snapshot; use ratatui::{Terminal, backend::TestBackend, widgets::ListState}; + use tui_input::Input; use crate::{ - LtEvent, - widgets::{self, MyIssuesWidget, selected_issue::tests::make_issue}, + widgets::{self, selected_issue::tests::make_issue, MyIssuesWidget}, InputMode, LtEvent }; fn create_key_event(key: char) -> crossterm::event::Event { @@ -391,7 +392,7 @@ mod tests { #[test] fn test_empty_state() { - let app = MyIssuesWidget::default(); + let mut app = MyIssuesWidget::default(); let mut terminal = Terminal::new(TestBackend::new(40, 20)).unwrap(); terminal .draw(|frame| frame.render_widget(&app, frame.area())) @@ -409,12 +410,14 @@ 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)]), })), diff --git a/src/widgets/tab_widget.rs b/src/widgets/tab_widget.rs index f12a191..6c96565 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -31,27 +31,29 @@ impl Default for TabWidget { TabWidget { state: Arc::new(RwLock::new(TabWidgetState { selected_index: 0, - tabs: vec![ - Tab { - title: String::from("Search Results"), - custom_view: None, - visible: false, - } - ,Tab { + tabs: vec![Tab { title: String::from("My Issues"), + tab_type: TabType::MyIssues, custom_view: None, - visible: true, }], })), } } } +#[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, - visible: bool } impl TabWidget { @@ -71,7 +73,7 @@ impl TabWidget { for custom_view in data.custom_views.nodes.iter() { state.tabs.push(Tab { title: custom_view.name.clone(), - visible: true, + tab_type: TabType::CustomView, custom_view: Some(custom_view.clone()), }); } @@ -82,18 +84,39 @@ 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 hide_search_tab(&self) { + let mut state = self.state.write().unwrap(); + if state.tabs[state.selected_index].tab_type == TabType::SearchResults { + state.tabs.pop(); + } } pub fn handle_event(&self, event: &Event) -> crate::TabChangeEvent { @@ -101,13 +124,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 => { @@ -117,7 +146,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, @@ -145,10 +180,7 @@ impl Widget for &TabWidget { .unwrap() .tabs .iter() - .filter_map(|tab| { - if !tab.visible { - return None - } + .map(|tab| { let (icon, color) = if let Some(view) = &tab.custom_view { ( match &view.icon { @@ -160,11 +192,15 @@ impl Widget for &TabWidget { _ => "#ffffff".to_string(), }, ) + } else if tab.tab_type == TabType::SearchResults { + (iconmap::ico_to_nf("Magnify"), String::from("#FFFFFF")) } else { (iconmap::ico_to_nf("Home"), String::from("#FFFFFF")) }; let project_color = Color::from_str(&color).unwrap(); - Some(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::>(), ) @@ -183,7 +219,7 @@ mod tests { use insta::assert_snapshot; use ratatui::{Terminal, backend::TestBackend}; - use crate::{TabChangeEvent, queries::custom_views_query, widgets::TabWidget}; + use crate::{queries::custom_views_query, widgets::{tab_widget::TabType, TabWidget}, TabChangeEvent}; use super::{Tab, TabWidgetState}; @@ -221,10 +257,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()), @@ -235,6 +273,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(), @@ -261,8 +300,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") }) ); @@ -274,8 +313,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") }) ); @@ -287,8 +326,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") }) ); @@ -300,8 +339,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") }) ); From 803fcb18beb9441b5c78c807791cebf62e0af504 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Fri, 11 Jul 2025 23:21:31 -0500 Subject: [PATCH 05/11] some tests --- src/widgets/issue_list.rs | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/widgets/issue_list.rs b/src/widgets/issue_list.rs index 4c8c81b..d2233b5 100644 --- a/src/widgets/issue_list.rs +++ b/src/widgets/issue_list.rs @@ -206,7 +206,6 @@ impl MyIssuesWidget { pub fn run(&self, tab_change_event: TabChangeEvent) { let this = self.clone(); - //println!("Tab {:#?}", tab_change_event); match tab_change_event { TabChangeEvent::FetchMyIssues => { tokio::spawn(this.fetch_my_issues()); @@ -350,17 +349,7 @@ impl Widget for &MyIssuesWidget { let input = Paragraph::new(value) .style(Color::Yellow) .block(block2); - // Do I need to store this at the parent level so I can pass events via handle_event? - /*let mut search_input = TextArea::new(vec![self.search_input_value.clone()]); - search_input.set_cursor_line_style(Style::default()); - search_input.set_placeholder_text("Search here"); - search_input.set_block(block2); - search_input.render(search_area, buf);*/ input.render(search_area, buf); - // Ratatui hides the cursor unless it's explicitly set. Position the cursor past the - // end of the input text and one line down from the border to the input line - //let x = self.input.visual_cursor().max(scroll) - scroll + 1; - //.set_cursor_position((search_area.x + x as u16, search_area.y + 1)); } } } @@ -452,7 +441,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); } } From 432ebc9510d1a9aef2ba93d07c367e6cb33fe675 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Fri, 11 Jul 2025 23:23:32 -0500 Subject: [PATCH 06/11] snapshot, query --- Cargo.lock | 12 ----- Cargo.toml | 1 - src/queries/search.graphql | 45 +++++++++++++++++++ ...ets__issue_list__tests__with_issues-3.snap | 24 ++++++++++ 4 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 src/queries/search.graphql create mode 100644 src/widgets/snapshots/lt__widgets__issue_list__tests__with_issues-3.snap diff --git a/Cargo.lock b/Cargo.lock index a7a5150..5ec04c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,7 +1147,6 @@ dependencies = [ "tokio-stream", "tui-input", "tui-markdown", - "tui-textarea", ] [[package]] @@ -2409,17 +2408,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tui-textarea" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" -dependencies = [ - "crossterm", - "ratatui", - "unicode-width 0.2.0", -] - [[package]] name = "unicase" version = "2.8.1" diff --git a/Cargo.toml b/Cargo.toml index 9499b67..67d7385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ 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"] } -tui-textarea = "0.7.0" [dev-dependencies] insta = "1.43.1" 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/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..77b1017 --- /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 ───────────────┘" From 552acac505a12c39a29692cec7a20329816ac9b9 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Fri, 11 Jul 2025 23:25:17 -0500 Subject: [PATCH 07/11] formatting --- src/widgets/issue_list.rs | 22 +++++++++++++--------- src/widgets/tab_widget.rs | 10 +++++++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/widgets/issue_list.rs b/src/widgets/issue_list.rs index d2233b5..82cdd90 100644 --- a/src/widgets/issue_list.rs +++ b/src/widgets/issue_list.rs @@ -20,9 +20,14 @@ use tui_input::backend::crossterm::EventHandler; use std::collections::HashMap; use crate::{ - api::LinearClient, iconmap, queries::{ - custom_view_query, custom_views_query, my_issues_query::{self}, search_query, CustomViewQuery, MyIssuesQuery, SearchQuery - }, InputMode, IssueFragment, LoadingState, LtEvent, TabChangeEvent + InputMode, IssueFragment, LoadingState, LtEvent, TabChangeEvent, + api::LinearClient, + iconmap, + queries::{ + CustomViewQuery, MyIssuesQuery, SearchQuery, custom_view_query, custom_views_query, + my_issues_query::{self}, + search_query, + }, }; #[derive(Debug, Default)] @@ -107,7 +112,7 @@ impl MyIssuesWidget { 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() + term: search_term.to_string(), }; match client.query(SearchQuery, variables).await { Ok(data) => { @@ -226,7 +231,7 @@ impl MyIssuesWidget { } if let Event::Key(key) = event { if key.kind == KeyEventKind::Press { - use InputMode::{Normal, Editing}; + use InputMode::{Editing, Normal}; match (self.input_mode.clone(), key.code) { (Normal, KeyCode::Char('j')) => { @@ -346,9 +351,7 @@ impl Widget for &MyIssuesWidget { } else { self.input.value().to_string() }; - let input = Paragraph::new(value) - .style(Color::Yellow) - .block(block2); + let input = Paragraph::new(value).style(Color::Yellow).block(block2); input.render(search_area, buf); } } @@ -367,7 +370,8 @@ mod tests { use tui_input::Input; use crate::{ - widgets::{self, selected_issue::tests::make_issue, MyIssuesWidget}, InputMode, LtEvent + InputMode, LtEvent, + widgets::{self, MyIssuesWidget, selected_issue::tests::make_issue}, }; fn create_key_event(key: char) -> crossterm::event::Event { diff --git a/src/widgets/tab_widget.rs b/src/widgets/tab_widget.rs index 6c96565..9dccdde 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -134,7 +134,7 @@ impl TabWidget { return match state.tabs[index].tab_type { TabType::MyIssues => TabChangeEvent::FetchMyIssues, TabType::SearchResults => TabChangeEvent::SearchIssues, - _ => TabChangeEvent::None + _ => TabChangeEvent::None, }; } } @@ -150,7 +150,7 @@ impl TabWidget { return match state.tabs[state.selected_index].tab_type { TabType::MyIssues => TabChangeEvent::FetchMyIssues, TabType::SearchResults => TabChangeEvent::SearchIssues, - _ => TabChangeEvent::None + _ => TabChangeEvent::None, }; } } @@ -219,7 +219,11 @@ mod tests { use insta::assert_snapshot; use ratatui::{Terminal, backend::TestBackend}; - use crate::{queries::custom_views_query, widgets::{tab_widget::TabType, TabWidget}, TabChangeEvent}; + use crate::{ + TabChangeEvent, + queries::custom_views_query, + widgets::{TabWidget, tab_widget::TabType}, + }; use super::{Tab, TabWidgetState}; From 6441a70192cc0f4439f25ad18e9fe419842ef264 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Fri, 11 Jul 2025 23:30:07 -0500 Subject: [PATCH 08/11] dead code cleanup --- src/main.rs | 2 +- src/widgets/tab_widget.rs | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index d0c6974..2b98357 100644 --- a/src/main.rs +++ b/src/main.rs @@ -153,7 +153,7 @@ impl App { self.selected_issue_widget .set_selected_issue(selected_issue); } - LtEvent::SearchIssues(term) => { + LtEvent::SearchIssues(_) => { self.tab_widget.show_and_select_search_tab(); } _ => (), diff --git a/src/widgets/tab_widget.rs b/src/widgets/tab_widget.rs index 9dccdde..5c47162 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -112,13 +112,6 @@ impl TabWidget { state.selected_index = state.tabs.len() - 1; } - pub fn hide_search_tab(&self) { - let mut state = self.state.write().unwrap(); - if state.tabs[state.selected_index].tab_type == TabType::SearchResults { - state.tabs.pop(); - } - } - pub fn handle_event(&self, event: &Event) -> crate::TabChangeEvent { if let Event::Key(key) = event { if key.kind == KeyEventKind::Press { From 99e4b440966d934cca2c21ec284989614b004e79 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Sat, 12 Jul 2025 10:00:06 -0500 Subject: [PATCH 09/11] testing testing testing --- README.md | 5 ++- src/widgets/issue_list.rs | 9 +++-- ...dgets__issue_list__tests__empty_state.snap | 40 +++++++++---------- ...ets__issue_list__tests__with_issues-2.snap | 40 +++++++++---------- ...ets__issue_list__tests__with_issues-3.snap | 40 +++++++++---------- ...dgets__issue_list__tests__with_issues.snap | 40 +++++++++---------- ...dgets__tab_widget__tests__empty_state.snap | 4 +- ...idgets__tab_widget__tests__multi_tabs.snap | 4 +- src/widgets/tab_widget.rs | 17 +++++++- 9 files changed, 108 insertions(+), 91 deletions(-) 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/widgets/issue_list.rs b/src/widgets/issue_list.rs index 82cdd90..1251988 100644 --- a/src/widgets/issue_list.rs +++ b/src/widgets/issue_list.rs @@ -288,8 +288,11 @@ impl Widget for &MyIssuesWidget { }; 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() { @@ -386,7 +389,7 @@ mod tests { #[test] fn test_empty_state() { let mut app = MyIssuesWidget::default(); - 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(); @@ -415,7 +418,7 @@ mod tests { 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(); 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 index 77b1017..6780150 100644 --- 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 @@ -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.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.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 5c47162..94f4424 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -232,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(); @@ -283,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(); @@ -344,5 +344,18 @@ mod tests { 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()); + + } } From dcbfb613ec3b355019558d90ecb563254b2be969 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Sat, 12 Jul 2025 10:01:58 -0500 Subject: [PATCH 10/11] snapshot --- .../lt__widgets__tab_widget__tests__multi_tabs-2.snap | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/widgets/snapshots/lt__widgets__tab_widget__tests__multi_tabs-2.snap 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 " +" " From 362b191f60c535d9c5b45ec453b5c36dc0268e85 Mon Sep 17 00:00:00 2001 From: Mark DiMarco Date: Sat, 12 Jul 2025 10:06:56 -0500 Subject: [PATCH 11/11] last few touches --- src/main.rs | 3 +++ src/widgets/tab_widget.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 2b98357..a55bf46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -131,6 +131,9 @@ impl App { (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(); diff --git a/src/widgets/tab_widget.rs b/src/widgets/tab_widget.rs index 94f4424..dc91bd1 100644 --- a/src/widgets/tab_widget.rs +++ b/src/widgets/tab_widget.rs @@ -186,7 +186,7 @@ impl Widget for &TabWidget { }, ) } else if tab.tab_type == TabType::SearchResults { - (iconmap::ico_to_nf("Magnify"), String::from("#FFFFFF")) + (iconmap::ico_to_nf("Magnify"), Color::Yellow.to_string()) } else { (iconmap::ico_to_nf("Home"), String::from("#FFFFFF")) };