From 969b7ed1158bf6687dbd47ab401f18bb913e0c12 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 18:58:43 +0900 Subject: [PATCH 01/74] chore: bump up version to v0.7.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5a7f0a..ce6069f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -843,7 +843,7 @@ dependencies = [ [[package]] name = "jnv" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index ace8337..ba1cf70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jnv" -version = "0.7.0" +version = "0.7.1" authors = ["ynqa "] edition = "2021" description = "JSON navigator and interactive filter leveraging jq" From d2bf0981e757aee6ada3ad76b8428456d681fa3c Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 19:17:55 +0900 Subject: [PATCH 02/74] chore: Box::leak => Vec --- src/json.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/json.rs b/src/json.rs index 348b0d7..81efc39 100644 --- a/src/json.rs +++ b/src/json.rs @@ -20,20 +20,21 @@ use crate::{ // #[derive(Clone)] pub struct Json { state: jsonstream::State, - json: &'static [serde_json::Value], + json: Vec, keybinds: JsonViewerKeybinds, } impl Json { pub fn new( formatter: JsonStreamConfig, - input_stream: &'static [serde_json::Value], + input_stream: Vec, keybinds: JsonViewerKeybinds, ) -> anyhow::Result { + let stream = JsonStream::new(input_stream.iter()); Ok(Self { json: input_stream, state: jsonstream::State { - stream: JsonStream::new(input_stream.iter()), + stream, config: formatter, }, keybinds, @@ -100,7 +101,7 @@ impl Visualizer for Json { area: (u16, u16), input: String, ) -> (Option, Option) { - match run_jaq(&input, self.json) { + match run_jaq(&input, &self.json) { Ok(ret) => { let mut guide = None; if ret.iter().all(|val| *val == Value::Null) { @@ -138,7 +139,7 @@ impl Visualizer for Json { fn run_jaq( query: &str, - json_stream: &'static [serde_json::Value], + json_stream: &[serde_json::Value], ) -> anyhow::Result> { let arena = Arena::default(); let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs())); @@ -204,9 +205,7 @@ impl ViewProvider for JsonStreamProvider { item: &'static str, keybinds: JsonViewerKeybinds, ) -> anyhow::Result { - let stream = self.deserialize_json(item)?; - let static_stream = Box::leak(stream.into_boxed_slice()); - Json::new(std::mem::take(&mut self.formatter), static_stream, keybinds) + Json::new(std::mem::take(&mut self.formatter), self.deserialize_json(item)?, keybinds) } } From 37a9aff14eb6b70f0635ee18a06854426691e7fe Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 19:53:49 +0900 Subject: [PATCH 03/74] chore: remove init.rs --- src/json.rs | 93 +++++++++++++++++++++++++++++-------------- src/main.rs | 5 +-- src/processor.rs | 12 ++---- src/processor/init.rs | 62 ----------------------------- src/prompt.rs | 20 +++++----- 5 files changed, 80 insertions(+), 112 deletions(-) delete mode 100644 src/processor/init.rs diff --git a/src/json.rs b/src/json.rs index 81efc39..191a6d6 100644 --- a/src/json.rs +++ b/src/json.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use jaq_core::{ load::{Arena, File, Loader}, Compiler, Ctx, RcIter, @@ -5,19 +7,35 @@ use jaq_core::{ use jaq_json::Val; use promkit_widgets::{ - core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, - jsonstream::{self, config::Config as JsonStreamConfig, jsonz, JsonStream}, + core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, + jsonstream::{self, jsonz, JsonStream}, serde_json::{self, Deserializer, Value}, status::{self, Severity}, }; +use tokio::sync::Mutex; use crate::{ - config::JsonViewerKeybinds, - processor::{ViewProvider, Visualizer}, + config::{JsonConfig, JsonViewerKeybinds}, + processor::{Context, State, Visualizer}, + prompt::Index, search::SearchProvider, }; -// #[derive(Clone)] +/// Deserialize JSON string into a vector of serde_json::Value. +/// If max_streams is given, only deserialize up to that many JSON values. +fn deserialize_json( + json_str: &str, + max_streams: Option, +) -> anyhow::Result> { + let deserializer: serde_json::StreamDeserializer<'_, serde_json::de::StrRead<'_>, Value> = + Deserializer::from_str(json_str).into_iter::(); + let results = match max_streams { + Some(l) => deserializer.take(l).collect::, _>>(), + None => deserializer.collect::, _>>(), + }; + results.map_err(anyhow::Error::from) +} + pub struct Json { state: jsonstream::State, json: Vec, @@ -25,18 +43,50 @@ pub struct Json { } impl Json { - pub fn new( - formatter: JsonStreamConfig, - input_stream: Vec, + pub async fn initialize( + input: &'static str, + config: JsonConfig, keybinds: JsonViewerKeybinds, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, ) -> anyhow::Result { + // Set state to Loading to prevent overwriting by spinner frames in terminal. + { + let mut shared_ctx = shared_ctx.lock().await; + if let Some(task) = shared_ctx.current_task.take() { + task.abort(); + } + shared_ctx.state = State::Loading; + } + + let input_stream = deserialize_json(input, config.max_streams)?; let stream = JsonStream::new(input_stream.iter()); + let state = jsonstream::State { + stream, + config: config.stream, + }; + + // Set state to Idle to prevent overwriting by spinner frames in terminal. + { + let mut shared_ctx = shared_ctx.lock().await; + shared_ctx.state = State::Idle; + } + + { + let shared_ctx = shared_ctx.lock().await; + let area = shared_ctx.area; + drop(shared_ctx); + + // TODO: error handling + let _ = shared_renderer + .update([(Index::Processor, state.create_graphemes(area.0, area.1))]) + .render() + .await; + } + Ok(Self { json: input_stream, - state: jsonstream::State { - stream, - config: formatter, - }, + state, keybinds, }) } @@ -87,10 +137,6 @@ impl Visualizer for Json { self.state.config.format_raw_json(self.state.stream.rows()) } - async fn create_init_pane(&mut self, area: (u16, u16)) -> StyledGraphemes { - self.state.create_graphemes(area.0, area.1) - } - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { self.operate(event); self.state.create_graphemes(area.0, area.1) @@ -175,12 +221,12 @@ fn run_jaq( #[derive(Clone)] pub struct JsonStreamProvider { - formatter: JsonStreamConfig, + formatter: jsonstream::config::Config, max_streams: Option, } impl JsonStreamProvider { - pub fn new(formatter: JsonStreamConfig, max_streams: Option) -> Self { + pub fn new(formatter: jsonstream::config::Config, max_streams: Option) -> Self { Self { formatter, max_streams, @@ -198,17 +244,6 @@ impl JsonStreamProvider { } } -#[async_trait::async_trait] -impl ViewProvider for JsonStreamProvider { - async fn provide( - &mut self, - item: &'static str, - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result { - Json::new(std::mem::take(&mut self.formatter), self.deserialize_json(item)?, keybinds) - } -} - #[async_trait::async_trait] impl SearchProvider for JsonStreamProvider { async fn provide( diff --git a/src/main.rs b/src/main.rs index 38e2ee7..c72ed47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,9 +20,7 @@ use json::JsonStreamProvider; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod processor; -use processor::{ - init::ViewInitializer, monitor::ContextMonitor, Context, Processor, ViewProvider, Visualizer, -}; +use processor::{monitor::ContextMonitor, Context, Processor, Visualizer}; mod prompt; mod search; use search::{IncrementalSearcher, SearchProvider}; @@ -206,6 +204,7 @@ async fn main() -> anyhow::Result<()> { // TODO: put all logics here. let maybe_output = prompt::run( item, + config.json, config.reactivity_control, provider, editor, diff --git a/src/processor.rs b/src/processor.rs index 3e588e0..a75759e 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -6,9 +6,6 @@ use promkit_widgets::core::{ }; use tokio::{sync::Mutex, task::JoinHandle}; -pub mod init; -pub use init::ViewProvider; - use crate::prompt::Index; pub mod monitor; @@ -17,7 +14,7 @@ fn empty_pane() -> StyledGraphemes { } #[derive(PartialEq)] -enum State { +pub enum State { Idle, Loading, Processing, @@ -26,7 +23,6 @@ enum State { #[async_trait] pub trait Visualizer: Send + Sync + 'static { async fn content_to_copy(&self) -> String; - async fn create_init_pane(&mut self, area: (u16, u16)) -> StyledGraphemes; async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes; async fn create_panes_from_query( &mut self, @@ -36,9 +32,9 @@ pub trait Visualizer: Send + Sync + 'static { } pub struct Context { - state: State, - area: (u16, u16), - current_task: Option>, + pub state: State, + pub area: (u16, u16), + pub current_task: Option>, } impl Context { diff --git a/src/processor/init.rs b/src/processor/init.rs deleted file mode 100644 index 4b8e6f6..0000000 --- a/src/processor/init.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use promkit_widgets::core::render::SharedRenderer; -use tokio::sync::Mutex; - -use super::{Context, State, Visualizer}; -use crate::{config::JsonViewerKeybinds, prompt::Index}; - -#[async_trait] -pub trait ViewProvider { - async fn provide( - &mut self, - item: &'static str, - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result; -} - -pub struct ViewInitializer { - shared: Arc>, -} - -impl ViewInitializer { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } - - pub async fn initialize<'a, T: ViewProvider>( - &self, - provider: &'a mut T, - item: &'static str, - area: (u16, u16), - shared_renderer: SharedRenderer, - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result { - { - let mut shared_state = self.shared.lock().await; - if let Some(task) = shared_state.current_task.take() { - task.abort(); - } - shared_state.state = State::Loading; - } - - let mut visualizer = provider.provide(item, keybinds).await?; - let pane = visualizer.create_init_pane(area).await; - - // Set state to Idle to prevent overwriting by spinner frames in terminal. - { - let mut shared_state = self.shared.lock().await; - shared_state.state = State::Idle; - } - { - // TODO: error handling - let _ = shared_renderer - .update([(Index::Processor, pane)]) - .render() - .await; - } - - Ok(visualizer) - } -} diff --git a/src/prompt.rs b/src/prompt.rs index 52afd75..d7883be 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -26,9 +26,9 @@ use tokio::{ }; use crate::{ - config::{Keybinds, ReactivityControl}, - Context, ContextMonitor, Editor, Processor, SearchProvider, ViewInitializer, ViewProvider, - Visualizer, + config::{JsonConfig, Keybinds, ReactivityControl}, + json::Json, + Context, ContextMonitor, Editor, Processor, SearchProvider, Visualizer, }; fn spawn_debouncer( @@ -91,10 +91,11 @@ pub enum Index { } #[allow(clippy::too_many_arguments)] -pub async fn run( +pub async fn run( item: &'static str, + json_config: JsonConfig, reactivity_control: ReactivityControl, - provider: &mut T, + _provider: &mut T, editor: Editor, loading_suggestions_task: JoinHandle>, no_hint: bool, @@ -164,13 +165,12 @@ pub async fn run( let shared_editor = Arc::new(RwLock::new(editor)); let processor = Processor::new(ctx.clone()); let context_monitor = ContextMonitor::new(ctx.clone()); - let initializer = ViewInitializer::new(ctx.clone()); - let initializing = initializer.initialize( - provider, + let initializing = Json::initialize( item, - size, - shared_renderer.clone(), + json_config, keybinds.on_json_viewer, + shared_renderer.clone(), + ctx.clone(), ); let main_task: JoinHandle> = { From 21fb13821bd726366a502980f636f6715dd1891b Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 20:14:49 +0900 Subject: [PATCH 04/74] fix: remove provider from args --- src/main.rs | 1 - src/prompt.rs | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index c72ed47..df16fbf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,7 +206,6 @@ async fn main() -> anyhow::Result<()> { item, config.json, config.reactivity_control, - provider, editor, loading_suggestions_task, config.no_hint, diff --git a/src/prompt.rs b/src/prompt.rs index d7883be..faafc9b 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -28,7 +28,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, json::Json, - Context, ContextMonitor, Editor, Processor, SearchProvider, Visualizer, + Context, ContextMonitor, Editor, Processor, Visualizer, }; fn spawn_debouncer( @@ -91,11 +91,10 @@ pub enum Index { } #[allow(clippy::too_many_arguments)] -pub async fn run( +pub async fn run( item: &'static str, json_config: JsonConfig, reactivity_control: ReactivityControl, - _provider: &mut T, editor: Editor, loading_suggestions_task: JoinHandle>, no_hint: bool, From 7793d80066638f8c1505043a981419f72fe58e2d Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 20:50:37 +0900 Subject: [PATCH 05/74] chore; remove provider --- src/json.rs | 49 +++++++++++-------------------------------------- src/main.rs | 18 ++++++++---------- src/search.rs | 9 +++++---- 3 files changed, 24 insertions(+), 52 deletions(-) diff --git a/src/json.rs b/src/json.rs index 191a6d6..1a1baa1 100644 --- a/src/json.rs +++ b/src/json.rs @@ -18,9 +18,19 @@ use crate::{ config::{JsonConfig, JsonViewerKeybinds}, processor::{Context, State, Visualizer}, prompt::Index, - search::SearchProvider, }; +/// Get all JSON paths from the input JSON string, +/// respecting the max_streams limit if provided. +pub async fn get_all_paths( + json_str: &str, + max_streams: Option, +) -> anyhow::Result> { + let stream = deserialize_json(json_str, max_streams)?; + let paths = jsonz::get_all_paths(stream.iter()).collect::>(); + Ok(paths.into_iter()) +} + /// Deserialize JSON string into a vector of serde_json::Value. /// If max_streams is given, only deserialize up to that many JSON values. fn deserialize_json( @@ -218,40 +228,3 @@ fn run_jaq( Ok(ret) } - -#[derive(Clone)] -pub struct JsonStreamProvider { - formatter: jsonstream::config::Config, - max_streams: Option, -} - -impl JsonStreamProvider { - pub fn new(formatter: jsonstream::config::Config, max_streams: Option) -> Self { - Self { - formatter, - max_streams, - } - } - - fn deserialize_json(&self, json_str: &str) -> anyhow::Result> { - let deserializer: serde_json::StreamDeserializer<'_, serde_json::de::StrRead<'_>, Value> = - Deserializer::from_str(json_str).into_iter::(); - let results = match self.max_streams { - Some(l) => deserializer.take(l).collect::, _>>(), - None => deserializer.collect::, _>>(), - }; - results.map_err(anyhow::Error::from) - } -} - -#[async_trait::async_trait] -impl SearchProvider for JsonStreamProvider { - async fn provide( - &mut self, - item: &str, - ) -> anyhow::Result + Send>> { - let stream = self.deserialize_json(item)?; - let static_stream = Box::leak(stream.into_boxed_slice()); - Ok(Box::new(jsonz::get_all_paths(static_stream.iter()))) - } -} diff --git a/src/main.rs b/src/main.rs index df16fbf..c7eaa62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,14 +16,13 @@ mod editor; use editor::Editor; mod config; mod json; -use json::JsonStreamProvider; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod processor; use processor::{monitor::ContextMonitor, Context, Processor, Visualizer}; mod prompt; mod search; -use search::{IncrementalSearcher, SearchProvider}; +use search::IncrementalSearcher; use crate::config::DEFAULT_CONFIG; @@ -150,6 +149,7 @@ fn determine_config_file(config_path: Option) -> anyhow::Result anyhow::Result<()> { let args = Args::parse(); let input = parse_input(&args)?; + let input: &'static str = Box::leak(input.into_boxed_str()); let config = determine_config_file(args.config_file) .and_then(|config_file| { @@ -179,13 +179,11 @@ async fn main() -> anyhow::Result<()> { config: config.editor.on_focus.clone(), }; - let provider = - &mut JsonStreamProvider::new(config.json.stream.clone(), config.json.max_streams); - - let item = Box::leak(input.into_boxed_str()); - - let loading_suggestions_task = - searcher.spawn_load_task(provider, item, config.completion.search_load_chunk_size); + let loading_suggestions_task = searcher.spawn_load_task( + &input, + config.json.max_streams, + config.completion.search_load_chunk_size, + ); // TODO: re-consider put editor_task of prompt::run into Editor construction time. // Overall, there are several cases where it would be sufficient to @@ -203,7 +201,7 @@ async fn main() -> anyhow::Result<()> { // TODO: put all logics here. let maybe_output = prompt::run( - item, + &input, config.json, config.reactivity_control, editor, diff --git a/src/search.rs b/src/search.rs index b46869f..5c8a4d7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -11,6 +11,8 @@ use tokio::{ task::JoinHandle, }; +use crate::json; + #[async_trait] pub trait SearchProvider: Clone + Send + 'static { async fn provide( @@ -49,18 +51,17 @@ impl IncrementalSearcher { } } - pub fn spawn_load_task( + pub fn spawn_load_task( &self, - provider: &mut T, item: &'static str, + max_streams: Option, chunk_size: usize, ) -> JoinHandle> { let shared_set = self.shared_set.clone(); let shared_load_state = self.shared_load_state.clone(); - let mut provider = provider.clone(); tokio::spawn(async move { let mut batch = Vec::with_capacity(chunk_size); - let iter = provider.provide(item).await?; + let iter = json::get_all_paths(item, max_streams).await?; for v in iter { batch.push(v); From 502ccfe725c8336537340b6b81048c1d2af777da Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 21:02:09 +0900 Subject: [PATCH 06/74] chore: json => jsonruntime --- src/main.rs | 2 +- src/prompt.rs | 24 ++++++++++++------------ src/{json.rs => runtime.rs} | 6 +++--- src/search.rs | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) rename src/{json.rs => runtime.rs} (98%) diff --git a/src/main.rs b/src/main.rs index c7eaa62..4c8f834 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use promkit_widgets::{ mod editor; use editor::Editor; mod config; -mod json; +mod runtime; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod processor; diff --git a/src/prompt.rs b/src/prompt.rs index faafc9b..ebab67f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -27,7 +27,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, - json::Json, + runtime::JsonRuntime, Context, ContextMonitor, Editor, Processor, Visualizer, }; @@ -164,7 +164,7 @@ pub async fn run( let shared_editor = Arc::new(RwLock::new(editor)); let processor = Processor::new(ctx.clone()); let context_monitor = ContextMonitor::new(ctx.clone()); - let initializing = Json::initialize( + let initializing = JsonRuntime::initialize( item, json_config, keybinds.on_json_viewer, @@ -356,17 +356,17 @@ pub async fn run( }) }; - let shared_visualizer = Arc::new(Mutex::new(initializing.await?)); + let shared_runtime = Arc::new(Mutex::new(initializing.await?)); let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); - let shared_visualizer = shared_visualizer.clone(); + let shared_runtime = shared_runtime.clone(); tokio::spawn(async move { loop { tokio::select! { Some(()) = processor_copy_rx.recv() => { - let visualizer = shared_visualizer.lock().await; - let guide = copy_to_clipboard(&visualizer.content_to_copy().await); + let runtime = shared_runtime.lock().await; + let guide = copy_to_clipboard(&runtime.content_to_copy().await); if !no_hint { let size = terminal::size()?; let pane = guide.create_graphemes(size.0, size.1); @@ -377,8 +377,8 @@ pub async fn run( } Some(event) = processor_event_rx.recv() => { let pane = { - let mut visualizer = shared_visualizer.lock().await; - visualizer.create_pane_from_event((size.0, size.1), &event).await + let mut runtime = shared_runtime.lock().await; + runtime.create_pane_from_event((size.0, size.1), &event).await }; { shared_renderer.update([ @@ -388,7 +388,7 @@ pub async fn run( } Some(query) = last_query_rx.recv() => { processor.render_result( - shared_visualizer.clone(), + shared_runtime.clone(), query, shared_renderer.clone(), ).await; @@ -414,7 +414,7 @@ pub async fn run( editor.text() }; processor.render_on_resize( - shared_visualizer.clone(), + shared_runtime.clone(), area, text, shared_renderer.clone(), @@ -432,8 +432,8 @@ pub async fn run( main_task.await??; let output = if write_to_stdout { - let visualizer = shared_visualizer.lock().await; - Some(visualizer.content_to_copy().await) + let runtime = shared_runtime.lock().await; + Some(runtime.content_to_copy().await) } else { None }; diff --git a/src/json.rs b/src/runtime.rs similarity index 98% rename from src/json.rs rename to src/runtime.rs index 1a1baa1..19658bf 100644 --- a/src/json.rs +++ b/src/runtime.rs @@ -46,13 +46,13 @@ fn deserialize_json( results.map_err(anyhow::Error::from) } -pub struct Json { +pub struct JsonRuntime { state: jsonstream::State, json: Vec, keybinds: JsonViewerKeybinds, } -impl Json { +impl JsonRuntime { pub async fn initialize( input: &'static str, config: JsonConfig, @@ -142,7 +142,7 @@ impl Json { } #[async_trait::async_trait] -impl Visualizer for Json { +impl Visualizer for JsonRuntime { async fn content_to_copy(&self) -> String { self.state.config.format_raw_json(self.state.stream.rows()) } diff --git a/src/search.rs b/src/search.rs index 5c8a4d7..4ac6540 100644 --- a/src/search.rs +++ b/src/search.rs @@ -11,7 +11,7 @@ use tokio::{ task::JoinHandle, }; -use crate::json; +use crate::runtime; #[async_trait] pub trait SearchProvider: Clone + Send + 'static { @@ -61,7 +61,7 @@ impl IncrementalSearcher { let shared_load_state = self.shared_load_state.clone(); tokio::spawn(async move { let mut batch = Vec::with_capacity(chunk_size); - let iter = json::get_all_paths(item, max_streams).await?; + let iter = runtime::get_all_paths(item, max_streams).await?; for v in iter { batch.push(v); From df4ff8773a0256f19a72525ec273738e6488c94e Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 21:07:24 +0900 Subject: [PATCH 07/74] chore: content_to_copy => formatted_content --- src/processor.rs | 1 - src/prompt.rs | 4 ++-- src/runtime.rs | 9 +++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/processor.rs b/src/processor.rs index a75759e..6266210 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -22,7 +22,6 @@ pub enum State { #[async_trait] pub trait Visualizer: Send + Sync + 'static { - async fn content_to_copy(&self) -> String; async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes; async fn create_panes_from_query( &mut self, diff --git a/src/prompt.rs b/src/prompt.rs index ebab67f..ab8cc22 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -366,7 +366,7 @@ pub async fn run( tokio::select! { Some(()) = processor_copy_rx.recv() => { let runtime = shared_runtime.lock().await; - let guide = copy_to_clipboard(&runtime.content_to_copy().await); + let guide = copy_to_clipboard(&runtime.formatted_content()); if !no_hint { let size = terminal::size()?; let pane = guide.create_graphemes(size.0, size.1); @@ -433,7 +433,7 @@ pub async fn run( let output = if write_to_stdout { let runtime = shared_runtime.lock().await; - Some(runtime.content_to_copy().await) + Some(runtime.formatted_content()) } else { None }; diff --git a/src/runtime.rs b/src/runtime.rs index 19658bf..3cda8b9 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -101,6 +101,11 @@ impl JsonRuntime { }) } + /// Get the formatted content of current JSON stream + pub fn formatted_content(&self) -> String { + self.state.config.format_raw_json(self.state.stream.rows()) + } + fn operate(&mut self, event: &Event) { match event { // Move up. @@ -143,10 +148,6 @@ impl JsonRuntime { #[async_trait::async_trait] impl Visualizer for JsonRuntime { - async fn content_to_copy(&self) -> String { - self.state.config.format_raw_json(self.state.stream.rows()) - } - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { self.operate(event); self.state.create_graphemes(area.0, area.1) From 92226395d832c611f5e01383b213d452176cbe30 Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 21:08:46 +0900 Subject: [PATCH 08/74] docs: for initialize --- src/runtime.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/runtime.rs b/src/runtime.rs index 3cda8b9..cb11b02 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -53,6 +53,7 @@ pub struct JsonRuntime { } impl JsonRuntime { + /// Initialize JsonRuntime while deserializing the input JSON string and rendering the initial view. pub async fn initialize( input: &'static str, config: JsonConfig, From ef1e4cd0d5c64757c2322fc7abf59db53f2d0aee Mon Sep 17 00:00:00 2001 From: ynqa Date: Thu, 26 Mar 2026 21:22:13 +0900 Subject: [PATCH 09/74] chore: create json.rs including json-related operations --- src/json.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/runtime.rs | 77 ++++---------------------------------------------- src/search.rs | 4 +-- 4 files changed, 80 insertions(+), 74 deletions(-) create mode 100644 src/json.rs diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..f12a377 --- /dev/null +++ b/src/json.rs @@ -0,0 +1,72 @@ +use jaq_core::{ + load::{Arena, File, Loader}, + Compiler, Ctx, RcIter, +}; +use jaq_json::Val; + +use promkit_widgets::{ + jsonstream::jsonz, + serde_json::{self, Deserializer, Value}, +}; + +/// Get all JSON paths from the input JSON string, +/// respecting the max_streams limit if provided. +pub async fn get_all_paths( + json_str: &str, + max_streams: Option, +) -> anyhow::Result> { + let stream = deserialize(json_str, max_streams)?; + let paths = jsonz::get_all_paths(stream.iter()).collect::>(); + Ok(paths.into_iter()) +} + +/// Deserialize JSON string into a vector of serde_json::Value. +/// If max_streams is given, only deserialize up to that many JSON values. +pub fn deserialize( + json_str: &str, + max_streams: Option, +) -> anyhow::Result> { + let deserializer: serde_json::StreamDeserializer<'_, serde_json::de::StrRead<'_>, Value> = + Deserializer::from_str(json_str).into_iter::(); + let results = match max_streams { + Some(l) => deserializer.take(l).collect::, _>>(), + None => deserializer.collect::, _>>(), + }; + results.map_err(anyhow::Error::from) +} + +pub fn run_jaq( + query: &str, + json_stream: &[serde_json::Value], +) -> anyhow::Result> { + let arena = Arena::default(); + let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs())); + let modules = loader + .load( + &arena, + File { + code: query, + path: (), + }, + ) + .map_err(|errs| anyhow::anyhow!("jq filter parsing failed: {errs:?}"))?; + let filter = Compiler::default() + .with_funs(jaq_std::funs().chain(jaq_json::funs())) + .compile(modules) + .map_err(|errs| anyhow::anyhow!("jq filter compilation failed: {errs:?}"))?; + + let mut ret = Vec::::new(); + + for input in json_stream { + let inputs = RcIter::new(core::iter::empty()); + let out = filter.run((Ctx::new([], &inputs), Val::from(input.clone()))); + for item in out { + match item { + Ok(val) => ret.push(val.into()), + Err(err) => return Err(anyhow::anyhow!("jq filter execution failed: {err}")), + } + } + } + + Ok(ret) +} diff --git a/src/main.rs b/src/main.rs index 4c8f834..3cb0f58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ use processor::{monitor::ContextMonitor, Context, Processor, Visualizer}; mod prompt; mod search; use search::IncrementalSearcher; +mod json; use crate::config::DEFAULT_CONFIG; diff --git a/src/runtime.rs b/src/runtime.rs index cb11b02..914df32 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,51 +1,20 @@ use std::sync::Arc; -use jaq_core::{ - load::{Arena, File, Loader}, - Compiler, Ctx, RcIter, -}; -use jaq_json::Val; - use promkit_widgets::{ core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, - jsonstream::{self, jsonz, JsonStream}, - serde_json::{self, Deserializer, Value}, + jsonstream::{self, JsonStream}, + serde_json::{self, Value}, status::{self, Severity}, }; use tokio::sync::Mutex; use crate::{ config::{JsonConfig, JsonViewerKeybinds}, + json, processor::{Context, State, Visualizer}, prompt::Index, }; -/// Get all JSON paths from the input JSON string, -/// respecting the max_streams limit if provided. -pub async fn get_all_paths( - json_str: &str, - max_streams: Option, -) -> anyhow::Result> { - let stream = deserialize_json(json_str, max_streams)?; - let paths = jsonz::get_all_paths(stream.iter()).collect::>(); - Ok(paths.into_iter()) -} - -/// Deserialize JSON string into a vector of serde_json::Value. -/// If max_streams is given, only deserialize up to that many JSON values. -fn deserialize_json( - json_str: &str, - max_streams: Option, -) -> anyhow::Result> { - let deserializer: serde_json::StreamDeserializer<'_, serde_json::de::StrRead<'_>, Value> = - Deserializer::from_str(json_str).into_iter::(); - let results = match max_streams { - Some(l) => deserializer.take(l).collect::, _>>(), - None => deserializer.collect::, _>>(), - }; - results.map_err(anyhow::Error::from) -} - pub struct JsonRuntime { state: jsonstream::State, json: Vec, @@ -70,7 +39,7 @@ impl JsonRuntime { shared_ctx.state = State::Loading; } - let input_stream = deserialize_json(input, config.max_streams)?; + let input_stream = json::deserialize(input, config.max_streams)?; let stream = JsonStream::new(input_stream.iter()); let state = jsonstream::State { stream, @@ -159,7 +128,7 @@ impl Visualizer for JsonRuntime { area: (u16, u16), input: String, ) -> (Option, Option) { - match run_jaq(&input, &self.json) { + match json::run_jaq(&input, &self.json) { Ok(ret) => { let mut guide = None; if ret.iter().all(|val| *val == Value::Null) { @@ -194,39 +163,3 @@ impl Visualizer for JsonRuntime { } } } - -fn run_jaq( - query: &str, - json_stream: &[serde_json::Value], -) -> anyhow::Result> { - let arena = Arena::default(); - let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs())); - let modules = loader - .load( - &arena, - File { - code: query, - path: (), - }, - ) - .map_err(|errs| anyhow::anyhow!("jq filter parsing failed: {errs:?}"))?; - let filter = Compiler::default() - .with_funs(jaq_std::funs().chain(jaq_json::funs())) - .compile(modules) - .map_err(|errs| anyhow::anyhow!("jq filter compilation failed: {errs:?}"))?; - - let mut ret = Vec::::new(); - - for input in json_stream { - let inputs = RcIter::new(core::iter::empty()); - let out = filter.run((Ctx::new([], &inputs), Val::from(input.clone()))); - for item in out { - match item { - Ok(val) => ret.push(val.into()), - Err(err) => return Err(anyhow::anyhow!("jq filter execution failed: {err}")), - } - } - } - - Ok(ret) -} diff --git a/src/search.rs b/src/search.rs index 4ac6540..5c8a4d7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -11,7 +11,7 @@ use tokio::{ task::JoinHandle, }; -use crate::runtime; +use crate::json; #[async_trait] pub trait SearchProvider: Clone + Send + 'static { @@ -61,7 +61,7 @@ impl IncrementalSearcher { let shared_load_state = self.shared_load_state.clone(); tokio::spawn(async move { let mut batch = Vec::with_capacity(chunk_size); - let iter = runtime::get_all_paths(item, max_streams).await?; + let iter = json::get_all_paths(item, max_streams).await?; for v in iter { batch.push(v); From 40b612959c25afb9620744048ba2ef130c23b4e1 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 27 Mar 2026 18:23:07 +0900 Subject: [PATCH 10/74] wip; --- src/runtime.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/runtime.rs b/src/runtime.rs index 914df32..ebf9d2a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use promkit_widgets::{ - core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, + core::{Widget, crossterm::{self, event::Event}, grapheme::StyledGraphemes, render::SharedRenderer}, jsonstream::{self, JsonStream}, serde_json::{self, Value}, status::{self, Severity}, @@ -15,10 +15,22 @@ use crate::{ prompt::Index, }; +/// Represent the trigger for rendering views. +pub enum RenderTrigger { + /// User actions such as key presses + UserAction(crossterm::event::Event), + /// Query updates such as new jq filter input + QueryUpdated { query: String }, + /// Terminal resize events + Resized { area: (u16, u16), query: String }, +} + pub struct JsonRuntime { state: jsonstream::State, json: Vec, keybinds: JsonViewerKeybinds, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, } impl JsonRuntime { @@ -68,6 +80,8 @@ impl JsonRuntime { json: input_stream, state, keybinds, + shared_renderer, + shared_ctx, }) } @@ -76,6 +90,73 @@ impl JsonRuntime { self.state.config.format_raw_json(self.state.stream.rows()) } + /// Render views based on the given trigger. + pub async fn render(&mut self, trigger: RenderTrigger) { + match trigger { + _ => {}, + RenderTrigger::Resized { area, query } => { + { + let mut ctx = self.shared_ctx.lock().await; + // Update the terminal area in shared context for accurate rendering. + ctx.area = area; + + // Abort any ongoing processing task to prevent race conditions + // and ensure the new render reflects the latest terminal size. + if let Some(task) = ctx.current_task.take() { + task.abort(); + } + } + + let process_task = self.spawn_process_task(query); + + // Store the new processing task handle in shared context + // to allow future cancellation if needed. + { + let mut ctx = self.shared_ctx.lock().await; + ctx.current_task = Some(process_task); + } + } + } + } + + fn spawn_process_task( + &self, + query: String, + ) -> tokio::task::JoinHandle<()> { + let shared: Arc> = self.shared_ctx.clone(); + let shared_renderer = self.shared_renderer.clone(); + tokio::spawn(async move { + { + let mut shared_state = shared.lock().await; + shared_state.state = State::Processing; + } + + let (maybe_guide, maybe_resp) = { + let shared_state = shared.lock().await; + let area = shared_state.area; + drop(shared_state); + + self.create_panes_from_query(area, query).await + }; + + // Set state to Idle to prevent overwriting by spinner frames in terminal. + { + let mut shared_state = shared.lock().await; + shared_state.state = State::Idle; + } + { + // TODO: error handling + let _ = shared_renderer + .update([ + (Index::Guide, maybe_guide.unwrap_or(StyledGraphemes::default())), + (Index::Processor, maybe_resp.unwrap_or(StyledGraphemes::default())), + ]) + .render() + .await; + } + }) + } + fn operate(&mut self, event: &Event) { match event { // Move up. From 56893a0eac3fb690ed589f943c38e7e1f5ed2677 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 27 Mar 2026 22:33:49 +0900 Subject: [PATCH 11/74] chore: runtime => state --- Cargo.lock | 123 ++----------- Cargo.toml | 3 - src/main.rs | 4 +- src/processor.rs | 141 --------------- src/processor/monitor.rs | 26 --- src/prompt.rs | 53 +++--- src/runtime.rs | 246 -------------------------- src/search.rs | 9 - src/state.rs | 361 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 399 insertions(+), 567 deletions(-) delete mode 100644 src/processor.rs delete mode 100644 src/processor/monitor.rs delete mode 100644 src/runtime.rs create mode 100644 src/state.rs diff --git a/Cargo.lock b/Cargo.lock index ce6069f..25f817d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,17 +103,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -161,9 +150,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -336,72 +325,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -575,12 +498,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -758,12 +675,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "image" version = "0.25.10" @@ -847,9 +758,7 @@ version = "0.7.1" dependencies = [ "anyhow", "arboard", - "async-trait", "clap", - "derive_builder", "dirs", "duration-string", "futures", @@ -861,7 +770,6 @@ dependencies = [ "serde", "termcfg", "tokio", - "tokio-stream", "toml", ] @@ -941,9 +849,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1390,9 +1298,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -1526,17 +1434,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -1601,9 +1498,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -1995,9 +1892,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index ba1cf70..8d3fde2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,17 +11,14 @@ readme = "README.md" [dependencies] anyhow = "1.0.102" arboard = { version = "3.6.1", features = ["wayland-data-control"] } -async-trait = "0.1.89" clap = { version = "4.6.0", features = ["derive"] } duration-string = { version = "0.5.3", features = ["serde"] } -derive_builder = "0.20.2" dirs = "6.0.0" futures = "0.3.32" rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.50.0", features = ["full"] } -tokio-stream = "0.1.18" toml = "0.9.8" # jaq dependencies diff --git a/src/main.rs b/src/main.rs index 3cb0f58..3274c40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,11 +15,9 @@ use promkit_widgets::{ mod editor; use editor::Editor; mod config; -mod runtime; +mod state; mod stdout_redirect; use stdout_redirect::StdoutRedirect; -mod processor; -use processor::{monitor::ContextMonitor, Context, Processor, Visualizer}; mod prompt; mod search; use search::IncrementalSearcher; diff --git a/src/processor.rs b/src/processor.rs deleted file mode 100644 index 6266210..0000000 --- a/src/processor.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use promkit_widgets::core::{ - crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, -}; -use tokio::{sync::Mutex, task::JoinHandle}; - -use crate::prompt::Index; -pub mod monitor; - -fn empty_pane() -> StyledGraphemes { - StyledGraphemes::default() -} - -#[derive(PartialEq)] -pub enum State { - Idle, - Loading, - Processing, -} - -#[async_trait] -pub trait Visualizer: Send + Sync + 'static { - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes; - async fn create_panes_from_query( - &mut self, - area: (u16, u16), - query: String, - ) -> (Option, Option); -} - -pub struct Context { - pub state: State, - pub area: (u16, u16), - pub current_task: Option>, -} - -impl Context { - pub fn new(area: (u16, u16)) -> Self { - Self { - state: State::Idle, - area, - current_task: None, - } - } -} - -pub struct Processor { - shared: Arc>, -} - -impl Processor { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } - - fn spawn_process_task( - &self, - query: String, - shared_visualizer: Arc>, - shared_renderer: SharedRenderer, - ) -> JoinHandle<()> { - let shared = self.shared.clone(); - tokio::spawn(async move { - { - let mut shared_state = shared.lock().await; - shared_state.state = State::Processing; - } - - let (maybe_guide, maybe_resp) = { - let shared_state = shared.lock().await; - let area = shared_state.area; - drop(shared_state); - - let mut visualizer = shared_visualizer.lock().await; - visualizer.create_panes_from_query(area, query).await - }; - - // Set state to Idle to prevent overwriting by spinner frames in terminal. - { - let mut shared_state = shared.lock().await; - shared_state.state = State::Idle; - } - { - // TODO: error handling - let _ = shared_renderer - .update([ - (Index::Guide, maybe_guide.unwrap_or_else(empty_pane)), - (Index::Processor, maybe_resp.unwrap_or_else(empty_pane)), - ]) - .render() - .await; - } - }) - } - - pub async fn render_on_resize( - &self, - shared_visualizer: Arc>, - area: (u16, u16), - query: String, - shared_renderer: SharedRenderer, - ) { - { - let mut shared_state = self.shared.lock().await; - shared_state.area = area; - if let Some(task) = shared_state.current_task.take() { - task.abort(); - } - } - - let process_task = self.spawn_process_task(query, shared_visualizer, shared_renderer); - - { - let mut shared_state = self.shared.lock().await; - shared_state.current_task = Some(process_task); - } - } - - pub async fn render_result( - &self, - shared_visualizer: Arc>, - query: String, - shared_renderer: SharedRenderer, - ) { - { - let mut shared_state = self.shared.lock().await; - if let Some(task) = shared_state.current_task.take() { - task.abort(); - } - } - - let process_task = self.spawn_process_task(query, shared_visualizer, shared_renderer); - - { - let mut shared_state = self.shared.lock().await; - shared_state.current_task = Some(process_task); - } - } -} diff --git a/src/processor/monitor.rs b/src/processor/monitor.rs deleted file mode 100644 index 7775d5f..0000000 --- a/src/processor/monitor.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::{future::Future, sync::Arc}; - -use promkit_widgets::spinner; -use tokio::sync::Mutex; - -use super::{Context, State}; - -pub struct ContextMonitor { - shared: Arc>, -} - -impl ContextMonitor { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } -} - -impl spinner::State for ContextMonitor { - fn is_idle(&self) -> impl Future + Send { - let shared = self.shared.clone(); - async move { - let context = shared.lock().await; - context.state == State::Idle - } - } -} diff --git a/src/prompt.rs b/src/prompt.rs index ab8cc22..9684a45 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -27,8 +27,8 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, - runtime::JsonRuntime, - Context, ContextMonitor, Editor, Processor, Visualizer, + state::{initialize, render, Context, ContextMonitor, RenderTrigger}, + Editor, }; fn spawn_debouncer( @@ -162,9 +162,8 @@ pub async fn run( let mut text_diff = [editor.text(), editor.text()]; let shared_editor = Arc::new(RwLock::new(editor)); - let processor = Processor::new(ctx.clone()); let context_monitor = ContextMonitor::new(ctx.clone()); - let initializing = JsonRuntime::initialize( + let initializing = initialize( item, json_config, keybinds.on_json_viewer, @@ -356,16 +355,17 @@ pub async fn run( }) }; - let shared_runtime = Arc::new(Mutex::new(initializing.await?)); + let shared_viewer_state = Arc::new(Mutex::new(initializing.await?)); let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); - let shared_runtime = shared_runtime.clone(); + let shared_viewer_state = shared_viewer_state.clone(); + let ctx = ctx.clone(); tokio::spawn(async move { loop { tokio::select! { Some(()) = processor_copy_rx.recv() => { - let runtime = shared_runtime.lock().await; + let runtime = shared_viewer_state.lock().await; let guide = copy_to_clipboard(&runtime.formatted_content()); if !no_hint { let size = terminal::size()?; @@ -376,22 +376,22 @@ pub async fn run( } } Some(event) = processor_event_rx.recv() => { - let pane = { - let mut runtime = shared_runtime.lock().await; - runtime.create_pane_from_event((size.0, size.1), &event).await - }; - { - shared_renderer.update([ - (Index::Processor, pane), - ]).render().await?; - } + render( + shared_viewer_state.clone(), + shared_renderer.clone(), + ctx.clone(), + RenderTrigger::UserAction(event), + ) + .await; } Some(query) = last_query_rx.recv() => { - processor.render_result( - shared_runtime.clone(), - query, + render( + shared_viewer_state.clone(), shared_renderer.clone(), - ).await; + ctx.clone(), + RenderTrigger::QueryUpdated { query }, + ) + .await; } Some(area) = last_resize_rx.recv() => { let (editor_pane, guide_pane, searcher_pane) = { @@ -413,12 +413,13 @@ pub async fn run( let editor = shared_editor.read().await; editor.text() }; - processor.render_on_resize( - shared_runtime.clone(), - area, - text, + render( + shared_viewer_state.clone(), shared_renderer.clone(), - ).await; + ctx.clone(), + RenderTrigger::Resized { area, query: text }, + ) + .await; } else => { break @@ -432,7 +433,7 @@ pub async fn run( main_task.await??; let output = if write_to_stdout { - let runtime = shared_runtime.lock().await; + let runtime = shared_viewer_state.lock().await; Some(runtime.formatted_content()) } else { None diff --git a/src/runtime.rs b/src/runtime.rs deleted file mode 100644 index ebf9d2a..0000000 --- a/src/runtime.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::sync::Arc; - -use promkit_widgets::{ - core::{Widget, crossterm::{self, event::Event}, grapheme::StyledGraphemes, render::SharedRenderer}, - jsonstream::{self, JsonStream}, - serde_json::{self, Value}, - status::{self, Severity}, -}; -use tokio::sync::Mutex; - -use crate::{ - config::{JsonConfig, JsonViewerKeybinds}, - json, - processor::{Context, State, Visualizer}, - prompt::Index, -}; - -/// Represent the trigger for rendering views. -pub enum RenderTrigger { - /// User actions such as key presses - UserAction(crossterm::event::Event), - /// Query updates such as new jq filter input - QueryUpdated { query: String }, - /// Terminal resize events - Resized { area: (u16, u16), query: String }, -} - -pub struct JsonRuntime { - state: jsonstream::State, - json: Vec, - keybinds: JsonViewerKeybinds, - shared_renderer: SharedRenderer, - shared_ctx: Arc>, -} - -impl JsonRuntime { - /// Initialize JsonRuntime while deserializing the input JSON string and rendering the initial view. - pub async fn initialize( - input: &'static str, - config: JsonConfig, - keybinds: JsonViewerKeybinds, - shared_renderer: SharedRenderer, - shared_ctx: Arc>, - ) -> anyhow::Result { - // Set state to Loading to prevent overwriting by spinner frames in terminal. - { - let mut shared_ctx = shared_ctx.lock().await; - if let Some(task) = shared_ctx.current_task.take() { - task.abort(); - } - shared_ctx.state = State::Loading; - } - - let input_stream = json::deserialize(input, config.max_streams)?; - let stream = JsonStream::new(input_stream.iter()); - let state = jsonstream::State { - stream, - config: config.stream, - }; - - // Set state to Idle to prevent overwriting by spinner frames in terminal. - { - let mut shared_ctx = shared_ctx.lock().await; - shared_ctx.state = State::Idle; - } - - { - let shared_ctx = shared_ctx.lock().await; - let area = shared_ctx.area; - drop(shared_ctx); - - // TODO: error handling - let _ = shared_renderer - .update([(Index::Processor, state.create_graphemes(area.0, area.1))]) - .render() - .await; - } - - Ok(Self { - json: input_stream, - state, - keybinds, - shared_renderer, - shared_ctx, - }) - } - - /// Get the formatted content of current JSON stream - pub fn formatted_content(&self) -> String { - self.state.config.format_raw_json(self.state.stream.rows()) - } - - /// Render views based on the given trigger. - pub async fn render(&mut self, trigger: RenderTrigger) { - match trigger { - _ => {}, - RenderTrigger::Resized { area, query } => { - { - let mut ctx = self.shared_ctx.lock().await; - // Update the terminal area in shared context for accurate rendering. - ctx.area = area; - - // Abort any ongoing processing task to prevent race conditions - // and ensure the new render reflects the latest terminal size. - if let Some(task) = ctx.current_task.take() { - task.abort(); - } - } - - let process_task = self.spawn_process_task(query); - - // Store the new processing task handle in shared context - // to allow future cancellation if needed. - { - let mut ctx = self.shared_ctx.lock().await; - ctx.current_task = Some(process_task); - } - } - } - } - - fn spawn_process_task( - &self, - query: String, - ) -> tokio::task::JoinHandle<()> { - let shared: Arc> = self.shared_ctx.clone(); - let shared_renderer = self.shared_renderer.clone(); - tokio::spawn(async move { - { - let mut shared_state = shared.lock().await; - shared_state.state = State::Processing; - } - - let (maybe_guide, maybe_resp) = { - let shared_state = shared.lock().await; - let area = shared_state.area; - drop(shared_state); - - self.create_panes_from_query(area, query).await - }; - - // Set state to Idle to prevent overwriting by spinner frames in terminal. - { - let mut shared_state = shared.lock().await; - shared_state.state = State::Idle; - } - { - // TODO: error handling - let _ = shared_renderer - .update([ - (Index::Guide, maybe_guide.unwrap_or(StyledGraphemes::default())), - (Index::Processor, maybe_resp.unwrap_or(StyledGraphemes::default())), - ]) - .render() - .await; - } - }) - } - - fn operate(&mut self, event: &Event) { - match event { - // Move up. - event if self.keybinds.up.contains(event) => { - self.state.stream.up(); - } - - // Move down. - event if self.keybinds.down.contains(event) => { - self.state.stream.down(); - } - - // Move to head - event if self.keybinds.move_to_head.contains(event) => { - self.state.stream.head(); - } - - // Move to tail - event if self.keybinds.move_to_tail.contains(event) => { - self.state.stream.tail(); - } - - // Toggle collapse/expand - event if self.keybinds.toggle.contains(event) => { - self.state.stream.toggle(); - } - - event if self.keybinds.expand.contains(event) => { - self.state.stream.set_nodes_visibility(false); - } - - event if self.keybinds.collapse.contains(event) => { - self.state.stream.set_nodes_visibility(true); - } - - _ => (), - } - } -} - -#[async_trait::async_trait] -impl Visualizer for JsonRuntime { - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { - self.operate(event); - self.state.create_graphemes(area.0, area.1) - } - - async fn create_panes_from_query( - &mut self, - area: (u16, u16), - input: String, - ) -> (Option, Option) { - match json::run_jaq(&input, &self.json) { - Ok(ret) => { - let mut guide = None; - if ret.iter().all(|val| *val == Value::Null) { - guide = Some( - status::State::new( - format!( - "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" - ), - Severity::Warning, - ) - .create_graphemes(area.0, area.1), - ); - - self.state.stream = JsonStream::new(self.json.iter()); - } else { - self.state.stream = JsonStream::new(ret.iter()); - } - - (guide, Some(self.state.create_graphemes(area.0, area.1))) - } - Err(e) => { - self.state.stream = JsonStream::new(self.json.iter()); - - ( - Some( - status::State::new(format!("jq failed: `{e}`"), Severity::Error) - .create_graphemes(area.0, area.1), - ), - Some(self.state.create_graphemes(area.0, area.1)), - ) - } - } - } -} diff --git a/src/search.rs b/src/search.rs index 5c8a4d7..2632dd7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,7 +1,6 @@ use std::{collections::BTreeSet, sync::Arc}; use anyhow::anyhow; -use async_trait::async_trait; use promkit_widgets::{ core::{grapheme::StyledGraphemes, Widget}, listbox::{self, Listbox}, @@ -13,14 +12,6 @@ use tokio::{ use crate::json; -#[async_trait] -pub trait SearchProvider: Clone + Send + 'static { - async fn provide( - &mut self, - item: &str, - ) -> anyhow::Result + Send>>; -} - #[derive(Clone, Default)] pub struct LoadState { pub loaded: bool, diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..9d6fdb7 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,361 @@ +use std::{future::Future, sync::Arc}; + +use promkit_widgets::{ + core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, + jsonstream::{self, JsonStream}, + serde_json::{self, Value}, + spinner, + status::{self, Severity}, +}; +use tokio::{sync::Mutex, task::JoinHandle}; + +use crate::{ + config::{JsonConfig, JsonViewerKeybinds}, + json, + prompt::Index, +}; + +/// Represent the trigger for rendering views. +pub enum RenderTrigger { + /// User actions such as key presses + UserAction(Event), + /// Query updates such as new jq filter input + QueryUpdated { query: String }, + /// Terminal resize events + Resized { area: (u16, u16), query: String }, +} + +#[derive(PartialEq)] +pub enum State { + Idle, + Loading, + Processing, +} + +pub struct Context { + pub state: State, + pub area: (u16, u16), + pub current_task: Option>, +} + +impl Context { + pub fn new(area: (u16, u16)) -> Self { + Self { + state: State::Idle, + area, + current_task: None, + } + } +} + +pub struct ContextMonitor { + shared: Arc>, +} + +impl ContextMonitor { + pub fn new(shared: Arc>) -> Self { + Self { shared } + } +} + +impl spinner::State for ContextMonitor { + fn is_idle(&self) -> impl Future + Send { + let shared = self.shared.clone(); + async move { + let context = shared.lock().await; + context.state == State::Idle + } + } +} + +pub struct JsonViewerState { + state: jsonstream::State, + json: Vec, + keybinds: JsonViewerKeybinds, +} + +pub async fn initialize( + input: &'static str, + config: JsonConfig, + keybinds: JsonViewerKeybinds, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, +) -> anyhow::Result { + // Set state to Loading to prevent overwriting by spinner frames in terminal. + { + let mut shared_ctx = shared_ctx.lock().await; + if let Some(task) = shared_ctx.current_task.take() { + task.abort(); + } + shared_ctx.state = State::Loading; + } + + let input_stream = json::deserialize(input, config.max_streams)?; + let stream = JsonStream::new(input_stream.iter()); + let state = jsonstream::State { + stream, + config: config.stream, + }; + + // Set state to Idle to prevent overwriting by spinner frames in terminal. + { + let mut shared_ctx = shared_ctx.lock().await; + shared_ctx.state = State::Idle; + } + + { + let shared_ctx = shared_ctx.lock().await; + let area = shared_ctx.area; + drop(shared_ctx); + + // TODO: error handling + let _ = shared_renderer + .update([(Index::Processor, state.create_graphemes(area.0, area.1))]) + .render() + .await; + } + + Ok(JsonViewerState { + json: input_stream, + state, + keybinds, + }) +} + +pub async fn render( + shared_viewer_state: Arc>, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, + trigger: RenderTrigger, +) { + match trigger { + RenderTrigger::UserAction(event) => { + render_from_user_action(shared_viewer_state, shared_renderer, shared_ctx, event).await; + } + RenderTrigger::QueryUpdated { query } => { + start_query_processing(shared_viewer_state, shared_renderer, shared_ctx, query).await; + } + RenderTrigger::Resized { area, query } => { + resize_and_start_query_processing( + shared_viewer_state, + shared_renderer, + shared_ctx, + area, + query, + ) + .await; + } + } +} + +async fn render_from_user_action( + shared_viewer_state: Arc>, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, + event: Event, +) { + let area = { + let ctx = shared_ctx.lock().await; + ctx.area + }; + + let pane = { + let mut runtime = shared_viewer_state.lock().await; + runtime.create_pane_from_event(area, &event).await + }; + + // TODO: error handling + let _ = shared_renderer + .update([(Index::Processor, pane)]) + .render() + .await; +} + +async fn start_query_processing( + shared_viewer_state: Arc>, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, + query: String, +) { + { + let mut shared_state = shared_ctx.lock().await; + if let Some(task) = shared_state.current_task.take() { + task.abort(); + } + } + + let process_task = spawn_query_processing_task( + shared_viewer_state.clone(), + shared_ctx.clone(), + shared_renderer, + query, + ); + + { + let mut shared_state = shared_ctx.lock().await; + shared_state.current_task = Some(process_task); + } +} + +async fn resize_and_start_query_processing( + shared_viewer_state: Arc>, + shared_renderer: SharedRenderer, + shared_ctx: Arc>, + area: (u16, u16), + query: String, +) { + { + let mut shared_state = shared_ctx.lock().await; + shared_state.area = area; + if let Some(task) = shared_state.current_task.take() { + task.abort(); + } + } + + let process_task = spawn_query_processing_task( + shared_viewer_state.clone(), + shared_ctx.clone(), + shared_renderer, + query, + ); + + { + let mut shared_state = shared_ctx.lock().await; + shared_state.current_task = Some(process_task); + } +} + +fn spawn_query_processing_task( + shared_viewer_state: Arc>, + shared_ctx: Arc>, + shared_renderer: SharedRenderer, + query: String, +) -> JoinHandle<()> { + tokio::spawn(async move { + { + let mut shared_state = shared_ctx.lock().await; + shared_state.state = State::Processing; + } + + let (maybe_guide, maybe_resp) = { + let shared_state = shared_ctx.lock().await; + let area = shared_state.area; + drop(shared_state); + + let mut runtime = shared_viewer_state.lock().await; + runtime.create_panes_from_query(area, query).await + }; + + // Set state to Idle to prevent overwriting by spinner frames in terminal. + { + let mut shared_state = shared_ctx.lock().await; + shared_state.state = State::Idle; + } + + // TODO: error handling + let _ = shared_renderer + .update([ + ( + Index::Guide, + maybe_guide.unwrap_or(StyledGraphemes::default()), + ), + ( + Index::Processor, + maybe_resp.unwrap_or(StyledGraphemes::default()), + ), + ]) + .render() + .await; + }) +} + +impl JsonViewerState { + /// Get the formatted content of current JSON stream. + pub fn formatted_content(&self) -> String { + self.state.config.format_raw_json(self.state.stream.rows()) + } + + fn operate(&mut self, event: &Event) { + match event { + // Move up. + event if self.keybinds.up.contains(event) => { + self.state.stream.up(); + } + + // Move down. + event if self.keybinds.down.contains(event) => { + self.state.stream.down(); + } + + // Move to head + event if self.keybinds.move_to_head.contains(event) => { + self.state.stream.head(); + } + + // Move to tail + event if self.keybinds.move_to_tail.contains(event) => { + self.state.stream.tail(); + } + + // Toggle collapse/expand + event if self.keybinds.toggle.contains(event) => { + self.state.stream.toggle(); + } + + event if self.keybinds.expand.contains(event) => { + self.state.stream.set_nodes_visibility(false); + } + + event if self.keybinds.collapse.contains(event) => { + self.state.stream.set_nodes_visibility(true); + } + + _ => (), + } + } + + async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { + self.operate(event); + self.state.create_graphemes(area.0, area.1) + } + + async fn create_panes_from_query( + &mut self, + area: (u16, u16), + input: String, + ) -> (Option, Option) { + match json::run_jaq(&input, &self.json) { + Ok(ret) => { + let mut guide = None; + if ret.iter().all(|val| *val == Value::Null) { + guide = Some( + status::State::new( + format!( + "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" + ), + Severity::Warning, + ) + .create_graphemes(area.0, area.1), + ); + + self.state.stream = JsonStream::new(self.json.iter()); + } else { + self.state.stream = JsonStream::new(ret.iter()); + } + + (guide, Some(self.state.create_graphemes(area.0, area.1))) + } + Err(e) => { + self.state.stream = JsonStream::new(self.json.iter()); + + ( + Some( + status::State::new(format!("jq failed: `{e}`"), Severity::Error) + .create_graphemes(area.0, area.1), + ), + Some(self.state.create_graphemes(area.0, area.1)), + ) + } + } + } +} From b195814fed771ecee6708e3b5822d139ab04ba51 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 27 Mar 2026 23:51:06 +0900 Subject: [PATCH 12/74] chore: state => json_viewer --- src/{state.rs => json_viewer.rs} | 18 +++++++++--------- src/main.rs | 2 +- src/prompt.rs | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) rename src/{state.rs => json_viewer.rs} (96%) diff --git a/src/state.rs b/src/json_viewer.rs similarity index 96% rename from src/state.rs rename to src/json_viewer.rs index 9d6fdb7..f9202b1 100644 --- a/src/state.rs +++ b/src/json_viewer.rs @@ -68,7 +68,7 @@ impl spinner::State for ContextMonitor { } } -pub struct JsonViewerState { +pub struct JsonViewer { state: jsonstream::State, json: Vec, keybinds: JsonViewerKeybinds, @@ -80,7 +80,7 @@ pub async fn initialize( keybinds: JsonViewerKeybinds, shared_renderer: SharedRenderer, shared_ctx: Arc>, -) -> anyhow::Result { +) -> anyhow::Result { // Set state to Loading to prevent overwriting by spinner frames in terminal. { let mut shared_ctx = shared_ctx.lock().await; @@ -115,7 +115,7 @@ pub async fn initialize( .await; } - Ok(JsonViewerState { + Ok(JsonViewer { json: input_stream, state, keybinds, @@ -123,7 +123,7 @@ pub async fn initialize( } pub async fn render( - shared_viewer_state: Arc>, + shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, trigger: RenderTrigger, @@ -149,7 +149,7 @@ pub async fn render( } async fn render_from_user_action( - shared_viewer_state: Arc>, + shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, event: Event, @@ -172,7 +172,7 @@ async fn render_from_user_action( } async fn start_query_processing( - shared_viewer_state: Arc>, + shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, query: String, @@ -198,7 +198,7 @@ async fn start_query_processing( } async fn resize_and_start_query_processing( - shared_viewer_state: Arc>, + shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, area: (u16, u16), @@ -226,7 +226,7 @@ async fn resize_and_start_query_processing( } fn spawn_query_processing_task( - shared_viewer_state: Arc>, + shared_viewer_state: Arc>, shared_ctx: Arc>, shared_renderer: SharedRenderer, query: String, @@ -269,7 +269,7 @@ fn spawn_query_processing_task( }) } -impl JsonViewerState { +impl JsonViewer { /// Get the formatted content of current JSON stream. pub fn formatted_content(&self) -> String { self.state.config.format_raw_json(self.state.stream.rows()) diff --git a/src/main.rs b/src/main.rs index 3274c40..6e60971 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use promkit_widgets::{ mod editor; use editor::Editor; mod config; -mod state; +mod json_viewer; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod prompt; diff --git a/src/prompt.rs b/src/prompt.rs index 9684a45..c96f970 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -27,7 +27,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, - state::{initialize, render, Context, ContextMonitor, RenderTrigger}, + json_viewer::{initialize, render, Context, ContextMonitor, RenderTrigger}, Editor, }; From 179f6bdffe2134ceec2c933d3b196a6b250c1bdb Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 00:00:20 +0900 Subject: [PATCH 13/74] chore: rename for trigger and handlers --- src/json_viewer.rs | 54 ++++++++++++++++++++++++++++------------------ src/prompt.rs | 14 ++++++------ 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index f9202b1..eb1702a 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -15,16 +15,6 @@ use crate::{ prompt::Index, }; -/// Represent the trigger for rendering views. -pub enum RenderTrigger { - /// User actions such as key presses - UserAction(Event), - /// Query updates such as new jq filter input - QueryUpdated { query: String }, - /// Terminal resize events - Resized { area: (u16, u16), query: String }, -} - #[derive(PartialEq)] pub enum State { Idle, @@ -68,12 +58,25 @@ impl spinner::State for ContextMonitor { } } +/// Represent the trigger for rendering views. +pub enum RenderTrigger { + /// User actions such as key presses + UserAction(Event), + /// Query changes such as new jq filter input + QueryChanged { query: String }, + /// Terminal resize events + AreaResized { area: (u16, u16), query: String }, +} + +/// JSON viewer that maintains the state of JSON stream +/// and handles user interactions and query processing. pub struct JsonViewer { state: jsonstream::State, json: Vec, keybinds: JsonViewerKeybinds, } +/// Initialize the JSON viewer with the given input, configuration, keybinds, and shared context. pub async fn initialize( input: &'static str, config: JsonConfig, @@ -130,13 +133,13 @@ pub async fn render( ) { match trigger { RenderTrigger::UserAction(event) => { - render_from_user_action(shared_viewer_state, shared_renderer, shared_ctx, event).await; + handle_user_action(shared_viewer_state, shared_renderer, shared_ctx, event).await; } - RenderTrigger::QueryUpdated { query } => { - start_query_processing(shared_viewer_state, shared_renderer, shared_ctx, query).await; + RenderTrigger::QueryChanged { query } => { + handle_query_changed(shared_viewer_state, shared_renderer, shared_ctx, query).await; } - RenderTrigger::Resized { area, query } => { - resize_and_start_query_processing( + RenderTrigger::AreaResized { area, query } => { + handle_area_resized( shared_viewer_state, shared_renderer, shared_ctx, @@ -148,7 +151,7 @@ pub async fn render( } } -async fn render_from_user_action( +async fn handle_user_action( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, @@ -171,7 +174,7 @@ async fn render_from_user_action( .await; } -async fn start_query_processing( +async fn handle_query_changed( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, @@ -191,13 +194,15 @@ async fn start_query_processing( query, ); + // Store the new processing task handle in shared context + // to allow future cancellation if needed. { let mut shared_state = shared_ctx.lock().await; shared_state.current_task = Some(process_task); } } -async fn resize_and_start_query_processing( +async fn handle_area_resized( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, shared_ctx: Arc>, @@ -205,9 +210,14 @@ async fn resize_and_start_query_processing( query: String, ) { { - let mut shared_state = shared_ctx.lock().await; - shared_state.area = area; - if let Some(task) = shared_state.current_task.take() { + let mut ctx = shared_ctx.lock().await; + + // Update the terminal area in shared context for accurate rendering. + ctx.area = area; + + // Abort any ongoing processing task to prevent race conditions + // and ensure the new render reflects the latest terminal size. + if let Some(task) = ctx.current_task.take() { task.abort(); } } @@ -219,6 +229,8 @@ async fn resize_and_start_query_processing( query, ); + // Store the new processing task handle in shared context + // to allow future cancellation if needed. { let mut shared_state = shared_ctx.lock().await; shared_state.current_task = Some(process_task); diff --git a/src/prompt.rs b/src/prompt.rs index c96f970..0caaf7f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -27,7 +27,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, - json_viewer::{initialize, render, Context, ContextMonitor, RenderTrigger}, + json_viewer::{self, Context, ContextMonitor, RenderTrigger}, Editor, }; @@ -163,7 +163,7 @@ pub async fn run( let mut text_diff = [editor.text(), editor.text()]; let shared_editor = Arc::new(RwLock::new(editor)); let context_monitor = ContextMonitor::new(ctx.clone()); - let initializing = initialize( + let initializing = json_viewer::initialize( item, json_config, keybinds.on_json_viewer, @@ -376,7 +376,7 @@ pub async fn run( } } Some(event) = processor_event_rx.recv() => { - render( + json_viewer::render( shared_viewer_state.clone(), shared_renderer.clone(), ctx.clone(), @@ -385,11 +385,11 @@ pub async fn run( .await; } Some(query) = last_query_rx.recv() => { - render( + json_viewer::render( shared_viewer_state.clone(), shared_renderer.clone(), ctx.clone(), - RenderTrigger::QueryUpdated { query }, + RenderTrigger::QueryChanged { query }, ) .await; } @@ -413,11 +413,11 @@ pub async fn run( let editor = shared_editor.read().await; editor.text() }; - render( + json_viewer::render( shared_viewer_state.clone(), shared_renderer.clone(), ctx.clone(), - RenderTrigger::Resized { area, query: text }, + RenderTrigger::AreaResized { area, query: text }, ) .await; } From 32ef4d98902b5857b8d2aa9018deb895eef77a32 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 00:03:11 +0900 Subject: [PATCH 14/74] chore: remove create_pane_from_event --- src/json_viewer.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index eb1702a..ce4e76f 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -162,14 +162,15 @@ async fn handle_user_action( ctx.area }; - let pane = { - let mut runtime = shared_viewer_state.lock().await; - runtime.create_pane_from_event(area, &event).await + let graphemes = { + let mut viewer = shared_viewer_state.lock().await; + viewer.operate(&event); + viewer.state.create_graphemes(area.0, area.1) }; // TODO: error handling let _ = shared_renderer - .update([(Index::Processor, pane)]) + .update([(Index::Processor, graphemes)]) .render() .await; } @@ -326,11 +327,6 @@ impl JsonViewer { } } - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { - self.operate(event); - self.state.create_graphemes(area.0, area.1) - } - async fn create_panes_from_query( &mut self, area: (u16, u16), From 4b001549a2ec48c83922f343b9fa98ba0eabfa00 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 00:36:03 +0900 Subject: [PATCH 15/74] chore: use SharedContext --- src/json_viewer.rs | 63 ++++++++++++++++++++++++++++------------------ src/prompt.rs | 8 +++--- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index ce4e76f..edc899e 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -1,7 +1,12 @@ use std::{future::Future, sync::Arc}; use promkit_widgets::{ - core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, + core::{ + crossterm::{event::Event, terminal}, + grapheme::StyledGraphemes, + render::SharedRenderer, + Widget, + }, jsonstream::{self, JsonStream}, serde_json::{self, Value}, spinner, @@ -16,41 +21,49 @@ use crate::{ }; #[derive(PartialEq)] +/// Represent the current state of the JSON viewer, +/// which can be used to control rendering behavior +/// and manage concurrent tasks like query processing and spinner animation. pub enum State { + /// The viewer is idle and ready for user interactions or query processing. Idle, + /// The viewer is currently loading the JSON stream, which may involve deserialization Loading, + /// The viewer is actively processing a jq query, which may involve executing the query + /// and updating the view with the results. Processing, } -pub struct Context { - pub state: State, - pub area: (u16, u16), - pub current_task: Option>, +struct Context { + /// The current state of the processor, which can be Idle, Loading, or Processing. + state: State, + /// The current size of the terminal area. + area: (u16, u16), + /// The current task being executed, if any. + current_task: Option>, } -impl Context { - pub fn new(area: (u16, u16)) -> Self { - Self { +#[derive(Clone)] +pub struct SharedContext(Arc>); + +impl SharedContext { + pub fn try_default() -> anyhow::Result { + let area = terminal::size()?; + Ok(Self(Arc::new(Mutex::new(Context { state: State::Idle, area, current_task: None, - } + })))) } -} - -pub struct ContextMonitor { - shared: Arc>, -} -impl ContextMonitor { - pub fn new(shared: Arc>) -> Self { - Self { shared } + async fn lock(&self) -> tokio::sync::MutexGuard<'_, Context> { + self.0.lock().await } } -impl spinner::State for ContextMonitor { +impl spinner::State for SharedContext { fn is_idle(&self) -> impl Future + Send { - let shared = self.shared.clone(); + let shared = self.0.clone(); async move { let context = shared.lock().await; context.state == State::Idle @@ -82,7 +95,7 @@ pub async fn initialize( config: JsonConfig, keybinds: JsonViewerKeybinds, shared_renderer: SharedRenderer, - shared_ctx: Arc>, + shared_ctx: SharedContext, ) -> anyhow::Result { // Set state to Loading to prevent overwriting by spinner frames in terminal. { @@ -128,7 +141,7 @@ pub async fn initialize( pub async fn render( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, - shared_ctx: Arc>, + shared_ctx: SharedContext, trigger: RenderTrigger, ) { match trigger { @@ -154,7 +167,7 @@ pub async fn render( async fn handle_user_action( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, - shared_ctx: Arc>, + shared_ctx: SharedContext, event: Event, ) { let area = { @@ -178,7 +191,7 @@ async fn handle_user_action( async fn handle_query_changed( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, - shared_ctx: Arc>, + shared_ctx: SharedContext, query: String, ) { { @@ -206,7 +219,7 @@ async fn handle_query_changed( async fn handle_area_resized( shared_viewer_state: Arc>, shared_renderer: SharedRenderer, - shared_ctx: Arc>, + shared_ctx: SharedContext, area: (u16, u16), query: String, ) { @@ -240,7 +253,7 @@ async fn handle_area_resized( fn spawn_query_processing_task( shared_viewer_state: Arc>, - shared_ctx: Arc>, + shared_ctx: SharedContext, shared_renderer: SharedRenderer, query: String, ) -> JoinHandle<()> { diff --git a/src/prompt.rs b/src/prompt.rs index 0caaf7f..63f7c8d 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -27,7 +27,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, - json_viewer::{self, Context, ContextMonitor, RenderTrigger}, + json_viewer::{self, RenderTrigger, SharedContext}, Editor, }; @@ -120,7 +120,7 @@ pub async fn run( .await?, ); - let ctx = Arc::new(Mutex::new(Context::new(size))); + let ctx = SharedContext::try_default()?; let (last_query_tx, mut last_query_rx) = mpsc::channel(1); let (debounce_query_tx, debounce_query_rx) = mpsc::channel(1); @@ -143,7 +143,7 @@ pub async fn run( let spinning = tokio::spawn({ let shared_renderer = shared_renderer.clone(); - let state = ContextMonitor::new(ctx.clone()); + let state = ctx.clone(); let spin_duration = reactivity_control.spin_duration; async move { let spinner = Spinner::default().duration(spin_duration); @@ -162,7 +162,7 @@ pub async fn run( let mut text_diff = [editor.text(), editor.text()]; let shared_editor = Arc::new(RwLock::new(editor)); - let context_monitor = ContextMonitor::new(ctx.clone()); + let context_monitor = ctx.clone(); let initializing = json_viewer::initialize( item, json_config, From 2a0a2d5b43c71ab6a27c88b8f40f2d5f8e0f6b82 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 01:03:25 +0900 Subject: [PATCH 16/74] chore: use SharedJsonViewer --- src/json_viewer.rs | 18 ++++++++++-------- src/prompt.rs | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index edc899e..cbfb2eb 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -89,6 +89,8 @@ pub struct JsonViewer { keybinds: JsonViewerKeybinds, } +pub type SharedJsonViewer = Arc>; + /// Initialize the JSON viewer with the given input, configuration, keybinds, and shared context. pub async fn initialize( input: &'static str, @@ -96,7 +98,7 @@ pub async fn initialize( keybinds: JsonViewerKeybinds, shared_renderer: SharedRenderer, shared_ctx: SharedContext, -) -> anyhow::Result { +) -> anyhow::Result { // Set state to Loading to prevent overwriting by spinner frames in terminal. { let mut shared_ctx = shared_ctx.lock().await; @@ -131,15 +133,15 @@ pub async fn initialize( .await; } - Ok(JsonViewer { + Ok(Arc::new(Mutex::new(JsonViewer { json: input_stream, state, keybinds, - }) + }))) } pub async fn render( - shared_viewer_state: Arc>, + shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, trigger: RenderTrigger, @@ -165,7 +167,7 @@ pub async fn render( } async fn handle_user_action( - shared_viewer_state: Arc>, + shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, event: Event, @@ -189,7 +191,7 @@ async fn handle_user_action( } async fn handle_query_changed( - shared_viewer_state: Arc>, + shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, query: String, @@ -217,7 +219,7 @@ async fn handle_query_changed( } async fn handle_area_resized( - shared_viewer_state: Arc>, + shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, area: (u16, u16), @@ -252,7 +254,7 @@ async fn handle_area_resized( } fn spawn_query_processing_task( - shared_viewer_state: Arc>, + shared_viewer_state: SharedJsonViewer, shared_ctx: SharedContext, shared_renderer: SharedRenderer, query: String, diff --git a/src/prompt.rs b/src/prompt.rs index 63f7c8d..08c7967 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -21,7 +21,7 @@ use promkit_widgets::{ status::{self, Severity}, }; use tokio::{ - sync::{mpsc, Mutex, RwLock}, + sync::{mpsc, RwLock}, task::JoinHandle, }; @@ -355,7 +355,7 @@ pub async fn run( }) }; - let shared_viewer_state = Arc::new(Mutex::new(initializing.await?)); + let shared_viewer_state = initializing.await?; let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); From ad8459d0f7da3fd8b8755358ce11d83e351244fd Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 01:15:40 +0900 Subject: [PATCH 17/74] chore: remove context_monitor --- src/prompt.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/prompt.rs b/src/prompt.rs index 08c7967..54d41d6 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -143,11 +143,11 @@ pub async fn run( let spinning = tokio::spawn({ let shared_renderer = shared_renderer.clone(); - let state = ctx.clone(); + let ctx = ctx.clone(); let spin_duration = reactivity_control.spin_duration; async move { let spinner = Spinner::default().duration(spin_duration); - let _ = spinner::run(&spinner, state, Index::Processor, shared_renderer).await; + let _ = spinner::run(&spinner, ctx, Index::Processor, shared_renderer).await; } }); @@ -162,7 +162,6 @@ pub async fn run( let mut text_diff = [editor.text(), editor.text()]; let shared_editor = Arc::new(RwLock::new(editor)); - let context_monitor = ctx.clone(); let initializing = json_viewer::initialize( item, json_config, @@ -174,6 +173,7 @@ pub async fn run( let main_task: JoinHandle> = { let mut stream = EventStream::new(); let shared_renderer = shared_renderer.clone(); + let ctx = ctx.clone(); tokio::spawn(async move { 'main: loop { tokio::select! { @@ -208,7 +208,7 @@ pub async fn run( editor_copy_tx.send(()).await?; }, event if keybinds.copy_result.contains(&event) => { - if context_monitor.is_idle().await { + if ctx.is_idle().await { processor_copy_tx.send(()).await?; } else if !no_hint{ let size = terminal::size()?; @@ -227,7 +227,7 @@ pub async fn run( event if keybinds.switch_mode.contains(&event) => { match focus { Focus::Editor => { - if context_monitor.is_idle().await { + if ctx.is_idle().await { focus = Focus::Processor; editor_focus_tx.send(false).await?; execute!( From b79a84044f1dfe1b7d7626a928f7210f0299be50 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 01:31:00 +0900 Subject: [PATCH 18/74] chore: change order for functions --- src/json_viewer.rs | 174 +++++++++++++++++++++++---------------------- 1 file changed, 88 insertions(+), 86 deletions(-) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index cbfb2eb..3c7a360 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -91,6 +91,94 @@ pub struct JsonViewer { pub type SharedJsonViewer = Arc>; + +impl JsonViewer { + /// Get the formatted content of current JSON stream. + pub fn formatted_content(&self) -> String { + self.state.config.format_raw_json(self.state.stream.rows()) + } + + /// Handle user interactions and update the viewer state accordingly. + fn operate(&mut self, event: &Event) { + match event { + // Move up. + event if self.keybinds.up.contains(event) => { + self.state.stream.up(); + } + + // Move down. + event if self.keybinds.down.contains(event) => { + self.state.stream.down(); + } + + // Move to head + event if self.keybinds.move_to_head.contains(event) => { + self.state.stream.head(); + } + + // Move to tail + event if self.keybinds.move_to_tail.contains(event) => { + self.state.stream.tail(); + } + + // Toggle collapse/expand + event if self.keybinds.toggle.contains(event) => { + self.state.stream.toggle(); + } + + event if self.keybinds.expand.contains(event) => { + self.state.stream.set_nodes_visibility(false); + } + + event if self.keybinds.collapse.contains(event) => { + self.state.stream.set_nodes_visibility(true); + } + + _ => (), + } + } + + async fn create_panes_from_query( + &mut self, + area: (u16, u16), + input: String, + ) -> (Option, Option) { + match json::run_jaq(&input, &self.json) { + Ok(ret) => { + let mut guide = None; + if ret.iter().all(|val| *val == Value::Null) { + guide = Some( + status::State::new( + format!( + "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" + ), + Severity::Warning, + ) + .create_graphemes(area.0, area.1), + ); + + self.state.stream = JsonStream::new(self.json.iter()); + } else { + self.state.stream = JsonStream::new(ret.iter()); + } + + (guide, Some(self.state.create_graphemes(area.0, area.1))) + } + Err(e) => { + self.state.stream = JsonStream::new(self.json.iter()); + + ( + Some( + status::State::new(format!("jq failed: `{e}`"), Severity::Error) + .create_graphemes(area.0, area.1), + ), + Some(self.state.create_graphemes(area.0, area.1)), + ) + } + } + } +} + /// Initialize the JSON viewer with the given input, configuration, keybinds, and shared context. pub async fn initialize( input: &'static str, @@ -296,89 +384,3 @@ fn spawn_query_processing_task( .await; }) } - -impl JsonViewer { - /// Get the formatted content of current JSON stream. - pub fn formatted_content(&self) -> String { - self.state.config.format_raw_json(self.state.stream.rows()) - } - - fn operate(&mut self, event: &Event) { - match event { - // Move up. - event if self.keybinds.up.contains(event) => { - self.state.stream.up(); - } - - // Move down. - event if self.keybinds.down.contains(event) => { - self.state.stream.down(); - } - - // Move to head - event if self.keybinds.move_to_head.contains(event) => { - self.state.stream.head(); - } - - // Move to tail - event if self.keybinds.move_to_tail.contains(event) => { - self.state.stream.tail(); - } - - // Toggle collapse/expand - event if self.keybinds.toggle.contains(event) => { - self.state.stream.toggle(); - } - - event if self.keybinds.expand.contains(event) => { - self.state.stream.set_nodes_visibility(false); - } - - event if self.keybinds.collapse.contains(event) => { - self.state.stream.set_nodes_visibility(true); - } - - _ => (), - } - } - - async fn create_panes_from_query( - &mut self, - area: (u16, u16), - input: String, - ) -> (Option, Option) { - match json::run_jaq(&input, &self.json) { - Ok(ret) => { - let mut guide = None; - if ret.iter().all(|val| *val == Value::Null) { - guide = Some( - status::State::new( - format!( - "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" - ), - Severity::Warning, - ) - .create_graphemes(area.0, area.1), - ); - - self.state.stream = JsonStream::new(self.json.iter()); - } else { - self.state.stream = JsonStream::new(ret.iter()); - } - - (guide, Some(self.state.create_graphemes(area.0, area.1))) - } - Err(e) => { - self.state.stream = JsonStream::new(self.json.iter()); - - ( - Some( - status::State::new(format!("jq failed: `{e}`"), Severity::Error) - .create_graphemes(area.0, area.1), - ), - Some(self.state.create_graphemes(area.0, area.1)), - ) - } - } - } -} From 4abb44e02d315e64746f922cdd1f2689fb1e96f8 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 02:09:16 +0900 Subject: [PATCH 19/74] chore: refactor function names --- src/json_viewer.rs | 67 +++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 3c7a360..6d7ed31 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -98,8 +98,8 @@ impl JsonViewer { self.state.config.format_raw_json(self.state.stream.rows()) } - /// Handle user interactions and update the viewer state accordingly. - fn operate(&mut self, event: &Event) { + /// Handle user actions and update the viewer state accordingly. + fn apply_user_action(&mut self, event: &Event) { match event { // Move up. event if self.keybinds.up.contains(event) => { @@ -138,7 +138,8 @@ impl JsonViewer { } } - async fn create_panes_from_query( + /// Process jq query and update the viewer state with the results. + async fn refresh_view_with_query( &mut self, area: (u16, u16), input: String, @@ -189,11 +190,11 @@ pub async fn initialize( ) -> anyhow::Result { // Set state to Loading to prevent overwriting by spinner frames in terminal. { - let mut shared_ctx = shared_ctx.lock().await; - if let Some(task) = shared_ctx.current_task.take() { + let mut ctx = shared_ctx.lock().await; + if let Some(task) = ctx.current_task.take() { task.abort(); } - shared_ctx.state = State::Loading; + ctx.state = State::Loading; } let input_stream = json::deserialize(input, config.max_streams)?; @@ -205,14 +206,14 @@ pub async fn initialize( // Set state to Idle to prevent overwriting by spinner frames in terminal. { - let mut shared_ctx = shared_ctx.lock().await; - shared_ctx.state = State::Idle; + let mut ctx = shared_ctx.lock().await; + ctx.state = State::Idle; } { - let shared_ctx = shared_ctx.lock().await; - let area = shared_ctx.area; - drop(shared_ctx); + let ctx = shared_ctx.lock().await; + let area = ctx.area; + drop(ctx); // TODO: error handling let _ = shared_renderer @@ -267,7 +268,7 @@ async fn handle_user_action( let graphemes = { let mut viewer = shared_viewer_state.lock().await; - viewer.operate(&event); + viewer.apply_user_action(&event); viewer.state.create_graphemes(area.0, area.1) }; @@ -284,14 +285,16 @@ async fn handle_query_changed( shared_ctx: SharedContext, query: String, ) { + // Abort any ongoing processing task to prevent race conditions + // and ensure the new render reflects the latest terminal size. { - let mut shared_state = shared_ctx.lock().await; - if let Some(task) = shared_state.current_task.take() { + let mut ctx = shared_ctx.lock().await; + if let Some(task) = ctx.current_task.take() { task.abort(); } } - let process_task = spawn_query_processing_task( + let task = spawn_query_update_task( shared_viewer_state.clone(), shared_ctx.clone(), shared_renderer, @@ -301,8 +304,8 @@ async fn handle_query_changed( // Store the new processing task handle in shared context // to allow future cancellation if needed. { - let mut shared_state = shared_ctx.lock().await; - shared_state.current_task = Some(process_task); + let mut ctx = shared_ctx.lock().await; + ctx.current_task = Some(task); } } @@ -326,7 +329,7 @@ async fn handle_area_resized( } } - let process_task = spawn_query_processing_task( + let task = spawn_query_update_task( shared_viewer_state.clone(), shared_ctx.clone(), shared_renderer, @@ -336,36 +339,40 @@ async fn handle_area_resized( // Store the new processing task handle in shared context // to allow future cancellation if needed. { - let mut shared_state = shared_ctx.lock().await; - shared_state.current_task = Some(process_task); + let mut ctx = shared_ctx.lock().await; + ctx.current_task = Some(task); } } -fn spawn_query_processing_task( +/// Spawn a background task to process jq query +/// and update the viewer state +/// and rendered view accordingly. +fn spawn_query_update_task( shared_viewer_state: SharedJsonViewer, shared_ctx: SharedContext, shared_renderer: SharedRenderer, query: String, ) -> JoinHandle<()> { tokio::spawn(async move { + // Set state to Processing to prevent overwriting by spinner frames in terminal. { - let mut shared_state = shared_ctx.lock().await; - shared_state.state = State::Processing; + let mut ctx = shared_ctx.lock().await; + ctx.state = State::Processing; } let (maybe_guide, maybe_resp) = { - let shared_state = shared_ctx.lock().await; - let area = shared_state.area; - drop(shared_state); + let ctx = shared_ctx.lock().await; + let area = ctx.area; + drop(ctx); let mut runtime = shared_viewer_state.lock().await; - runtime.create_panes_from_query(area, query).await + runtime.refresh_view_with_query(area, query).await }; - // Set state to Idle to prevent overwriting by spinner frames in terminal. + // Set state to Idle to allow rendering of spinner frames in terminal. { - let mut shared_state = shared_ctx.lock().await; - shared_state.state = State::Idle; + let mut ctx = shared_ctx.lock().await; + ctx.state = State::Idle; } // TODO: error handling From 738370b62868208e82fedf248d890c8f6621d247 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 02:37:28 +0900 Subject: [PATCH 20/74] chore: refactor names --- src/editor.rs | 6 +++--- src/main.rs | 3 +++ src/search.rs | 49 ++++++++++++++++++++++++++----------------------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 7a39e60..53d16a2 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -98,11 +98,11 @@ pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Resul match editor.searcher.start_search(&prefix) { Ok(result) => match result.head_item { Some(head) => { - if result.load_state.loaded { + if result.load_progress.is_complete { editor.guide = status::State::new( format!( "Loaded all ({}) suggestions", - result.load_state.loaded_item_len + result.load_progress.loaded_path_count ), Severity::Success, ); @@ -110,7 +110,7 @@ pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Resul editor.guide = status::State::new( format!( "Loaded partially ({}) suggestions", - result.load_state.loaded_item_len + result.load_progress.loaded_path_count ), Severity::Success, ); diff --git a/src/main.rs b/src/main.rs index 6e60971..a6546c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,9 +147,12 @@ fn determine_config_file(config_path: Option) -> anyhow::Result anyhow::Result<()> { let args = Args::parse(); + + // Load input data let input = parse_input(&args)?; let input: &'static str = Box::leak(input.into_boxed_str()); + // Load configuration let config = determine_config_file(args.config_file) .and_then(|config_file| { std::fs::read_to_string(&config_file) diff --git a/src/search.rs b/src/search.rs index 2632dd7..e4bc2e4 100644 --- a/src/search.rs +++ b/src/search.rs @@ -13,19 +13,23 @@ use tokio::{ use crate::json; #[derive(Clone, Default)] -pub struct LoadState { - pub loaded: bool, - pub loaded_item_len: usize, +pub struct SuggestionLoadProgress { + pub is_complete: bool, + pub loaded_path_count: usize, } pub struct StartSearchResult { pub head_item: Option, - pub load_state: LoadState, + pub load_progress: SuggestionLoadProgress, } pub struct IncrementalSearcher { - shared_set: Arc>>, - shared_load_state: Arc>, + // Background loader writes discovered JSON paths, completion search reads them. + // This mutex protects the shared path index itself. + shared_paths: Arc>>, + // Loader updates progress, completion reads status for hints. + // RwLock is used because reads are frequent from UI-side completion. + shared_progress: Arc>, state: listbox::State, search_result_chunk_size: usize, search_chunk_remaining: Vec, @@ -34,8 +38,8 @@ pub struct IncrementalSearcher { impl IncrementalSearcher { pub fn new(state: listbox::State, search_result_chunk_size: usize) -> Self { Self { - shared_set: Default::default(), - shared_load_state: Default::default(), + shared_paths: Default::default(), + shared_progress: Default::default(), state, search_result_chunk_size, search_chunk_remaining: Default::default(), @@ -48,8 +52,8 @@ impl IncrementalSearcher { max_streams: Option, chunk_size: usize, ) -> JoinHandle> { - let shared_set = self.shared_set.clone(); - let shared_load_state = self.shared_load_state.clone(); + let shared_paths = self.shared_paths.clone(); + let shared_progress = self.shared_progress.clone(); tokio::spawn(async move { let mut batch = Vec::with_capacity(chunk_size); let iter = json::get_all_paths(item, max_streams).await?; @@ -58,26 +62,26 @@ impl IncrementalSearcher { batch.push(v); if batch.len() >= chunk_size { - let mut set = shared_set.lock().await; + let mut set = shared_paths.lock().await; for item in batch.drain(..) { set.insert(item); } - let mut state = shared_load_state.write().await; - state.loaded_item_len += chunk_size; + let mut state = shared_progress.write().await; + state.loaded_path_count += chunk_size; } } let remaining = batch.len(); if !batch.is_empty() { - let mut set = shared_set.lock().await; + let mut set = shared_paths.lock().await; for item in batch { set.insert(item); } } - let mut state = shared_load_state.write().await; - state.loaded = true; - state.loaded_item_len += remaining; + let mut state = shared_progress.write().await; + state.is_complete = true; + state.loaded_path_count += remaining; Ok(()) }) } @@ -113,10 +117,9 @@ impl IncrementalSearcher { } pub fn start_search(&mut self, prefix: &str) -> anyhow::Result { - match ( - self.shared_load_state.try_read(), - self.shared_set.try_lock(), - ) { + // UI event handling path uses try_* to avoid blocking input latency. + // If loader currently holds one of these locks, we return a retriable error. + match (self.shared_progress.try_read(), self.shared_paths.try_lock()) { (Ok(state), Ok(set)) => { let mut items: Vec<_> = set .iter() @@ -126,7 +129,7 @@ impl IncrementalSearcher { if items.is_empty() { return Ok(StartSearchResult { head_item: None, - load_state: state.clone(), + load_progress: state.clone(), }); } let used = items @@ -136,7 +139,7 @@ impl IncrementalSearcher { self.state.listbox = Listbox::from(used); Ok(StartSearchResult { head_item: Some(self.state.listbox.get().to_string()), - load_state: state.clone(), + load_progress: state.clone(), }) } (Err(_), _) | (_, Err(_)) => Err(anyhow!( From f8cd69bb859bc9812525d4659ee235c39a01dc1a Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 12:49:32 +0900 Subject: [PATCH 21/74] chore: define SharedSuggestionStore to start separating search and editor --- src/editor.rs | 114 +++++++++++++++------------ src/json_viewer.rs | 1 - src/main.rs | 17 ++-- src/prompt.rs | 23 ++++-- src/search.rs | 189 ++++++++++++++++++++++++--------------------- 5 files changed, 191 insertions(+), 153 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 53d16a2..edb04c9 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -10,7 +10,10 @@ use promkit_widgets::{ text_editor, }; -use crate::{config::EditorKeybinds, search::IncrementalSearcher}; +use crate::{ + config::EditorKeybinds, + search::{IncrementalSearcher, SharedSuggestionStore}, +}; pub struct Editor { handler: Handler, @@ -18,14 +21,14 @@ pub struct Editor { focus_config: text_editor::Config, defocus_config: text_editor::Config, guide: status::State, - searcher: IncrementalSearcher, + shared_suggestions: SharedSuggestionStore, editor_keybinds: EditorKeybinds, } impl Editor { pub fn new( state: text_editor::State, - searcher: IncrementalSearcher, + shared_suggestions: SharedSuggestionStore, focus_config: text_editor::Config, defocus_config: text_editor::Config, editor_keybinds: EditorKeybinds, @@ -36,7 +39,7 @@ impl Editor { focus_config, defocus_config, guide: status::State::default(), - searcher, + shared_suggestions, editor_keybinds, } } @@ -45,10 +48,10 @@ impl Editor { self.state.config = self.focus_config.clone(); } - pub fn defocus(&mut self) { + pub fn defocus(&mut self, searcher: &mut IncrementalSearcher) { self.state.config = self.defocus_config.clone(); - self.searcher.leave_search(); + searcher.leave_search(); self.handler = BOXED_EDITOR_HANDLER; self.guide = status::State::default(); @@ -62,72 +65,77 @@ impl Editor { self.state.create_graphemes(width, height) } - pub fn create_searcher_pane(&self, width: u16, height: u16) -> StyledGraphemes { - self.searcher.create_pane(width, height) + pub fn create_searcher_pane( + &self, + searcher: &IncrementalSearcher, + width: u16, + height: u16, + ) -> StyledGraphemes { + searcher.create_pane(width, height) } pub fn create_guide_pane(&self, width: u16, height: u16) -> StyledGraphemes { self.guide.create_graphemes(width, height) } - pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { - (self.handler)(event, self).await + pub async fn operate( + &mut self, + event: &Event, + searcher: &mut IncrementalSearcher, + ) -> anyhow::Result<()> { + (self.handler)(event, self, searcher).await } } pub type Handler = for<'a> fn( &'a Event, &'a mut Editor, + &'a mut IncrementalSearcher, ) -> Pin> + Send + 'a>>; const BOXED_EDITOR_HANDLER: Handler = - |event, editor| -> Pin> + Send + '_>> { - Box::pin(edit(event, editor)) + |event, editor, searcher| -> Pin> + Send + '_>> { + Box::pin(edit(event, editor, searcher)) }; const BOXED_SEARCHER_HANDLER: Handler = - |event, editor| -> Pin> + Send + '_>> { - Box::pin(search(event, editor)) + |event, editor, searcher| -> Pin> + Send + '_>> { + Box::pin(search(event, editor, searcher)) }; -pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Result<()> { +pub async fn edit<'a>( + event: &'a Event, + editor: &'a mut Editor, + searcher: &'a mut IncrementalSearcher, +) -> anyhow::Result<()> { editor.guide = status::State::default(); match event { key if editor.editor_keybinds.completion.contains(key) => { let prefix = editor.state.texteditor.text_without_cursor().to_string(); - match editor.searcher.start_search(&prefix) { - Ok(result) => match result.head_item { - Some(head) => { - if result.load_progress.is_complete { - editor.guide = status::State::new( - format!( - "Loaded all ({}) suggestions", - result.load_progress.loaded_path_count - ), - Severity::Success, - ); - } else { - editor.guide = status::State::new( - format!( - "Loaded partially ({}) suggestions", - result.load_progress.loaded_path_count - ), - Severity::Success, - ); - } - editor.state.texteditor.replace(&head); - editor.handler = BOXED_SEARCHER_HANDLER; - } - None => { + let (items, _) = editor.shared_suggestions.collect_matches(&prefix).await; + let progress = searcher.load_progress().await; + match searcher.apply_search_items(items) { + Some(head) => { + if progress.is_complete { editor.guide = status::State::new( - format!("No suggestion found for '{prefix}'"), - Severity::Warning, + format!("Loaded all ({}) suggestions", progress.loaded_path_count), + Severity::Success, + ); + } else { + editor.guide = status::State::new( + format!( + "Loaded partially ({}) suggestions", + progress.loaded_path_count + ), + Severity::Success, ); } - }, - Err(e) => { + editor.state.texteditor.replace(&head); + editor.handler = BOXED_SEARCHER_HANDLER; + } + None => { editor.guide = status::State::new( - format!("Failed to lookup suggestions: {e}"), + format!("No suggestion found for '{prefix}'"), Severity::Warning, ); } @@ -214,28 +222,32 @@ pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Resul Ok(()) } -pub async fn search<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Result<()> { +pub async fn search<'a>( + event: &'a Event, + editor: &'a mut Editor, + searcher: &'a mut IncrementalSearcher, +) -> anyhow::Result<()> { match event { key if editor.editor_keybinds.on_completion.down.contains(key) => { - editor.searcher.down_with_load(); + searcher.down_with_load(); editor .state .texteditor - .replace(&editor.searcher.get_current_item()); + .replace(&searcher.get_current_item()); } key if editor.editor_keybinds.on_completion.up.contains(key) => { - editor.searcher.up(); + searcher.up(); editor .state .texteditor - .replace(&editor.searcher.get_current_item()); + .replace(&searcher.get_current_item()); } _ => { - editor.searcher.leave_search(); + searcher.leave_search(); editor.handler = BOXED_EDITOR_HANDLER; - return edit(event, editor).await; + return edit(event, editor, searcher).await; } } diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 6d7ed31..2f0a9f9 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -91,7 +91,6 @@ pub struct JsonViewer { pub type SharedJsonViewer = Arc>; - impl JsonViewer { /// Get the formatted content of current JSON stream. pub fn formatted_content(&self) -> String { diff --git a/src/main.rs b/src/main.rs index a6546c8..7d967cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,7 +147,7 @@ fn determine_config_file(config_path: Option) -> anyhow::Result anyhow::Result<()> { let args = Args::parse(); - + // Load input data let input = parse_input(&args)?; let input: &'static str = Box::leak(input.into_boxed_str()); @@ -168,9 +168,6 @@ async fn main() -> anyhow::Result<()> { config: config.completion.listbox.clone(), }; - let searcher = - IncrementalSearcher::new(listbox_state, config.completion.search_result_chunk_size); - let text_editor_state = text_editor::State { texteditor: if let Some(filter) = args.default_filter { TextEditor::new(filter) @@ -181,10 +178,16 @@ async fn main() -> anyhow::Result<()> { config: config.editor.on_focus.clone(), }; - let loading_suggestions_task = searcher.spawn_load_task( + let shared_suggestions = search::initialize( &input, config.json.max_streams, config.completion.search_load_chunk_size, + ) + .await?; + let searcher = IncrementalSearcher::new( + shared_suggestions.clone(), + listbox_state, + config.completion.search_result_chunk_size, ); // TODO: re-consider put editor_task of prompt::run into Editor construction time. @@ -192,7 +195,7 @@ async fn main() -> anyhow::Result<()> { // launch a background thread during construction. let editor = Editor::new( text_editor_state, - searcher, + shared_suggestions.clone(), config.editor.on_focus, config.editor.on_defocus, // TODO: remove clones @@ -207,7 +210,7 @@ async fn main() -> anyhow::Result<()> { config.json, config.reactivity_control, editor, - loading_suggestions_task, + searcher, config.no_hint, config.keybinds, args.write_to_stdout, diff --git a/src/prompt.rs b/src/prompt.rs index 54d41d6..1b46539 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -28,6 +28,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, json_viewer::{self, RenderTrigger, SharedContext}, + search::IncrementalSearcher, Editor, }; @@ -96,7 +97,7 @@ pub async fn run( json_config: JsonConfig, reactivity_control: ReactivityControl, editor: Editor, - loading_suggestions_task: JoinHandle>, + searcher: IncrementalSearcher, no_hint: bool, keybinds: Keybinds, write_to_stdout: bool, @@ -162,6 +163,7 @@ pub async fn run( let mut text_diff = [editor.text(), editor.text()]; let shared_editor = Arc::new(RwLock::new(editor)); + let shared_searcher = Arc::new(RwLock::new(searcher)); let initializing = json_viewer::initialize( item, json_config, @@ -284,25 +286,29 @@ pub async fn run( let editor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); + let shared_searcher = shared_searcher.clone(); tokio::spawn(async move { loop { tokio::select! { Some(focus) = editor_focus_rx.recv() => { - let (editor_pane, guide_pane) = { + let (editor_pane, guide_pane, searcher_pane) = { let mut editor = shared_editor.write().await; + let mut searcher = shared_searcher.write().await; if focus { editor.focus(); } else { - editor.defocus(); + editor.defocus(&mut searcher); } ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), + editor.create_searcher_pane(&searcher, size.0, size.1), ) }; shared_renderer.update([ (Index::Editor, editor_pane), (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), + (Index::Search, searcher_pane), ]).render().await?; } Some(()) = editor_copy_rx.recv() => { @@ -322,9 +328,9 @@ pub async fn run( Some(event) = editor_event_rx.recv() => { let size = terminal::size()?; let (editor_pane, guide_pane, searcher_pane) = { - let mut editor = shared_editor.write().await; - editor.operate(&event).await?; + let mut searcher = shared_searcher.write().await; + editor.operate(&event, &mut searcher).await?; let current_text = editor.text(); if current_text != text_diff[1] { @@ -335,7 +341,7 @@ pub async fn run( ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(size.0, size.1), + editor.create_searcher_pane(&searcher, size.0, size.1), ) }; { @@ -359,6 +365,7 @@ pub async fn run( let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); + let shared_searcher = shared_searcher.clone(); let shared_viewer_state = shared_viewer_state.clone(); let ctx = ctx.clone(); tokio::spawn(async move { @@ -396,10 +403,11 @@ pub async fn run( Some(area) = last_resize_rx.recv() => { let (editor_pane, guide_pane, searcher_pane) = { let editor = shared_editor.read().await; + let searcher = shared_searcher.read().await; ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(size.0, size.1), + editor.create_searcher_pane(&searcher, size.0, size.1), ) }; { @@ -439,7 +447,6 @@ pub async fn run( None }; - loading_suggestions_task.abort(); spinning.abort(); query_debouncer.abort(); resize_debouncer.abort(); diff --git a/src/search.rs b/src/search.rs index e4bc2e4..0f5de89 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,91 +1,124 @@ use std::{collections::BTreeSet, sync::Arc}; -use anyhow::anyhow; use promkit_widgets::{ core::{grapheme::StyledGraphemes, Widget}, listbox::{self, Listbox}, }; -use tokio::{ - sync::{Mutex, RwLock}, - task::JoinHandle, -}; +use tokio::{sync::Mutex, task}; use crate::json; +/// Progress information for loading suggestions #[derive(Clone, Default)] pub struct SuggestionLoadProgress { pub is_complete: bool, pub loaded_path_count: usize, } -pub struct StartSearchResult { - pub head_item: Option, - pub load_progress: SuggestionLoadProgress, +/// Store for suggestions with thread-safe access +struct SuggestionStore { + /// Set of all paths extracted from JSON input + paths: BTreeSet, + progress: SuggestionLoadProgress, +} + +#[derive(Clone)] +pub struct SharedSuggestionStore(Arc>); + +impl SharedSuggestionStore { + /// Collect suggestions that start with the given prefix + pub async fn collect_matches(&self, prefix: &str) -> (Vec, SuggestionLoadProgress) { + let store = self.0.lock().await; + let items = store + .paths + .iter() + .filter(|p| p.starts_with(prefix)) + .cloned() + .collect::>(); + (items, store.progress.clone()) + } + + /// Get the current load progress of suggestions + pub async fn load_progress(&self) -> SuggestionLoadProgress { + let store = self.0.lock().await; + store.progress.clone() + } +} + +/// Initialize shared suggestion store by loading paths from JSON input +pub async fn initialize( + item: &'static str, + max_streams: Option, + chunk_size: usize, +) -> anyhow::Result { + let shared = SharedSuggestionStore(Arc::new(Mutex::new(SuggestionStore { + paths: BTreeSet::new(), + progress: SuggestionLoadProgress::default(), + }))); + + let shared_for_loading = shared.clone(); + task::spawn(async move { + // Load paths in a streaming manner and update the shared store incrementally + let iter = match json::get_all_paths(item, max_streams).await { + Ok(iter) => iter, + Err(_) => { + let mut store = shared_for_loading.0.lock().await; + store.progress.is_complete = true; + return; + } + }; + + // Process paths in chunks to avoid holding the lock for too long + let mut batch = Vec::with_capacity(chunk_size); + for path in iter { + batch.push(path); + + if batch.len() >= chunk_size { + let loaded = batch.len(); + let mut store = shared_for_loading.0.lock().await; + for item in batch.drain(..) { + store.paths.insert(item); + } + store.progress.loaded_path_count += loaded; + } + } + + // Insert any remaining paths after the loop + let remaining = batch.len(); + let mut store = shared_for_loading.0.lock().await; + for item in batch { + store.paths.insert(item); + } + + // Mark loading as complete and update progress + store.progress.loaded_path_count += remaining; + store.progress.is_complete = true; + }); + + Ok(shared) } pub struct IncrementalSearcher { - // Background loader writes discovered JSON paths, completion search reads them. - // This mutex protects the shared path index itself. - shared_paths: Arc>>, - // Loader updates progress, completion reads status for hints. - // RwLock is used because reads are frequent from UI-side completion. - shared_progress: Arc>, + shared_suggestions: SharedSuggestionStore, state: listbox::State, search_result_chunk_size: usize, search_chunk_remaining: Vec, } impl IncrementalSearcher { - pub fn new(state: listbox::State, search_result_chunk_size: usize) -> Self { + pub fn new( + shared_suggestions: SharedSuggestionStore, + state: listbox::State, + search_result_chunk_size: usize, + ) -> Self { Self { - shared_paths: Default::default(), - shared_progress: Default::default(), + shared_suggestions, state, search_result_chunk_size, search_chunk_remaining: Default::default(), } } - pub fn spawn_load_task( - &self, - item: &'static str, - max_streams: Option, - chunk_size: usize, - ) -> JoinHandle> { - let shared_paths = self.shared_paths.clone(); - let shared_progress = self.shared_progress.clone(); - tokio::spawn(async move { - let mut batch = Vec::with_capacity(chunk_size); - let iter = json::get_all_paths(item, max_streams).await?; - - for v in iter { - batch.push(v); - - if batch.len() >= chunk_size { - let mut set = shared_paths.lock().await; - for item in batch.drain(..) { - set.insert(item); - } - let mut state = shared_progress.write().await; - state.loaded_path_count += chunk_size; - } - } - - let remaining = batch.len(); - if !batch.is_empty() { - let mut set = shared_paths.lock().await; - for item in batch { - set.insert(item); - } - } - - let mut state = shared_progress.write().await; - state.is_complete = true; - state.loaded_path_count += remaining; - Ok(()) - }) - } - pub fn up(&mut self) { self.state.listbox.backward(); } @@ -116,36 +149,20 @@ impl IncrementalSearcher { self.search_chunk_remaining = Vec::::new(); } - pub fn start_search(&mut self, prefix: &str) -> anyhow::Result { - // UI event handling path uses try_* to avoid blocking input latency. - // If loader currently holds one of these locks, we return a retriable error. - match (self.shared_progress.try_read(), self.shared_paths.try_lock()) { - (Ok(state), Ok(set)) => { - let mut items: Vec<_> = set - .iter() - .filter(|p| p.starts_with(prefix)) - .cloned() - .collect(); - if items.is_empty() { - return Ok(StartSearchResult { - head_item: None, - load_progress: state.clone(), - }); - } - let used = items - .drain(..self.search_result_chunk_size.min(items.len())) - .collect::>(); - self.search_chunk_remaining = items; - self.state.listbox = Listbox::from(used); - Ok(StartSearchResult { - head_item: Some(self.state.listbox.get().to_string()), - load_progress: state.clone(), - }) - } - (Err(_), _) | (_, Err(_)) => Err(anyhow!( - "Failed to acquire lock for suggestions. Please try again." - )), + pub fn apply_search_items(&mut self, mut items: Vec) -> Option { + if items.is_empty() { + return None; } + let used = items + .drain(..self.search_result_chunk_size.min(items.len())) + .collect::>(); + self.search_chunk_remaining = items; + self.state.listbox = Listbox::from(used); + Some(self.state.listbox.get().to_string()) + } + + pub async fn load_progress(&self) -> SuggestionLoadProgress { + self.shared_suggestions.load_progress().await } fn load_more(&mut self) { From bb0068119cdd0483e44682e92a44ca76adc428f2 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 13:27:38 +0900 Subject: [PATCH 22/74] chore: separate search and editor using Focus --- src/editor.rs | 275 ++++++++++++++++---------------------------------- src/main.rs | 1 - src/prompt.rs | 131 +++++++++++++++++++++--- src/search.rs | 12 +-- 4 files changed, 212 insertions(+), 207 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index edb04c9..3160148 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,5 +1,3 @@ -use std::{future::Future, pin::Pin}; - use promkit_widgets::{ core::{ crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, @@ -10,36 +8,28 @@ use promkit_widgets::{ text_editor, }; -use crate::{ - config::EditorKeybinds, - search::{IncrementalSearcher, SharedSuggestionStore}, -}; +use crate::config::EditorKeybinds; pub struct Editor { - handler: Handler, state: text_editor::State, focus_config: text_editor::Config, defocus_config: text_editor::Config, guide: status::State, - shared_suggestions: SharedSuggestionStore, editor_keybinds: EditorKeybinds, } impl Editor { pub fn new( state: text_editor::State, - shared_suggestions: SharedSuggestionStore, focus_config: text_editor::Config, defocus_config: text_editor::Config, editor_keybinds: EditorKeybinds, ) -> Self { Self { - handler: BOXED_EDITOR_HANDLER, state, focus_config, defocus_config, guide: status::State::default(), - shared_suggestions, editor_keybinds, } } @@ -48,12 +38,8 @@ impl Editor { self.state.config = self.focus_config.clone(); } - pub fn defocus(&mut self, searcher: &mut IncrementalSearcher) { + pub fn defocus(&mut self) { self.state.config = self.defocus_config.clone(); - - searcher.leave_search(); - self.handler = BOXED_EDITOR_HANDLER; - self.guide = status::State::default(); } @@ -65,191 +51,108 @@ impl Editor { self.state.create_graphemes(width, height) } - pub fn create_searcher_pane( - &self, - searcher: &IncrementalSearcher, - width: u16, - height: u16, - ) -> StyledGraphemes { - searcher.create_pane(width, height) - } - pub fn create_guide_pane(&self, width: u16, height: u16) -> StyledGraphemes { self.guide.create_graphemes(width, height) } - pub async fn operate( - &mut self, - event: &Event, - searcher: &mut IncrementalSearcher, - ) -> anyhow::Result<()> { - (self.handler)(event, self, searcher).await + pub fn clear_guide(&mut self) { + self.guide = status::State::default(); } -} -pub type Handler = for<'a> fn( - &'a Event, - &'a mut Editor, - &'a mut IncrementalSearcher, -) -> Pin> + Send + 'a>>; - -const BOXED_EDITOR_HANDLER: Handler = - |event, editor, searcher| -> Pin> + Send + '_>> { - Box::pin(edit(event, editor, searcher)) - }; -const BOXED_SEARCHER_HANDLER: Handler = - |event, editor, searcher| -> Pin> + Send + '_>> { - Box::pin(search(event, editor, searcher)) - }; - -pub async fn edit<'a>( - event: &'a Event, - editor: &'a mut Editor, - searcher: &'a mut IncrementalSearcher, -) -> anyhow::Result<()> { - editor.guide = status::State::default(); - - match event { - key if editor.editor_keybinds.completion.contains(key) => { - let prefix = editor.state.texteditor.text_without_cursor().to_string(); - let (items, _) = editor.shared_suggestions.collect_matches(&prefix).await; - let progress = searcher.load_progress().await; - match searcher.apply_search_items(items) { - Some(head) => { - if progress.is_complete { - editor.guide = status::State::new( - format!("Loaded all ({}) suggestions", progress.loaded_path_count), - Severity::Success, - ); - } else { - editor.guide = status::State::new( - format!( - "Loaded partially ({}) suggestions", - progress.loaded_path_count - ), - Severity::Success, - ); - } - editor.state.texteditor.replace(&head); - editor.handler = BOXED_SEARCHER_HANDLER; - } - None => { - editor.guide = status::State::new( - format!("No suggestion found for '{prefix}'"), - Severity::Warning, - ); - } - } - } + pub fn replace_text(&mut self, text: &str) { + self.state.texteditor.replace(text); + } - // Move cursor. - key if editor.editor_keybinds.backward.contains(key) => { - editor.state.texteditor.backward(); - } - key if editor.editor_keybinds.forward.contains(key) => { - editor.state.texteditor.forward(); - } - key if editor.editor_keybinds.move_to_head.contains(key) => { - editor.state.texteditor.move_to_head(); - } - key if editor.editor_keybinds.move_to_tail.contains(key) => { - editor.state.texteditor.move_to_tail(); + pub fn set_completion_found_guide(&mut self, loaded_path_count: usize, is_complete: bool) { + if is_complete { + self.guide = status::State::new( + format!("Loaded all ({loaded_path_count}) suggestions"), + Severity::Success, + ); + } else { + self.guide = status::State::new( + format!("Loaded partially ({loaded_path_count}) suggestions"), + Severity::Success, + ); } + } - // Move cursor to the nearest character. - key if editor - .editor_keybinds - .move_to_previous_nearest - .contains(key) => - { - editor - .state - .texteditor - .move_to_previous_nearest(&editor.state.config.word_break_chars); - } - key if editor.editor_keybinds.move_to_next_nearest.contains(key) => { - editor - .state - .texteditor - .move_to_next_nearest(&editor.state.config.word_break_chars); - } + pub fn set_completion_empty_guide(&mut self, prefix: &str) { + self.guide = status::State::new( + format!("No suggestion found for '{prefix}'"), + Severity::Warning, + ); + } - // Erase char(s). - key if editor.editor_keybinds.erase.contains(key) => { - editor.state.texteditor.erase(); - } - key if editor.editor_keybinds.erase_all.contains(key) => { - editor.state.texteditor.erase_all(); - } + pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { + self.clear_guide(); - // Erase to the nearest character. - key if editor - .editor_keybinds - .erase_to_previous_nearest - .contains(key) => - { - editor - .state - .texteditor - .erase_to_previous_nearest(&editor.state.config.word_break_chars); - } - key if editor.editor_keybinds.erase_to_next_nearest.contains(key) => { - editor - .state - .texteditor - .erase_to_next_nearest(&editor.state.config.word_break_chars); - } + match event { + // Move cursor. + key if self.editor_keybinds.backward.contains(key) => { + self.state.texteditor.backward(); + } + key if self.editor_keybinds.forward.contains(key) => { + self.state.texteditor.forward(); + } + key if self.editor_keybinds.move_to_head.contains(key) => { + self.state.texteditor.move_to_head(); + } + key if self.editor_keybinds.move_to_tail.contains(key) => { + self.state.texteditor.move_to_tail(); + } - // Input char. - Event::Key(KeyEvent { - code: KeyCode::Char(ch), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) - | Event::Key(KeyEvent { - code: KeyCode::Char(ch), - modifiers: KeyModifiers::SHIFT, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => match editor.state.config.edit_mode { - text_editor::Mode::Insert => editor.state.texteditor.insert(*ch), - text_editor::Mode::Overwrite => editor.state.texteditor.overwrite(*ch), - }, - - _ => {} - } - Ok(()) -} + // Move cursor to the nearest character. + key if self.editor_keybinds.move_to_previous_nearest.contains(key) => { + self.state + .texteditor + .move_to_previous_nearest(&self.state.config.word_break_chars); + } + key if self.editor_keybinds.move_to_next_nearest.contains(key) => { + self.state + .texteditor + .move_to_next_nearest(&self.state.config.word_break_chars); + } -pub async fn search<'a>( - event: &'a Event, - editor: &'a mut Editor, - searcher: &'a mut IncrementalSearcher, -) -> anyhow::Result<()> { - match event { - key if editor.editor_keybinds.on_completion.down.contains(key) => { - searcher.down_with_load(); - editor - .state - .texteditor - .replace(&searcher.get_current_item()); - } + // Erase char(s). + key if self.editor_keybinds.erase.contains(key) => { + self.state.texteditor.erase(); + } + key if self.editor_keybinds.erase_all.contains(key) => { + self.state.texteditor.erase_all(); + } - key if editor.editor_keybinds.on_completion.up.contains(key) => { - searcher.up(); - editor - .state - .texteditor - .replace(&searcher.get_current_item()); - } + // Erase to the nearest character. + key if self.editor_keybinds.erase_to_previous_nearest.contains(key) => { + self.state + .texteditor + .erase_to_previous_nearest(&self.state.config.word_break_chars); + } + key if self.editor_keybinds.erase_to_next_nearest.contains(key) => { + self.state + .texteditor + .erase_to_next_nearest(&self.state.config.word_break_chars); + } - _ => { - searcher.leave_search(); - editor.handler = BOXED_EDITOR_HANDLER; - return edit(event, editor, searcher).await; + // Input char. + Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => match self.state.config.edit_mode { + text_editor::Mode::Insert => self.state.texteditor.insert(*ch), + text_editor::Mode::Overwrite => self.state.texteditor.overwrite(*ch), + }, + + _ => {} } + Ok(()) } - - Ok(()) } diff --git a/src/main.rs b/src/main.rs index 7d967cd..4539c8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,7 +195,6 @@ async fn main() -> anyhow::Result<()> { // launch a background thread during construction. let editor = Editor::new( text_editor_state, - shared_suggestions.clone(), config.editor.on_focus, config.editor.on_defocus, // TODO: remove clones diff --git a/src/prompt.rs b/src/prompt.rs index 1b46539..7bd4719 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -78,8 +78,10 @@ fn empty_pane() -> StyledGraphemes { StyledGraphemes::default() } +#[derive(Clone, Copy)] enum Focus { Editor, + Searcher, Processor, } @@ -154,6 +156,7 @@ pub async fn run( let mut focus = Focus::Editor; let (editor_event_tx, mut editor_event_rx) = mpsc::channel::(1); + let (searcher_event_tx, mut searcher_event_rx) = mpsc::channel::<(Focus, Event)>(1); let (processor_event_tx, mut processor_event_rx) = mpsc::channel::(1); let (editor_copy_tx, mut editor_copy_rx) = mpsc::channel::<()>(1); @@ -161,9 +164,10 @@ pub async fn run( let (editor_focus_tx, mut editor_focus_rx) = mpsc::channel::(1); - let mut text_diff = [editor.text(), editor.text()]; + let text_diff = Arc::new(RwLock::new([editor.text(), editor.text()])); let shared_editor = Arc::new(RwLock::new(editor)); let shared_searcher = Arc::new(RwLock::new(searcher)); + let editor_keybinds = keybinds.on_editor.clone(); let initializing = json_viewer::initialize( item, json_config, @@ -176,6 +180,7 @@ pub async fn run( let mut stream = EventStream::new(); let shared_renderer = shared_renderer.clone(); let ctx = ctx.clone(); + let editor_keybinds = editor_keybinds.clone(); tokio::spawn(async move { 'main: loop { tokio::select! { @@ -228,7 +233,7 @@ pub async fn run( }, event if keybinds.switch_mode.contains(&event) => { match focus { - Focus::Editor => { + Focus::Editor | Focus::Searcher => { if ctx.is_idle().await { focus = Focus::Processor; editor_focus_tx.send(false).await?; @@ -265,7 +270,28 @@ pub async fn run( event => { match focus { Focus::Editor => { - editor_event_tx.send(event).await?; + if editor_keybinds.completion.contains(&event) { + focus = Focus::Searcher; + searcher_event_tx.send((Focus::Editor, event)).await?; + } else { + editor_event_tx.send(event).await?; + } + }, + Focus::Searcher => { + if editor_keybinds.on_completion.down.contains(&event) + || editor_keybinds.on_completion.up.contains(&event) + || editor_keybinds.completion.contains(&event) + { + searcher_event_tx.send((Focus::Searcher, event)).await?; + } else { + focus = Focus::Editor; + searcher_event_tx + .send((Focus::Searcher, event.clone())) + .await?; + if !editor_keybinds.completion.contains(&event) { + editor_event_tx.send(event).await?; + } + } }, Focus::Processor => { processor_event_tx.send(event).await?; @@ -287,6 +313,8 @@ pub async fn run( let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); let shared_searcher = shared_searcher.clone(); + let text_diff = text_diff.clone(); + let debounce_query_tx = debounce_query_tx.clone(); tokio::spawn(async move { loop { tokio::select! { @@ -297,12 +325,13 @@ pub async fn run( if focus { editor.focus(); } else { - editor.defocus(&mut searcher); + editor.defocus(); + searcher.leave_search(); } ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(&searcher, size.0, size.1), + searcher.create_pane(size.0, size.1), ) }; shared_renderer.update([ @@ -329,19 +358,20 @@ pub async fn run( let size = terminal::size()?; let (editor_pane, guide_pane, searcher_pane) = { let mut editor = shared_editor.write().await; - let mut searcher = shared_searcher.write().await; - editor.operate(&event, &mut searcher).await?; + editor.operate(&event).await?; + let searcher = shared_searcher.read().await; let current_text = editor.text(); - if current_text != text_diff[1] { + let mut diff = text_diff.write().await; + if current_text != diff[1] { debounce_query_tx.send(current_text.clone()).await?; - text_diff[0] = text_diff[1].clone(); - text_diff[1] = current_text; + diff[0] = diff[1].clone(); + diff[1] = current_text; } ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(&searcher, size.0, size.1), + searcher.create_pane(size.0, size.1), ) }; { @@ -361,6 +391,82 @@ pub async fn run( }) }; + let searcher_task: JoinHandle> = { + let shared_renderer = shared_renderer.clone(); + let shared_editor = shared_editor.clone(); + let shared_searcher = shared_searcher.clone(); + let text_diff = text_diff.clone(); + let debounce_query_tx = debounce_query_tx.clone(); + let editor_keybinds = editor_keybinds.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + Some((source_focus, event)) = searcher_event_rx.recv() => { + let size = terminal::size()?; + let (editor_pane, guide_pane, searcher_pane) = { + let mut editor = shared_editor.write().await; + let mut searcher = shared_searcher.write().await; + match source_focus { + Focus::Editor => { + let prefix = editor.text(); + let (head_item, load_progress) = + searcher.start_search(&prefix).await; + match head_item { + Some(head) => { + editor.set_completion_found_guide( + load_progress.loaded_path_count, + load_progress.is_complete, + ); + editor.replace_text(&head); + } + None => editor.set_completion_empty_guide(&prefix), + } + } + Focus::Searcher => { + if editor_keybinds.on_completion.down.contains(&event) + || editor_keybinds.completion.contains(&event) + { + searcher.down_with_load(); + editor.replace_text(&searcher.get_current_item()); + } else if editor_keybinds.on_completion.up.contains(&event) { + searcher.up(); + editor.replace_text(&searcher.get_current_item()); + } else { + searcher.leave_search(); + } + } + Focus::Processor => { + // No-op: Searcher events are not expected from Processor focus. + } + } + + let current_text = editor.text(); + let mut diff = text_diff.write().await; + if current_text != diff[1] { + debounce_query_tx.send(current_text.clone()).await?; + diff[0] = diff[1].clone(); + diff[1] = current_text; + } + ( + editor.create_editor_pane(size.0, size.1), + editor.create_guide_pane(size.0, size.1), + searcher.create_pane(size.0, size.1), + ) + }; + + shared_renderer.update([ + (Index::Editor, editor_pane), + (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), + (Index::Search, searcher_pane), + ]).render().await?; + } + else => break, + } + } + Ok(()) + }) + }; + let shared_viewer_state = initializing.await?; let processor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); @@ -407,7 +513,7 @@ pub async fn run( ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(&searcher, size.0, size.1), + searcher.create_pane(size.0, size.1), ) }; { @@ -451,6 +557,7 @@ pub async fn run( query_debouncer.abort(); resize_debouncer.abort(); editor_task.abort(); + searcher_task.abort(); processor_task.abort(); execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; diff --git a/src/search.rs b/src/search.rs index 0f5de89..30d9d03 100644 --- a/src/search.rs +++ b/src/search.rs @@ -37,12 +37,6 @@ impl SharedSuggestionStore { .collect::>(); (items, store.progress.clone()) } - - /// Get the current load progress of suggestions - pub async fn load_progress(&self) -> SuggestionLoadProgress { - let store = self.0.lock().await; - store.progress.clone() - } } /// Initialize shared suggestion store by loading paths from JSON input @@ -161,8 +155,10 @@ impl IncrementalSearcher { Some(self.state.listbox.get().to_string()) } - pub async fn load_progress(&self) -> SuggestionLoadProgress { - self.shared_suggestions.load_progress().await + pub async fn start_search(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { + let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; + let head_item = self.apply_search_items(items); + (head_item, progress) } fn load_more(&mut self) { From 93bd8816e931e7c1a0807f78e01afc3771c9bfd9 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 13:54:58 +0900 Subject: [PATCH 23/74] chore: define action-based handlers --- src/editor.rs | 4 + src/prompt.rs | 436 ++++++++++++++++++++++++++++---------------------- src/search.rs | 2 +- 3 files changed, 250 insertions(+), 192 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 3160148..9f7bd55 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -59,6 +59,10 @@ impl Editor { self.guide = status::State::default(); } + pub fn set_guide(&mut self, guide: status::State) { + self.guide = guide; + } + pub fn replace_text(&mut self, text: &str) { self.state.texteditor.replace(text); } diff --git a/src/prompt.rs b/src/prompt.rs index 7bd4719..5eb4df9 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -85,6 +85,32 @@ enum Focus { Processor, } +enum GlobalAction { + Resize(u16, u16), + Exit, + CopyQuery, + CopyResult, + SwitchMode, +} + +enum EditorAction { + Focus(bool), + CopyQuery, + UserEvent(Event), +} + +enum SearchAction { + Start, + UserEvent(Event), + Leave, +} + +enum JsonViewerAction { + CopyResult, + UserEvent(Event), + QueryChanged(String), +} + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { Editor = 0, @@ -155,14 +181,9 @@ pub async fn run( }); let mut focus = Focus::Editor; - let (editor_event_tx, mut editor_event_rx) = mpsc::channel::(1); - let (searcher_event_tx, mut searcher_event_rx) = mpsc::channel::<(Focus, Event)>(1); - let (processor_event_tx, mut processor_event_rx) = mpsc::channel::(1); - - let (editor_copy_tx, mut editor_copy_rx) = mpsc::channel::<()>(1); - let (processor_copy_tx, mut processor_copy_rx) = mpsc::channel::<()>(1); - - let (editor_focus_tx, mut editor_focus_rx) = mpsc::channel::(1); + let (editor_action_tx, mut editor_action_rx) = mpsc::channel::(1); + let (search_action_tx, mut search_action_rx) = mpsc::channel::(1); + let (json_viewer_action_tx, mut json_viewer_action_rx) = mpsc::channel::(8); let text_diff = Arc::new(RwLock::new([editor.text(), editor.text()])); let shared_editor = Arc::new(RwLock::new(editor)); @@ -181,6 +202,7 @@ pub async fn run( let shared_renderer = shared_renderer.clone(); let ctx = ctx.clone(); let editor_keybinds = editor_keybinds.clone(); + let json_viewer_action_tx = json_viewer_action_tx.clone(); tokio::spawn(async move { 'main: loop { tokio::select! { @@ -204,100 +226,124 @@ pub async fn run( other => other, }; - match event { - Event::Resize(width, height) => { - debounce_resize_tx.send((width, height)).await?; - }, - event if keybinds.exit.contains(&event) => { - break 'main - }, - event if keybinds.copy_query.contains(&event) => { - editor_copy_tx.send(()).await?; - }, - event if keybinds.copy_result.contains(&event) => { - if ctx.is_idle().await { - processor_copy_tx.send(()).await?; - } else if !no_hint{ - let size = terminal::size()?; - shared_renderer.update([ - ( - Index::Guide, - status::State::new( - "Failed to copy while rendering is in progress.", - Severity::Warning, - ) - .create_graphemes(size.0, size.1), - ), - ]).render().await?; + let global_action = if let Event::Resize(width, height) = event { + Some(GlobalAction::Resize(width, height)) + } else if keybinds.exit.contains(&event) { + Some(GlobalAction::Exit) + } else if keybinds.copy_query.contains(&event) { + Some(GlobalAction::CopyQuery) + } else if keybinds.copy_result.contains(&event) { + Some(GlobalAction::CopyResult) + } else if keybinds.switch_mode.contains(&event) { + Some(GlobalAction::SwitchMode) + } else { + None + }; + + if let Some(action) = global_action { + match action { + GlobalAction::Resize(width, height) => { + debounce_resize_tx.send((width, height)).await?; } - }, - event if keybinds.switch_mode.contains(&event) => { - match focus { + GlobalAction::Exit => break 'main, + GlobalAction::CopyQuery => { + editor_action_tx.send(EditorAction::CopyQuery).await?; + } + GlobalAction::CopyResult => { + if ctx.is_idle().await { + json_viewer_action_tx + .send(JsonViewerAction::CopyResult) + .await?; + } else if !no_hint { + let size = terminal::size()?; + shared_renderer + .update([( + Index::Guide, + status::State::new( + "Failed to copy while rendering is in progress.", + Severity::Warning, + ) + .create_graphemes(size.0, size.1), + )]) + .render() + .await?; + } + } + GlobalAction::SwitchMode => match focus { Focus::Editor | Focus::Searcher => { if ctx.is_idle().await { focus = Focus::Processor; - editor_focus_tx.send(false).await?; + search_action_tx.send(SearchAction::Leave).await?; + editor_action_tx.send(EditorAction::Focus(false)).await?; execute!( io::stdout(), terminal::EnterAlternateScreen, EnableMouseCapture, )?; - } else if !no_hint{ + } else if !no_hint { let size = terminal::size()?; - shared_renderer.update([ - ( + shared_renderer + .update([( Index::Guide, status::State::new( "Failed to switch pane while rendering is in progress.", Severity::Warning, ) .create_graphemes(size.0, size.1), - ), - ]).render().await?; + )]) + .render() + .await?; } - }, + } Focus::Processor => { focus = Focus::Editor; - editor_focus_tx.send(true).await?; + editor_action_tx.send(EditorAction::Focus(true)).await?; execute!( io::stdout(), terminal::LeaveAlternateScreen, DisableMouseCapture, )?; - }, + } + }, + } + continue; + } + + match focus { + Focus::Editor => { + if editor_keybinds.completion.contains(&event) { + focus = Focus::Searcher; + search_action_tx.send(SearchAction::Start).await?; + } else { + editor_action_tx.send(EditorAction::UserEvent(event)).await?; } - }, - event => { - match focus { - Focus::Editor => { - if editor_keybinds.completion.contains(&event) { - focus = Focus::Searcher; - searcher_event_tx.send((Focus::Editor, event)).await?; - } else { - editor_event_tx.send(event).await?; - } - }, - Focus::Searcher => { - if editor_keybinds.on_completion.down.contains(&event) - || editor_keybinds.on_completion.up.contains(&event) - || editor_keybinds.completion.contains(&event) - { - searcher_event_tx.send((Focus::Searcher, event)).await?; - } else { - focus = Focus::Editor; - searcher_event_tx - .send((Focus::Searcher, event.clone())) - .await?; - if !editor_keybinds.completion.contains(&event) { - editor_event_tx.send(event).await?; - } - } - }, - Focus::Processor => { - processor_event_tx.send(event).await?; - }, + } + Focus::Searcher => { + if editor_keybinds.on_completion.down.contains(&event) + || editor_keybinds.completion.contains(&event) + { + search_action_tx + .send(SearchAction::UserEvent(event)) + .await?; + } else if editor_keybinds.on_completion.up.contains(&event) { + search_action_tx + .send(SearchAction::UserEvent(event)) + .await?; + } else { + focus = Focus::Editor; + search_action_tx.send(SearchAction::Leave).await?; + if !editor_keybinds.completion.contains(&event) { + editor_action_tx + .send(EditorAction::UserEvent(event)) + .await?; + } } - }, + } + Focus::Processor => { + json_viewer_action_tx + .send(JsonViewerAction::UserEvent(event)) + .await?; + } } }, else => { @@ -309,6 +355,17 @@ pub async fn run( }) }; + let query_action_forwarder = { + let json_viewer_action_tx = json_viewer_action_tx.clone(); + tokio::spawn(async move { + while let Some(query) = last_query_rx.recv().await { + let _ = json_viewer_action_tx + .send(JsonViewerAction::QueryChanged(query)) + .await; + } + }) + }; + let editor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); @@ -318,70 +375,49 @@ pub async fn run( tokio::spawn(async move { loop { tokio::select! { - Some(focus) = editor_focus_rx.recv() => { - let (editor_pane, guide_pane, searcher_pane) = { + Some(action) = editor_action_rx.recv() => { + let size = terminal::size()?; + let (editor_pane, guide_pane, searcher_pane, maybe_text_for_debounce) = { let mut editor = shared_editor.write().await; - let mut searcher = shared_searcher.write().await; - if focus { - editor.focus(); - } else { - editor.defocus(); - searcher.leave_search(); + match action { + EditorAction::Focus(focus) => { + let mut searcher = shared_searcher.write().await; + if focus { + editor.focus(); + } else { + editor.defocus(); + searcher.leave_search(); + } + } + EditorAction::CopyQuery => { + let guide = copy_to_clipboard(&editor.text()); + editor.set_guide(guide); + } + EditorAction::UserEvent(event) => { + editor.operate(&event).await?; + } } + let searcher = shared_searcher.read().await; + let current_text = editor.text(); ( editor.create_editor_pane(size.0, size.1), editor.create_guide_pane(size.0, size.1), searcher.create_pane(size.0, size.1), + current_text, ) }; + let mut diff = text_diff.write().await; + if maybe_text_for_debounce != diff[1] { + debounce_query_tx.send(maybe_text_for_debounce.clone()).await?; + diff[0] = diff[1].clone(); + diff[1] = maybe_text_for_debounce; + } shared_renderer.update([ (Index::Editor, editor_pane), (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), (Index::Search, searcher_pane), ]).render().await?; } - Some(()) = editor_copy_rx.recv() => { - let text = { - let editor = shared_editor.read().await; - editor.text() - }; - let guide = copy_to_clipboard(&text); - if !no_hint { - let size = terminal::size()?; - let pane = guide.create_graphemes(size.0, size.1); - shared_renderer.update([ - (Index::Guide, pane), - ]).render().await?; - } - } - Some(event) = editor_event_rx.recv() => { - let size = terminal::size()?; - let (editor_pane, guide_pane, searcher_pane) = { - let mut editor = shared_editor.write().await; - editor.operate(&event).await?; - let searcher = shared_searcher.read().await; - - let current_text = editor.text(); - let mut diff = text_diff.write().await; - if current_text != diff[1] { - debounce_query_tx.send(current_text.clone()).await?; - diff[0] = diff[1].clone(); - diff[1] = current_text; - } - ( - editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), - searcher.create_pane(size.0, size.1), - ) - }; - { - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), - (Index::Search, searcher_pane), - ]).render().await?; - } - } else => { break } @@ -401,13 +437,13 @@ pub async fn run( tokio::spawn(async move { loop { tokio::select! { - Some((source_focus, event)) = searcher_event_rx.recv() => { + Some(action) = search_action_rx.recv() => { let size = terminal::size()?; let (editor_pane, guide_pane, searcher_pane) = { let mut editor = shared_editor.write().await; let mut searcher = shared_searcher.write().await; - match source_focus { - Focus::Editor => { + match action { + SearchAction::Start => { let prefix = editor.text(); let (head_item, load_progress) = searcher.start_search(&prefix).await; @@ -422,7 +458,7 @@ pub async fn run( None => editor.set_completion_empty_guide(&prefix), } } - Focus::Searcher => { + SearchAction::UserEvent(event) => { if editor_keybinds.on_completion.down.contains(&event) || editor_keybinds.completion.contains(&event) { @@ -431,12 +467,10 @@ pub async fn run( } else if editor_keybinds.on_completion.up.contains(&event) { searcher.up(); editor.replace_text(&searcher.get_current_item()); - } else { - searcher.leave_search(); } } - Focus::Processor => { - // No-op: Searcher events are not expected from Processor focus. + SearchAction::Leave => { + searcher.leave_search(); } } @@ -468,72 +502,90 @@ pub async fn run( }; let shared_viewer_state = initializing.await?; - let processor_task: JoinHandle> = { + let resize_action_forwarder = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); let shared_searcher = shared_searcher.clone(); let shared_viewer_state = shared_viewer_state.clone(); let ctx = ctx.clone(); + tokio::spawn(async move { + while let Some(area) = last_resize_rx.recv().await { + let size = terminal::size()?; + let (editor_pane, guide_pane, searcher_pane) = { + let editor = shared_editor.read().await; + let searcher = shared_searcher.read().await; + ( + editor.create_editor_pane(size.0, size.1), + editor.create_guide_pane(size.0, size.1), + searcher.create_pane(size.0, size.1), + ) + }; + shared_renderer + .update([ + (Index::Editor, editor_pane), + ( + Index::Guide, + if !no_hint { guide_pane } else { empty_pane() }, + ), + (Index::Search, searcher_pane), + ]) + .render() + .await?; + let text = { + let editor = shared_editor.read().await; + editor.text() + }; + json_viewer::render( + shared_viewer_state.clone(), + shared_renderer.clone(), + ctx.clone(), + RenderTrigger::AreaResized { area, query: text }, + ) + .await; + } + Ok::<(), anyhow::Error>(()) + }) + }; + + let processor_task: JoinHandle> = { + let shared_renderer = shared_renderer.clone(); + let shared_viewer_state = shared_viewer_state.clone(); + let ctx = ctx.clone(); tokio::spawn(async move { loop { tokio::select! { - Some(()) = processor_copy_rx.recv() => { - let runtime = shared_viewer_state.lock().await; - let guide = copy_to_clipboard(&runtime.formatted_content()); - if !no_hint { - let size = terminal::size()?; - let pane = guide.create_graphemes(size.0, size.1); - shared_renderer.update([ - (Index::Guide, pane), - ]).render().await?; - } - } - Some(event) = processor_event_rx.recv() => { - json_viewer::render( - shared_viewer_state.clone(), - shared_renderer.clone(), - ctx.clone(), - RenderTrigger::UserAction(event), - ) - .await; - } - Some(query) = last_query_rx.recv() => { - json_viewer::render( - shared_viewer_state.clone(), - shared_renderer.clone(), - ctx.clone(), - RenderTrigger::QueryChanged { query }, - ) - .await; - } - Some(area) = last_resize_rx.recv() => { - let (editor_pane, guide_pane, searcher_pane) = { - let editor = shared_editor.read().await; - let searcher = shared_searcher.read().await; - ( - editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), - searcher.create_pane(size.0, size.1), - ) - }; - { - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), - (Index::Search, searcher_pane), - ]).render().await?; + Some(action) = json_viewer_action_rx.recv() => { + match action { + JsonViewerAction::CopyResult => { + let runtime = shared_viewer_state.lock().await; + let guide = copy_to_clipboard(&runtime.formatted_content()); + if !no_hint { + let size = terminal::size()?; + let pane = guide.create_graphemes(size.0, size.1); + shared_renderer.update([ + (Index::Guide, pane), + ]).render().await?; + } + } + JsonViewerAction::UserEvent(event) => { + json_viewer::render( + shared_viewer_state.clone(), + shared_renderer.clone(), + ctx.clone(), + RenderTrigger::UserAction(event), + ) + .await; + } + JsonViewerAction::QueryChanged(query) => { + json_viewer::render( + shared_viewer_state.clone(), + shared_renderer.clone(), + ctx.clone(), + RenderTrigger::QueryChanged { query }, + ) + .await; + } } - let text = { - let editor = shared_editor.read().await; - editor.text() - }; - json_viewer::render( - shared_viewer_state.clone(), - shared_renderer.clone(), - ctx.clone(), - RenderTrigger::AreaResized { area, query: text }, - ) - .await; } else => { break @@ -556,6 +608,8 @@ pub async fn run( spinning.abort(); query_debouncer.abort(); resize_debouncer.abort(); + query_action_forwarder.abort(); + resize_action_forwarder.abort(); editor_task.abort(); searcher_task.abort(); processor_task.abort(); diff --git a/src/search.rs b/src/search.rs index 30d9d03..3c733d1 100644 --- a/src/search.rs +++ b/src/search.rs @@ -143,7 +143,7 @@ impl IncrementalSearcher { self.search_chunk_remaining = Vec::::new(); } - pub fn apply_search_items(&mut self, mut items: Vec) -> Option { + fn apply_search_items(&mut self, mut items: Vec) -> Option { if items.is_empty() { return None; } From 27322941fd4ba955aa0a14d5c211ff51cd66f9cc Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 14:33:47 +0900 Subject: [PATCH 24/74] chore: separate task definition only for json_viewer and define guide --- src/editor.rs | 39 ---------- src/guide.rs | 104 ++++++++++++++++++++++++++ src/json_viewer.rs | 110 +++++++++++++++++++++------- src/main.rs | 1 + src/prompt.rs | 178 ++++++++++++++------------------------------- 5 files changed, 242 insertions(+), 190 deletions(-) create mode 100644 src/guide.rs diff --git a/src/editor.rs b/src/editor.rs index 9f7bd55..84d6070 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4,7 +4,6 @@ use promkit_widgets::{ grapheme::StyledGraphemes, Widget, }, - status::{self, Severity}, text_editor, }; @@ -14,7 +13,6 @@ pub struct Editor { state: text_editor::State, focus_config: text_editor::Config, defocus_config: text_editor::Config, - guide: status::State, editor_keybinds: EditorKeybinds, } @@ -29,7 +27,6 @@ impl Editor { state, focus_config, defocus_config, - guide: status::State::default(), editor_keybinds, } } @@ -40,7 +37,6 @@ impl Editor { pub fn defocus(&mut self) { self.state.config = self.defocus_config.clone(); - self.guide = status::State::default(); } pub fn text(&self) -> String { @@ -51,46 +47,11 @@ impl Editor { self.state.create_graphemes(width, height) } - pub fn create_guide_pane(&self, width: u16, height: u16) -> StyledGraphemes { - self.guide.create_graphemes(width, height) - } - - pub fn clear_guide(&mut self) { - self.guide = status::State::default(); - } - - pub fn set_guide(&mut self, guide: status::State) { - self.guide = guide; - } - pub fn replace_text(&mut self, text: &str) { self.state.texteditor.replace(text); } - pub fn set_completion_found_guide(&mut self, loaded_path_count: usize, is_complete: bool) { - if is_complete { - self.guide = status::State::new( - format!("Loaded all ({loaded_path_count}) suggestions"), - Severity::Success, - ); - } else { - self.guide = status::State::new( - format!("Loaded partially ({loaded_path_count}) suggestions"), - Severity::Success, - ); - } - } - - pub fn set_completion_empty_guide(&mut self, prefix: &str) { - self.guide = status::State::new( - format!("No suggestion found for '{prefix}'"), - Severity::Warning, - ); - } - pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { - self.clear_guide(); - match event { // Move cursor. key if self.editor_keybinds.backward.contains(key) => { diff --git a/src/guide.rs b/src/guide.rs new file mode 100644 index 0000000..3ab6351 --- /dev/null +++ b/src/guide.rs @@ -0,0 +1,104 @@ +use arboard::Clipboard; +use promkit_widgets::{ + core::{crossterm::terminal, render::SharedRenderer, Widget}, + status::{self, Severity}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +use crate::prompt::Index; + +pub enum GuideMessage { + CopiedToClipboard, + FailedToCopyToClipboard(String), + FailedToSetupClipboard(String), + FailedToCopyWhileRenderingInProgress, + FailedToSwitchPaneWhileRenderingInProgress, + LoadedAllSuggestions(usize), + LoadedPartiallySuggestions(usize), + NoSuggestionFound(String), + JqReturnedNull(String), + JqFailed(String), +} + +pub enum GuideAction { + Clear, + Show(GuideMessage), +} + +pub fn message_to_state(message: GuideMessage) -> status::State { + match message { + GuideMessage::CopiedToClipboard => { + status::State::new("Copied to clipboard", Severity::Success) + } + GuideMessage::FailedToCopyToClipboard(e) => { + status::State::new(format!("Failed to copy to clipboard: {e}"), Severity::Error) + } + GuideMessage::FailedToSetupClipboard(e) => { + status::State::new(format!("Failed to setup clipboard: {e}"), Severity::Error) + } + GuideMessage::FailedToCopyWhileRenderingInProgress => status::State::new( + "Failed to copy while rendering is in progress.", + Severity::Warning, + ), + GuideMessage::FailedToSwitchPaneWhileRenderingInProgress => status::State::new( + "Failed to switch pane while rendering is in progress.", + Severity::Warning, + ), + GuideMessage::LoadedAllSuggestions(count) => status::State::new( + format!("Loaded all ({count}) suggestions"), + Severity::Success, + ), + GuideMessage::LoadedPartiallySuggestions(count) => status::State::new( + format!("Loaded partially ({count}) suggestions"), + Severity::Success, + ), + GuideMessage::NoSuggestionFound(prefix) => status::State::new( + format!("No suggestion found for '{prefix}'"), + Severity::Warning, + ), + GuideMessage::JqReturnedNull(input) => status::State::new( + format!("jq returned 'null', which may indicate a typo or incorrect filter: `{input}`"), + Severity::Warning, + ), + GuideMessage::JqFailed(e) => { + status::State::new(format!("jq failed: `{e}`"), Severity::Error) + } + } +} + +pub fn copy_to_clipboard_message(content: &str) -> GuideMessage { + match Clipboard::new() { + Ok(mut clipboard) => match clipboard.set_text(content) { + Ok(_) => GuideMessage::CopiedToClipboard, + Err(e) => GuideMessage::FailedToCopyToClipboard(e.to_string()), + }, + Err(e) => GuideMessage::FailedToSetupClipboard(e.to_string()), + } +} + +pub fn start_guide_task( + mut action_rx: mpsc::Receiver, + shared_renderer: SharedRenderer, + no_hint: bool, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + let size = terminal::size()?; + let pane = if no_hint { + Default::default() + } else { + match action { + GuideAction::Clear => status::State::default().create_graphemes(size.0, size.1), + GuideAction::Show(message) => message_to_state(message).create_graphemes(size.0, size.1), + } + }; + shared_renderer.update([(Index::Guide, pane)]).render().await?; + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 2f0a9f9..338e6c1 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -10,12 +10,15 @@ use promkit_widgets::{ jsonstream::{self, JsonStream}, serde_json::{self, Value}, spinner, - status::{self, Severity}, }; -use tokio::{sync::Mutex, task::JoinHandle}; +use tokio::{ + sync::{mpsc, Mutex}, + task::JoinHandle, +}; use crate::{ config::{JsonConfig, JsonViewerKeybinds}, + guide::{self, GuideAction, GuideMessage}, json, prompt::Index, }; @@ -91,6 +94,12 @@ pub struct JsonViewer { pub type SharedJsonViewer = Arc>; +pub enum ViewerAction { + CopyResult, + UserEvent(Event), + QueryChanged(String), +} + impl JsonViewer { /// Get the formatted content of current JSON stream. pub fn formatted_content(&self) -> String { @@ -142,20 +151,12 @@ impl JsonViewer { &mut self, area: (u16, u16), input: String, - ) -> (Option, Option) { + ) -> (Option, Option) { match json::run_jaq(&input, &self.json) { Ok(ret) => { let mut guide = None; if ret.iter().all(|val| *val == Value::Null) { - guide = Some( - status::State::new( - format!( - "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" - ), - Severity::Warning, - ) - .create_graphemes(area.0, area.1), - ); + guide = Some(GuideMessage::JqReturnedNull(input)); self.state.stream = JsonStream::new(self.json.iter()); } else { @@ -168,10 +169,7 @@ impl JsonViewer { self.state.stream = JsonStream::new(self.json.iter()); ( - Some( - status::State::new(format!("jq failed: `{e}`"), Severity::Error) - .create_graphemes(area.0, area.1), - ), + Some(GuideMessage::JqFailed(e.to_string())), Some(self.state.create_graphemes(area.0, area.1)), ) } @@ -232,6 +230,7 @@ pub async fn render( shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, trigger: RenderTrigger, ) { match trigger { @@ -239,13 +238,21 @@ pub async fn render( handle_user_action(shared_viewer_state, shared_renderer, shared_ctx, event).await; } RenderTrigger::QueryChanged { query } => { - handle_query_changed(shared_viewer_state, shared_renderer, shared_ctx, query).await; + handle_query_changed( + shared_viewer_state, + shared_renderer, + shared_ctx, + guide_action_tx, + query, + ) + .await; } RenderTrigger::AreaResized { area, query } => { handle_area_resized( shared_viewer_state, shared_renderer, shared_ctx, + guide_action_tx, area, query, ) @@ -282,6 +289,7 @@ async fn handle_query_changed( shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, query: String, ) { // Abort any ongoing processing task to prevent race conditions @@ -296,6 +304,7 @@ async fn handle_query_changed( let task = spawn_query_update_task( shared_viewer_state.clone(), shared_ctx.clone(), + guide_action_tx, shared_renderer, query, ); @@ -312,6 +321,7 @@ async fn handle_area_resized( shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, area: (u16, u16), query: String, ) { @@ -331,6 +341,7 @@ async fn handle_area_resized( let task = spawn_query_update_task( shared_viewer_state.clone(), shared_ctx.clone(), + guide_action_tx, shared_renderer, query, ); @@ -349,6 +360,7 @@ async fn handle_area_resized( fn spawn_query_update_task( shared_viewer_state: SharedJsonViewer, shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, shared_renderer: SharedRenderer, query: String, ) -> JoinHandle<()> { @@ -374,19 +386,63 @@ fn spawn_query_update_task( ctx.state = State::Idle; } + if let Some(message) = maybe_guide { + let _ = guide_action_tx.send(GuideAction::Show(message)).await; + } + // TODO: error handling let _ = shared_renderer - .update([ - ( - Index::Guide, - maybe_guide.unwrap_or(StyledGraphemes::default()), - ), - ( - Index::Processor, - maybe_resp.unwrap_or(StyledGraphemes::default()), - ), - ]) + .update([( + Index::Processor, + maybe_resp.unwrap_or(StyledGraphemes::default()), + )]) .render() .await; }) } + +pub fn start_viewer_task( + mut action_rx: mpsc::Receiver, + guide_action_tx: mpsc::Sender, + shared_viewer_state: SharedJsonViewer, + shared_renderer: SharedRenderer, + shared_ctx: SharedContext, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + match action { + ViewerAction::CopyResult => { + let runtime = shared_viewer_state.lock().await; + let message = guide::copy_to_clipboard_message(&runtime.formatted_content()); + let _ = guide_action_tx.send(GuideAction::Show(message)).await; + } + ViewerAction::UserEvent(event) => { + render( + shared_viewer_state.clone(), + shared_renderer.clone(), + shared_ctx.clone(), + guide_action_tx.clone(), + RenderTrigger::UserAction(event), + ) + .await; + } + ViewerAction::QueryChanged(query) => { + render( + shared_viewer_state.clone(), + shared_renderer.clone(), + shared_ctx.clone(), + guide_action_tx.clone(), + RenderTrigger::QueryChanged { query }, + ) + .await; + } + } + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/main.rs b/src/main.rs index 4539c8b..e7f9625 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use promkit_widgets::{ mod editor; use editor::Editor; mod config; +mod guide; mod json_viewer; mod stdout_redirect; use stdout_redirect::StdoutRedirect; diff --git a/src/prompt.rs b/src/prompt.rs index 5eb4df9..4894d63 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -1,6 +1,5 @@ use std::{io, sync::Arc, time::Duration}; -use arboard::Clipboard; use futures::StreamExt; use promkit_widgets::{ core::{ @@ -15,10 +14,8 @@ use promkit_widgets::{ }, grapheme::StyledGraphemes, render::{Renderer, SharedRenderer}, - Widget, }, spinner::{self, Spinner, State}, - status::{self, Severity}, }; use tokio::{ sync::{mpsc, RwLock}, @@ -27,6 +24,7 @@ use tokio::{ use crate::{ config::{JsonConfig, Keybinds, ReactivityControl}, + guide::{self, GuideAction, GuideMessage}, json_viewer::{self, RenderTrigger, SharedContext}, search::IncrementalSearcher, Editor, @@ -59,21 +57,6 @@ fn spawn_debouncer( }) } -fn copy_to_clipboard(content: &str) -> status::State { - match Clipboard::new() { - Ok(mut clipboard) => match clipboard.set_text(content) { - Ok(_) => status::State::new("Copied to clipboard", Severity::Success), - Err(e) => { - status::State::new(format!("Failed to copy to clipboard: {e}"), Severity::Error) - } - }, - // arboard fails (in the specific environment like linux?) on Clipboard::new() - // suppress the errors (but still show them) not to break the prompt - // https://github.com/1Password/arboard/issues/153 - Err(e) => status::State::new(format!("Failed to setup clipboard: {e}"), Severity::Error), - } -} - fn empty_pane() -> StyledGraphemes { StyledGraphemes::default() } @@ -105,12 +88,6 @@ enum SearchAction { Leave, } -enum JsonViewerAction { - CopyResult, - UserEvent(Event), - QueryChanged(String), -} - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { Editor = 0, @@ -183,7 +160,9 @@ pub async fn run( let mut focus = Focus::Editor; let (editor_action_tx, mut editor_action_rx) = mpsc::channel::(1); let (search_action_tx, mut search_action_rx) = mpsc::channel::(1); - let (json_viewer_action_tx, mut json_viewer_action_rx) = mpsc::channel::(8); + let (json_viewer_action_tx, json_viewer_action_rx) = + mpsc::channel::(8); + let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); let text_diff = Arc::new(RwLock::new([editor.text(), editor.text()])); let shared_editor = Arc::new(RwLock::new(editor)); @@ -199,10 +178,10 @@ pub async fn run( let main_task: JoinHandle> = { let mut stream = EventStream::new(); - let shared_renderer = shared_renderer.clone(); let ctx = ctx.clone(); let editor_keybinds = editor_keybinds.clone(); let json_viewer_action_tx = json_viewer_action_tx.clone(); + let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { 'main: loop { tokio::select! { @@ -225,6 +204,7 @@ pub async fn run( } other => other, }; + guide_action_tx.send(GuideAction::Clear).await?; let global_action = if let Event::Resize(width, height) = event { Some(GlobalAction::Resize(width, height)) @@ -252,20 +232,13 @@ pub async fn run( GlobalAction::CopyResult => { if ctx.is_idle().await { json_viewer_action_tx - .send(JsonViewerAction::CopyResult) + .send(json_viewer::ViewerAction::CopyResult) .await?; - } else if !no_hint { - let size = terminal::size()?; - shared_renderer - .update([( - Index::Guide, - status::State::new( - "Failed to copy while rendering is in progress.", - Severity::Warning, - ) - .create_graphemes(size.0, size.1), - )]) - .render() + } else { + guide_action_tx + .send(GuideAction::Show( + GuideMessage::FailedToCopyWhileRenderingInProgress, + )) .await?; } } @@ -280,18 +253,11 @@ pub async fn run( terminal::EnterAlternateScreen, EnableMouseCapture, )?; - } else if !no_hint { - let size = terminal::size()?; - shared_renderer - .update([( - Index::Guide, - status::State::new( - "Failed to switch pane while rendering is in progress.", - Severity::Warning, - ) - .create_graphemes(size.0, size.1), - )]) - .render() + } else { + guide_action_tx + .send(GuideAction::Show( + GuideMessage::FailedToSwitchPaneWhileRenderingInProgress, + )) .await?; } } @@ -341,7 +307,7 @@ pub async fn run( } Focus::Processor => { json_viewer_action_tx - .send(JsonViewerAction::UserEvent(event)) + .send(json_viewer::ViewerAction::UserEvent(event)) .await?; } } @@ -360,24 +326,27 @@ pub async fn run( tokio::spawn(async move { while let Some(query) = last_query_rx.recv().await { let _ = json_viewer_action_tx - .send(JsonViewerAction::QueryChanged(query)) + .send(json_viewer::ViewerAction::QueryChanged(query)) .await; } }) }; + let guide_task = guide::start_guide_task(guide_action_rx, shared_renderer.clone(), no_hint); + let editor_task: JoinHandle> = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); let shared_searcher = shared_searcher.clone(); let text_diff = text_diff.clone(); let debounce_query_tx = debounce_query_tx.clone(); + let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { loop { tokio::select! { Some(action) = editor_action_rx.recv() => { let size = terminal::size()?; - let (editor_pane, guide_pane, searcher_pane, maybe_text_for_debounce) = { + let (editor_pane, searcher_pane, maybe_text_for_debounce) = { let mut editor = shared_editor.write().await; match action { EditorAction::Focus(focus) => { @@ -390,8 +359,8 @@ pub async fn run( } } EditorAction::CopyQuery => { - let guide = copy_to_clipboard(&editor.text()); - editor.set_guide(guide); + let message = guide::copy_to_clipboard_message(&editor.text()); + guide_action_tx.send(GuideAction::Show(message)).await?; } EditorAction::UserEvent(event) => { editor.operate(&event).await?; @@ -401,7 +370,6 @@ pub async fn run( let current_text = editor.text(); ( editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), searcher.create_pane(size.0, size.1), current_text, ) @@ -414,7 +382,6 @@ pub async fn run( } shared_renderer.update([ (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), (Index::Search, searcher_pane), ]).render().await?; } @@ -434,12 +401,13 @@ pub async fn run( let text_diff = text_diff.clone(); let debounce_query_tx = debounce_query_tx.clone(); let editor_keybinds = editor_keybinds.clone(); + let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { loop { tokio::select! { Some(action) = search_action_rx.recv() => { let size = terminal::size()?; - let (editor_pane, guide_pane, searcher_pane) = { + let (editor_pane, searcher_pane) = { let mut editor = shared_editor.write().await; let mut searcher = shared_searcher.write().await; match action { @@ -449,13 +417,23 @@ pub async fn run( searcher.start_search(&prefix).await; match head_item { Some(head) => { - editor.set_completion_found_guide( - load_progress.loaded_path_count, - load_progress.is_complete, - ); + let message = if load_progress.is_complete { + GuideMessage::LoadedAllSuggestions( + load_progress.loaded_path_count, + ) + } else { + GuideMessage::LoadedPartiallySuggestions( + load_progress.loaded_path_count, + ) + }; + guide_action_tx.send(GuideAction::Show(message)).await?; editor.replace_text(&head); } - None => editor.set_completion_empty_guide(&prefix), + None => { + guide_action_tx.send(GuideAction::Show( + GuideMessage::NoSuggestionFound(prefix), + )).await?; + } } } SearchAction::UserEvent(event) => { @@ -483,14 +461,12 @@ pub async fn run( } ( editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), searcher.create_pane(size.0, size.1), ) }; shared_renderer.update([ (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), (Index::Search, searcher_pane), ]).render().await?; } @@ -508,27 +484,20 @@ pub async fn run( let shared_searcher = shared_searcher.clone(); let shared_viewer_state = shared_viewer_state.clone(); let ctx = ctx.clone(); + let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { while let Some(area) = last_resize_rx.recv().await { let size = terminal::size()?; - let (editor_pane, guide_pane, searcher_pane) = { + let (editor_pane, searcher_pane) = { let editor = shared_editor.read().await; let searcher = shared_searcher.read().await; ( editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), searcher.create_pane(size.0, size.1), ) }; shared_renderer - .update([ - (Index::Editor, editor_pane), - ( - Index::Guide, - if !no_hint { guide_pane } else { empty_pane() }, - ), - (Index::Search, searcher_pane), - ]) + .update([(Index::Editor, editor_pane), (Index::Search, searcher_pane)]) .render() .await?; let text = { @@ -539,6 +508,7 @@ pub async fn run( shared_viewer_state.clone(), shared_renderer.clone(), ctx.clone(), + guide_action_tx.clone(), RenderTrigger::AreaResized { area, query: text }, ) .await; @@ -547,54 +517,13 @@ pub async fn run( }) }; - let processor_task: JoinHandle> = { - let shared_renderer = shared_renderer.clone(); - let shared_viewer_state = shared_viewer_state.clone(); - let ctx = ctx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - Some(action) = json_viewer_action_rx.recv() => { - match action { - JsonViewerAction::CopyResult => { - let runtime = shared_viewer_state.lock().await; - let guide = copy_to_clipboard(&runtime.formatted_content()); - if !no_hint { - let size = terminal::size()?; - let pane = guide.create_graphemes(size.0, size.1); - shared_renderer.update([ - (Index::Guide, pane), - ]).render().await?; - } - } - JsonViewerAction::UserEvent(event) => { - json_viewer::render( - shared_viewer_state.clone(), - shared_renderer.clone(), - ctx.clone(), - RenderTrigger::UserAction(event), - ) - .await; - } - JsonViewerAction::QueryChanged(query) => { - json_viewer::render( - shared_viewer_state.clone(), - shared_renderer.clone(), - ctx.clone(), - RenderTrigger::QueryChanged { query }, - ) - .await; - } - } - } - else => { - break - } - } - } - Ok(()) - }) - }; + let processor_task = json_viewer::start_viewer_task( + json_viewer_action_rx, + guide_action_tx.clone(), + shared_viewer_state.clone(), + shared_renderer.clone(), + ctx.clone(), + ); main_task.await??; @@ -610,6 +539,7 @@ pub async fn run( resize_debouncer.abort(); query_action_forwarder.abort(); resize_action_forwarder.abort(); + guide_task.abort(); editor_task.abort(); searcher_task.abort(); processor_task.abort(); From cb8ba052b49b24393ef536dd1454ad6281348dd4 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 21:31:36 +0900 Subject: [PATCH 25/74] chore: separate task definition for other components --- src/{search.rs => completion.rs} | 114 +++++++++++++- src/main.rs | 16 +- src/prompt.rs | 231 +++++++---------------------- src/{editor.rs => query_editor.rs} | 102 +++++++++++-- 4 files changed, 253 insertions(+), 210 deletions(-) rename src/{search.rs => completion.rs} (52%) rename src/{editor.rs => query_editor.rs} (52%) diff --git a/src/search.rs b/src/completion.rs similarity index 52% rename from src/search.rs rename to src/completion.rs index 3c733d1..be36590 100644 --- a/src/search.rs +++ b/src/completion.rs @@ -1,12 +1,25 @@ use std::{collections::BTreeSet, sync::Arc}; use promkit_widgets::{ - core::{grapheme::StyledGraphemes, Widget}, + core::{ + crossterm::{event::Event, terminal}, + grapheme::StyledGraphemes, + Widget, + }, listbox::{self, Listbox}, }; -use tokio::{sync::Mutex, task}; +use tokio::{ + sync::{mpsc, Mutex, RwLock}, + task::{self, JoinHandle}, +}; -use crate::json; +use crate::{ + config::EditorKeybinds, + guide::{GuideAction, GuideMessage}, + json, + prompt::Index, + query_editor::QueryEditor, +}; /// Progress information for loading suggestions #[derive(Clone, Default)] @@ -92,14 +105,14 @@ pub async fn initialize( Ok(shared) } -pub struct IncrementalSearcher { +pub struct CompletionNavigator { shared_suggestions: SharedSuggestionStore, state: listbox::State, search_result_chunk_size: usize, search_chunk_remaining: Vec, } -impl IncrementalSearcher { +impl CompletionNavigator { pub fn new( shared_suggestions: SharedSuggestionStore, state: listbox::State, @@ -138,7 +151,7 @@ impl IncrementalSearcher { self.state.create_graphemes(width, height) } - pub fn leave_search(&mut self) { + pub fn leave(&mut self) { self.state.listbox = Listbox::from(Vec::::new()); self.search_chunk_remaining = Vec::::new(); } @@ -155,7 +168,7 @@ impl IncrementalSearcher { Some(self.state.listbox.get().to_string()) } - pub async fn start_search(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { + pub async fn start(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; let head_item = self.apply_search_items(items); (head_item, progress) @@ -175,3 +188,90 @@ impl IncrementalSearcher { } } } + +pub enum CompletionAction { + Start, + UserEvent(Event), + Leave, +} + +pub fn start_completion_task( + mut action_rx: mpsc::Receiver, + shared_renderer: promkit_widgets::core::render::SharedRenderer, + shared_editor: Arc>, + shared_completion: Arc>, + text_diff: Arc>, + debounce_query_tx: mpsc::Sender, + guide_action_tx: mpsc::Sender, + editor_keybinds: EditorKeybinds, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + let size = terminal::size()?; + let (editor_pane, completion_pane) = { + let mut editor = shared_editor.write().await; + let mut completion = shared_completion.write().await; + match action { + CompletionAction::Start => { + let prefix = editor.text(); + let (head_item, load_progress) = completion.start(&prefix).await; + match head_item { + Some(head) => { + let message = if load_progress.is_complete { + GuideMessage::LoadedAllSuggestions(load_progress.loaded_path_count) + } else { + GuideMessage::LoadedPartiallySuggestions(load_progress.loaded_path_count) + }; + guide_action_tx.send(GuideAction::Show(message)).await?; + editor.replace_text(&head); + } + None => { + guide_action_tx + .send(GuideAction::Show(GuideMessage::NoSuggestionFound(prefix))) + .await?; + } + } + } + CompletionAction::UserEvent(event) => { + if editor_keybinds.on_completion.down.contains(&event) + || editor_keybinds.completion.contains(&event) + { + completion.down_with_load(); + editor.replace_text(&completion.get_current_item()); + } else if editor_keybinds.on_completion.up.contains(&event) { + completion.up(); + editor.replace_text(&completion.get_current_item()); + } + } + CompletionAction::Leave => { + completion.leave(); + } + } + + let current_text = editor.text(); + let mut diff = text_diff.write().await; + if current_text != diff[1] { + debounce_query_tx.send(current_text.clone()).await?; + diff[0] = diff[1].clone(); + diff[1] = current_text; + } + + ( + editor.create_pane(size.0, size.1), + completion.create_pane(size.0, size.1), + ) + }; + + shared_renderer + .update([(Index::Editor, editor_pane), (Index::Search, completion_pane)]) + .render() + .await?; + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/main.rs b/src/main.rs index e7f9625..bc481f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,16 +12,16 @@ use promkit_widgets::{ text_editor::{self, TextEditor}, }; -mod editor; -use editor::Editor; +mod query_editor; +use query_editor::QueryEditor; mod config; mod guide; mod json_viewer; mod stdout_redirect; use stdout_redirect::StdoutRedirect; +mod completion; mod prompt; -mod search; -use search::IncrementalSearcher; +use completion::CompletionNavigator; mod json; use crate::config::DEFAULT_CONFIG; @@ -179,13 +179,13 @@ async fn main() -> anyhow::Result<()> { config: config.editor.on_focus.clone(), }; - let shared_suggestions = search::initialize( + let shared_suggestions = completion::initialize( &input, config.json.max_streams, config.completion.search_load_chunk_size, ) .await?; - let searcher = IncrementalSearcher::new( + let completion = CompletionNavigator::new( shared_suggestions.clone(), listbox_state, config.completion.search_result_chunk_size, @@ -194,7 +194,7 @@ async fn main() -> anyhow::Result<()> { // TODO: re-consider put editor_task of prompt::run into Editor construction time. // Overall, there are several cases where it would be sufficient to // launch a background thread during construction. - let editor = Editor::new( + let editor = QueryEditor::new( text_editor_state, config.editor.on_focus, config.editor.on_defocus, @@ -210,7 +210,7 @@ async fn main() -> anyhow::Result<()> { config.json, config.reactivity_control, editor, - searcher, + completion, config.no_hint, config.keybinds, args.write_to_stdout, diff --git a/src/prompt.rs b/src/prompt.rs index 4894d63..01646d7 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -23,11 +23,11 @@ use tokio::{ }; use crate::{ + completion::{self, CompletionAction, CompletionNavigator}, config::{JsonConfig, Keybinds, ReactivityControl}, guide::{self, GuideAction, GuideMessage}, json_viewer::{self, RenderTrigger, SharedContext}, - search::IncrementalSearcher, - Editor, + query_editor::{self, QueryEditor, QueryEditorAction}, }; fn spawn_debouncer( @@ -76,18 +76,6 @@ enum GlobalAction { SwitchMode, } -enum EditorAction { - Focus(bool), - CopyQuery, - UserEvent(Event), -} - -enum SearchAction { - Start, - UserEvent(Event), - Leave, -} - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { Editor = 0, @@ -101,8 +89,8 @@ pub async fn run( item: &'static str, json_config: JsonConfig, reactivity_control: ReactivityControl, - editor: Editor, - searcher: IncrementalSearcher, + editor: QueryEditor, + completion: CompletionNavigator, no_hint: bool, keybinds: Keybinds, write_to_stdout: bool, @@ -115,7 +103,7 @@ pub async fn run( let shared_renderer = SharedRenderer::new( Renderer::try_new_with_graphemes( [ - (Index::Editor, editor.create_editor_pane(size.0, size.1)), + (Index::Editor, editor.create_pane(size.0, size.1)), (Index::Guide, empty_pane()), (Index::Search, empty_pane()), (Index::Processor, empty_pane()), @@ -158,15 +146,15 @@ pub async fn run( }); let mut focus = Focus::Editor; - let (editor_action_tx, mut editor_action_rx) = mpsc::channel::(1); - let (search_action_tx, mut search_action_rx) = mpsc::channel::(1); + let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); + let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); let (json_viewer_action_tx, json_viewer_action_rx) = mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); let text_diff = Arc::new(RwLock::new([editor.text(), editor.text()])); let shared_editor = Arc::new(RwLock::new(editor)); - let shared_searcher = Arc::new(RwLock::new(searcher)); + let shared_completion = Arc::new(RwLock::new(completion)); let editor_keybinds = keybinds.on_editor.clone(); let initializing = json_viewer::initialize( item, @@ -227,7 +215,7 @@ pub async fn run( } GlobalAction::Exit => break 'main, GlobalAction::CopyQuery => { - editor_action_tx.send(EditorAction::CopyQuery).await?; + editor_action_tx.send(QueryEditorAction::CopyQuery).await?; } GlobalAction::CopyResult => { if ctx.is_idle().await { @@ -246,8 +234,8 @@ pub async fn run( Focus::Editor | Focus::Searcher => { if ctx.is_idle().await { focus = Focus::Processor; - search_action_tx.send(SearchAction::Leave).await?; - editor_action_tx.send(EditorAction::Focus(false)).await?; + completion_action_tx.send(CompletionAction::Leave).await?; + editor_action_tx.send(QueryEditorAction::Focus(false)).await?; execute!( io::stdout(), terminal::EnterAlternateScreen, @@ -263,7 +251,7 @@ pub async fn run( } Focus::Processor => { focus = Focus::Editor; - editor_action_tx.send(EditorAction::Focus(true)).await?; + editor_action_tx.send(QueryEditorAction::Focus(true)).await?; execute!( io::stdout(), terminal::LeaveAlternateScreen, @@ -279,28 +267,30 @@ pub async fn run( Focus::Editor => { if editor_keybinds.completion.contains(&event) { focus = Focus::Searcher; - search_action_tx.send(SearchAction::Start).await?; + completion_action_tx.send(CompletionAction::Start).await?; } else { - editor_action_tx.send(EditorAction::UserEvent(event)).await?; + editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; } } Focus::Searcher => { if editor_keybinds.on_completion.down.contains(&event) || editor_keybinds.completion.contains(&event) { - search_action_tx - .send(SearchAction::UserEvent(event)) + completion_action_tx + .send(CompletionAction::UserEvent(event)) .await?; } else if editor_keybinds.on_completion.up.contains(&event) { - search_action_tx - .send(SearchAction::UserEvent(event)) + completion_action_tx + .send(CompletionAction::UserEvent(event)) .await?; } else { focus = Focus::Editor; - search_action_tx.send(SearchAction::Leave).await?; + completion_action_tx.send(CompletionAction::Leave).await?; if !editor_keybinds.completion.contains(&event) { editor_action_tx - .send(EditorAction::UserEvent(event)) + .send(QueryEditorAction::UserEvent(event)) .await?; } } @@ -334,170 +324,51 @@ pub async fn run( let guide_task = guide::start_guide_task(guide_action_rx, shared_renderer.clone(), no_hint); - let editor_task: JoinHandle> = { - let shared_renderer = shared_renderer.clone(); - let shared_editor = shared_editor.clone(); - let shared_searcher = shared_searcher.clone(); - let text_diff = text_diff.clone(); - let debounce_query_tx = debounce_query_tx.clone(); - let guide_action_tx = guide_action_tx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - Some(action) = editor_action_rx.recv() => { - let size = terminal::size()?; - let (editor_pane, searcher_pane, maybe_text_for_debounce) = { - let mut editor = shared_editor.write().await; - match action { - EditorAction::Focus(focus) => { - let mut searcher = shared_searcher.write().await; - if focus { - editor.focus(); - } else { - editor.defocus(); - searcher.leave_search(); - } - } - EditorAction::CopyQuery => { - let message = guide::copy_to_clipboard_message(&editor.text()); - guide_action_tx.send(GuideAction::Show(message)).await?; - } - EditorAction::UserEvent(event) => { - editor.operate(&event).await?; - } - } - let searcher = shared_searcher.read().await; - let current_text = editor.text(); - ( - editor.create_editor_pane(size.0, size.1), - searcher.create_pane(size.0, size.1), - current_text, - ) - }; - let mut diff = text_diff.write().await; - if maybe_text_for_debounce != diff[1] { - debounce_query_tx.send(maybe_text_for_debounce.clone()).await?; - diff[0] = diff[1].clone(); - diff[1] = maybe_text_for_debounce; - } - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Search, searcher_pane), - ]).render().await?; - } - else => { - break - } - } - } - Ok(()) - }) - }; - - let searcher_task: JoinHandle> = { - let shared_renderer = shared_renderer.clone(); - let shared_editor = shared_editor.clone(); - let shared_searcher = shared_searcher.clone(); - let text_diff = text_diff.clone(); - let debounce_query_tx = debounce_query_tx.clone(); - let editor_keybinds = editor_keybinds.clone(); - let guide_action_tx = guide_action_tx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - Some(action) = search_action_rx.recv() => { - let size = terminal::size()?; - let (editor_pane, searcher_pane) = { - let mut editor = shared_editor.write().await; - let mut searcher = shared_searcher.write().await; - match action { - SearchAction::Start => { - let prefix = editor.text(); - let (head_item, load_progress) = - searcher.start_search(&prefix).await; - match head_item { - Some(head) => { - let message = if load_progress.is_complete { - GuideMessage::LoadedAllSuggestions( - load_progress.loaded_path_count, - ) - } else { - GuideMessage::LoadedPartiallySuggestions( - load_progress.loaded_path_count, - ) - }; - guide_action_tx.send(GuideAction::Show(message)).await?; - editor.replace_text(&head); - } - None => { - guide_action_tx.send(GuideAction::Show( - GuideMessage::NoSuggestionFound(prefix), - )).await?; - } - } - } - SearchAction::UserEvent(event) => { - if editor_keybinds.on_completion.down.contains(&event) - || editor_keybinds.completion.contains(&event) - { - searcher.down_with_load(); - editor.replace_text(&searcher.get_current_item()); - } else if editor_keybinds.on_completion.up.contains(&event) { - searcher.up(); - editor.replace_text(&searcher.get_current_item()); - } - } - SearchAction::Leave => { - searcher.leave_search(); - } - } - - let current_text = editor.text(); - let mut diff = text_diff.write().await; - if current_text != diff[1] { - debounce_query_tx.send(current_text.clone()).await?; - diff[0] = diff[1].clone(); - diff[1] = current_text; - } - ( - editor.create_editor_pane(size.0, size.1), - searcher.create_pane(size.0, size.1), - ) - }; + let editor_task = query_editor::start_query_editor_task( + editor_action_rx, + shared_renderer.clone(), + shared_editor.clone(), + shared_completion.clone(), + text_diff.clone(), + debounce_query_tx.clone(), + guide_action_tx.clone(), + ); - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Search, searcher_pane), - ]).render().await?; - } - else => break, - } - } - Ok(()) - }) - }; + let completion_task = completion::start_completion_task( + completion_action_rx, + shared_renderer.clone(), + shared_editor.clone(), + shared_completion.clone(), + text_diff.clone(), + debounce_query_tx.clone(), + guide_action_tx.clone(), + editor_keybinds.clone(), + ); let shared_viewer_state = initializing.await?; let resize_action_forwarder = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); - let shared_searcher = shared_searcher.clone(); + let shared_completion = shared_completion.clone(); let shared_viewer_state = shared_viewer_state.clone(); let ctx = ctx.clone(); let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { while let Some(area) = last_resize_rx.recv().await { let size = terminal::size()?; - let (editor_pane, searcher_pane) = { + let (editor_pane, completion_pane) = { let editor = shared_editor.read().await; - let searcher = shared_searcher.read().await; + let completion = shared_completion.read().await; ( - editor.create_editor_pane(size.0, size.1), - searcher.create_pane(size.0, size.1), + editor.create_pane(size.0, size.1), + completion.create_pane(size.0, size.1), ) }; shared_renderer - .update([(Index::Editor, editor_pane), (Index::Search, searcher_pane)]) + .update([ + (Index::Editor, editor_pane), + (Index::Search, completion_pane), + ]) .render() .await?; let text = { @@ -541,7 +412,7 @@ pub async fn run( resize_action_forwarder.abort(); guide_task.abort(); editor_task.abort(); - searcher_task.abort(); + completion_task.abort(); processor_task.abort(); execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; diff --git a/src/editor.rs b/src/query_editor.rs similarity index 52% rename from src/editor.rs rename to src/query_editor.rs index 84d6070..b94a662 100644 --- a/src/editor.rs +++ b/src/query_editor.rs @@ -1,22 +1,36 @@ +use std::sync::Arc; + use promkit_widgets::{ core::{ - crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + crossterm::{ + event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + terminal, + }, grapheme::StyledGraphemes, Widget, }, text_editor, }; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; -use crate::config::EditorKeybinds; +use crate::{ + completion::CompletionNavigator, + config::EditorKeybinds, + guide::{self, GuideAction}, + prompt::Index, +}; -pub struct Editor { +pub struct QueryEditor { state: text_editor::State, focus_config: text_editor::Config, defocus_config: text_editor::Config, editor_keybinds: EditorKeybinds, } -impl Editor { +impl QueryEditor { pub fn new( state: text_editor::State, focus_config: text_editor::Config, @@ -43,7 +57,7 @@ impl Editor { self.state.texteditor.text_without_cursor().to_string() } - pub fn create_editor_pane(&self, width: u16, height: u16) -> StyledGraphemes { + pub fn create_pane(&self, width: u16, height: u16) -> StyledGraphemes { self.state.create_graphemes(width, height) } @@ -53,7 +67,6 @@ impl Editor { pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { match event { - // Move cursor. key if self.editor_keybinds.backward.contains(key) => { self.state.texteditor.backward(); } @@ -66,8 +79,6 @@ impl Editor { key if self.editor_keybinds.move_to_tail.contains(key) => { self.state.texteditor.move_to_tail(); } - - // Move cursor to the nearest character. key if self.editor_keybinds.move_to_previous_nearest.contains(key) => { self.state .texteditor @@ -78,16 +89,12 @@ impl Editor { .texteditor .move_to_next_nearest(&self.state.config.word_break_chars); } - - // Erase char(s). key if self.editor_keybinds.erase.contains(key) => { self.state.texteditor.erase(); } key if self.editor_keybinds.erase_all.contains(key) => { self.state.texteditor.erase_all(); } - - // Erase to the nearest character. key if self.editor_keybinds.erase_to_previous_nearest.contains(key) => { self.state .texteditor @@ -98,8 +105,6 @@ impl Editor { .texteditor .erase_to_next_nearest(&self.state.config.word_break_chars); } - - // Input char. Event::Key(KeyEvent { code: KeyCode::Char(ch), modifiers: KeyModifiers::NONE, @@ -115,9 +120,76 @@ impl Editor { text_editor::Mode::Insert => self.state.texteditor.insert(*ch), text_editor::Mode::Overwrite => self.state.texteditor.overwrite(*ch), }, - _ => {} } Ok(()) } } + +pub enum QueryEditorAction { + Focus(bool), + CopyQuery, + UserEvent(Event), +} + +pub fn start_query_editor_task( + mut action_rx: mpsc::Receiver, + shared_renderer: promkit_widgets::core::render::SharedRenderer, + shared_editor: Arc>, + shared_completion: Arc>, + text_diff: Arc>, + debounce_query_tx: mpsc::Sender, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + let size = terminal::size()?; + let (editor_pane, completion_pane, maybe_text_for_debounce) = { + let mut editor = shared_editor.write().await; + match action { + QueryEditorAction::Focus(focus) => { + let mut completion = shared_completion.write().await; + if focus { + editor.focus(); + } else { + editor.defocus(); + completion.leave(); + } + } + QueryEditorAction::CopyQuery => { + let message = guide::copy_to_clipboard_message(&editor.text()); + guide_action_tx.send(GuideAction::Show(message)).await?; + } + QueryEditorAction::UserEvent(event) => { + editor.operate(&event).await?; + } + } + let completion = shared_completion.read().await; + let current_text = editor.text(); + ( + editor.create_pane(size.0, size.1), + completion.create_pane(size.0, size.1), + current_text, + ) + }; + + let mut diff = text_diff.write().await; + if maybe_text_for_debounce != diff[1] { + debounce_query_tx.send(maybe_text_for_debounce.clone()).await?; + diff[0] = diff[1].clone(); + diff[1] = maybe_text_for_debounce; + } + + shared_renderer + .update([(Index::Editor, editor_pane), (Index::Search, completion_pane)]) + .render() + .await?; + } + else => break, + } + } + Ok(()) + }) +} From bc4c7bc932787b402d0d34c9deeeae2ed7ef1776 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 22:38:31 +0900 Subject: [PATCH 26/74] chore: remove empty_pane --- src/prompt.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/prompt.rs b/src/prompt.rs index 01646d7..2379a2f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -57,10 +57,6 @@ fn spawn_debouncer( }) } -fn empty_pane() -> StyledGraphemes { - StyledGraphemes::default() -} - #[derive(Clone, Copy)] enum Focus { Editor, @@ -104,9 +100,9 @@ pub async fn run( Renderer::try_new_with_graphemes( [ (Index::Editor, editor.create_pane(size.0, size.1)), - (Index::Guide, empty_pane()), - (Index::Search, empty_pane()), - (Index::Processor, empty_pane()), + (Index::Guide, StyledGraphemes::default()), + (Index::Search, StyledGraphemes::default()), + (Index::Processor, StyledGraphemes::default()), ] .into_iter(), true, From a4a79826d0b78c4fbefa519c16d9d268f0a1e4a0 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 28 Mar 2026 23:09:31 +0900 Subject: [PATCH 27/74] docs: json_viewer --- src/guide.rs | 2 +- src/json_viewer.rs | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/guide.rs b/src/guide.rs index 3ab6351..38ee433 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -25,7 +25,7 @@ pub enum GuideAction { Show(GuideMessage), } -pub fn message_to_state(message: GuideMessage) -> status::State { +fn message_to_state(message: GuideMessage) -> status::State { match message { GuideMessage::CopiedToClipboard => { status::State::new("Copied to clipboard", Severity::Success) diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 338e6c1..c1e6587 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -94,12 +94,6 @@ pub struct JsonViewer { pub type SharedJsonViewer = Arc>; -pub enum ViewerAction { - CopyResult, - UserEvent(Event), - QueryChanged(String), -} - impl JsonViewer { /// Get the formatted content of current JSON stream. pub fn formatted_content(&self) -> String { @@ -401,6 +395,19 @@ fn spawn_query_update_task( }) } +/// Represent the actions that can be performed in JSON viewer, +/// including copying results to clipboard, handling user events, and processing query changes. +pub enum ViewerAction { + // Copy the current JSON stream results to clipboard. + CopyResult, + // Handle user events such as key presses for navigation and toggling. + UserEvent(Event), + // Handle changes in jq query input for dynamic filtering of JSON stream. + QueryChanged(String), +} + +/// Spawn a background task to handle viewer actions such as user events and query changes, +/// and update the viewer state and rendered view accordingly. pub fn start_viewer_task( mut action_rx: mpsc::Receiver, guide_action_tx: mpsc::Sender, From 16dc4e7250386fae9a05c04a08e62cb5754abcd6 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 00:15:26 +0900 Subject: [PATCH 28/74] chore: refactor query_editor and similar's --- src/completion.rs | 6 +++--- src/json_viewer.rs | 16 ++++++++-------- src/prompt.rs | 6 +++--- src/query_editor.rs | 26 ++++++++++++++++++++------ 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index be36590..8feaae5 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -147,7 +147,7 @@ impl CompletionNavigator { self.state.listbox.get().to_string() } - pub fn create_pane(&self, width: u16, height: u16) -> StyledGraphemes { + pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { self.state.create_graphemes(width, height) } @@ -259,8 +259,8 @@ pub fn start_completion_task( } ( - editor.create_pane(size.0, size.1), - completion.create_pane(size.0, size.1), + editor.create_graphemes(size.0, size.1), + completion.create_graphemes(size.0, size.1), ) }; diff --git a/src/json_viewer.rs b/src/json_viewer.rs index c1e6587..c909b8d 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -100,8 +100,8 @@ impl JsonViewer { self.state.config.format_raw_json(self.state.stream.rows()) } - /// Handle user actions and update the viewer state accordingly. - fn apply_user_action(&mut self, event: &Event) { + /// Handle user event and update the viewer state accordingly. + fn handle_user_event(&mut self, event: &Event) { match event { // Move up. event if self.keybinds.up.contains(event) => { @@ -229,7 +229,7 @@ pub async fn render( ) { match trigger { RenderTrigger::UserAction(event) => { - handle_user_action(shared_viewer_state, shared_renderer, shared_ctx, event).await; + handle_user_event(shared_viewer_state, shared_renderer, shared_ctx, event).await; } RenderTrigger::QueryChanged { query } => { handle_query_changed( @@ -255,7 +255,7 @@ pub async fn render( } } -async fn handle_user_action( +async fn handle_user_event( shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, shared_ctx: SharedContext, @@ -268,7 +268,7 @@ async fn handle_user_action( let graphemes = { let mut viewer = shared_viewer_state.lock().await; - viewer.apply_user_action(&event); + viewer.handle_user_event(&event); viewer.state.create_graphemes(area.0, area.1) }; @@ -398,11 +398,11 @@ fn spawn_query_update_task( /// Represent the actions that can be performed in JSON viewer, /// including copying results to clipboard, handling user events, and processing query changes. pub enum ViewerAction { - // Copy the current JSON stream results to clipboard. + /// Copy the current JSON stream results to clipboard. CopyResult, - // Handle user events such as key presses for navigation and toggling. + /// Handle user events such as key presses for navigation and toggling. UserEvent(Event), - // Handle changes in jq query input for dynamic filtering of JSON stream. + /// Handle changes in jq query input for dynamic filtering of JSON stream. QueryChanged(String), } diff --git a/src/prompt.rs b/src/prompt.rs index 2379a2f..4cec39d 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -99,7 +99,7 @@ pub async fn run( let shared_renderer = SharedRenderer::new( Renderer::try_new_with_graphemes( [ - (Index::Editor, editor.create_pane(size.0, size.1)), + (Index::Editor, editor.create_graphemes(size.0, size.1)), (Index::Guide, StyledGraphemes::default()), (Index::Search, StyledGraphemes::default()), (Index::Processor, StyledGraphemes::default()), @@ -356,8 +356,8 @@ pub async fn run( let editor = shared_editor.read().await; let completion = shared_completion.read().await; ( - editor.create_pane(size.0, size.1), - completion.create_pane(size.0, size.1), + editor.create_graphemes(size.0, size.1), + completion.create_graphemes(size.0, size.1), ) }; shared_renderer diff --git a/src/query_editor.rs b/src/query_editor.rs index b94a662..d3266a9 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -23,6 +23,8 @@ use crate::{ prompt::Index, }; +/// Editor for inputting jq query. It manages the state of the text editor +/// and handles user input events to update the query text accordingly. pub struct QueryEditor { state: text_editor::State, focus_config: text_editor::Config, @@ -45,27 +47,33 @@ impl QueryEditor { } } + /// Focus the query editor, applying the focus configuration. pub fn focus(&mut self) { self.state.config = self.focus_config.clone(); } + /// Defocus the query editor, applying the defocus configuration. pub fn defocus(&mut self) { self.state.config = self.defocus_config.clone(); } + /// Get the current text of the query editor without the cursor. pub fn text(&self) -> String { self.state.texteditor.text_without_cursor().to_string() } - pub fn create_pane(&self, width: u16, height: u16) -> StyledGraphemes { + /// Create graphemes for rendering the query editor. + pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { self.state.create_graphemes(width, height) } + /// Replace the current text of the query editor with the given text. pub fn replace_text(&mut self, text: &str) { self.state.texteditor.replace(text); } - pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { + /// Handle a user input event to update the query editor's state accordingly. + fn handle_user_event(&mut self, event: &Event) { match event { key if self.editor_keybinds.backward.contains(key) => { self.state.texteditor.backward(); @@ -122,16 +130,22 @@ impl QueryEditor { }, _ => {} } - Ok(()) } } +/// Represent the actions that can be performed on the query editor, +/// such as focusing, copying the query, or handling user events. pub enum QueryEditorAction { + /// Focus or defocus the query editor based on the boolean value. Focus(bool), + /// Copy the current query text to clipboard. CopyQuery, + /// Handle user input events to update the query editor's state. UserEvent(Event), } +/// Spawn a background task to manage the query editor's state and interactions. +/// It listens for actions such as focusing, copying the query, or handling user events. pub fn start_query_editor_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, @@ -163,14 +177,14 @@ pub fn start_query_editor_task( guide_action_tx.send(GuideAction::Show(message)).await?; } QueryEditorAction::UserEvent(event) => { - editor.operate(&event).await?; + editor.handle_user_event(&event); } } let completion = shared_completion.read().await; let current_text = editor.text(); ( - editor.create_pane(size.0, size.1), - completion.create_pane(size.0, size.1), + editor.create_graphemes(size.0, size.1), + completion.create_graphemes(size.0, size.1), current_text, ) }; From c4711c46c413267de18f8a41b33e6ff55f678378 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 01:30:23 +0900 Subject: [PATCH 29/74] chore: isolate queryeditor and completion --- src/completion.rs | 42 ++++++++++++++++-------------------------- src/prompt.rs | 13 ++++++++----- src/query_editor.rs | 22 ++++++++++------------ 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 8feaae5..40c030f 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -18,7 +18,7 @@ use crate::{ guide::{GuideAction, GuideMessage}, json, prompt::Index, - query_editor::QueryEditor, + query_editor::QueryEditorAction, }; /// Progress information for loading suggestions @@ -190,7 +190,7 @@ impl CompletionNavigator { } pub enum CompletionAction { - Start, + Start(String), UserEvent(Event), Leave, } @@ -198,10 +198,8 @@ pub enum CompletionAction { pub fn start_completion_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, - shared_editor: Arc>, shared_completion: Arc>, - text_diff: Arc>, - debounce_query_tx: mpsc::Sender, + query_editor_action_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, editor_keybinds: EditorKeybinds, ) -> JoinHandle> { @@ -210,12 +208,10 @@ pub fn start_completion_task( tokio::select! { Some(action) = action_rx.recv() => { let size = terminal::size()?; - let (editor_pane, completion_pane) = { - let mut editor = shared_editor.write().await; + let completion_pane = { let mut completion = shared_completion.write().await; match action { - CompletionAction::Start => { - let prefix = editor.text(); + CompletionAction::Start(prefix) => { let (head_item, load_progress) = completion.start(&prefix).await; match head_item { Some(head) => { @@ -225,7 +221,9 @@ pub fn start_completion_task( GuideMessage::LoadedPartiallySuggestions(load_progress.loaded_path_count) }; guide_action_tx.send(GuideAction::Show(message)).await?; - editor.replace_text(&head); + query_editor_action_tx + .send(QueryEditorAction::ReplaceText(head)) + .await?; } None => { guide_action_tx @@ -239,33 +237,25 @@ pub fn start_completion_task( || editor_keybinds.completion.contains(&event) { completion.down_with_load(); - editor.replace_text(&completion.get_current_item()); + query_editor_action_tx + .send(QueryEditorAction::ReplaceText(completion.get_current_item())) + .await?; } else if editor_keybinds.on_completion.up.contains(&event) { completion.up(); - editor.replace_text(&completion.get_current_item()); + query_editor_action_tx + .send(QueryEditorAction::ReplaceText(completion.get_current_item())) + .await?; } } CompletionAction::Leave => { completion.leave(); } } - - let current_text = editor.text(); - let mut diff = text_diff.write().await; - if current_text != diff[1] { - debounce_query_tx.send(current_text.clone()).await?; - diff[0] = diff[1].clone(); - diff[1] = current_text; - } - - ( - editor.create_graphemes(size.0, size.1), - completion.create_graphemes(size.0, size.1), - ) + completion.create_graphemes(size.0, size.1) }; shared_renderer - .update([(Index::Editor, editor_pane), (Index::Search, completion_pane)]) + .update([(Index::Search, completion_pane)]) .render() .await?; } diff --git a/src/prompt.rs b/src/prompt.rs index 4cec39d..f5b70af 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -163,6 +163,8 @@ pub async fn run( let main_task: JoinHandle> = { let mut stream = EventStream::new(); let ctx = ctx.clone(); + let shared_editor = shared_editor.clone(); + let editor_action_tx = editor_action_tx.clone(); let editor_keybinds = editor_keybinds.clone(); let json_viewer_action_tx = json_viewer_action_tx.clone(); let guide_action_tx = guide_action_tx.clone(); @@ -263,7 +265,11 @@ pub async fn run( Focus::Editor => { if editor_keybinds.completion.contains(&event) { focus = Focus::Searcher; - completion_action_tx.send(CompletionAction::Start).await?; + let prefix = { + let editor = shared_editor.read().await; + editor.text() + }; + completion_action_tx.send(CompletionAction::Start(prefix)).await?; } else { editor_action_tx .send(QueryEditorAction::UserEvent(event)) @@ -324,7 +330,6 @@ pub async fn run( editor_action_rx, shared_renderer.clone(), shared_editor.clone(), - shared_completion.clone(), text_diff.clone(), debounce_query_tx.clone(), guide_action_tx.clone(), @@ -333,10 +338,8 @@ pub async fn run( let completion_task = completion::start_completion_task( completion_action_rx, shared_renderer.clone(), - shared_editor.clone(), shared_completion.clone(), - text_diff.clone(), - debounce_query_tx.clone(), + editor_action_tx.clone(), guide_action_tx.clone(), editor_keybinds.clone(), ); diff --git a/src/query_editor.rs b/src/query_editor.rs index d3266a9..d820920 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -17,7 +17,6 @@ use tokio::{ }; use crate::{ - completion::CompletionNavigator, config::EditorKeybinds, guide::{self, GuideAction}, prompt::Index, @@ -140,6 +139,8 @@ pub enum QueryEditorAction { Focus(bool), /// Copy the current query text to clipboard. CopyQuery, + /// Replace the current query text. + ReplaceText(String), /// Handle user input events to update the query editor's state. UserEvent(Event), } @@ -150,7 +151,6 @@ pub fn start_query_editor_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, shared_editor: Arc>, - shared_completion: Arc>, text_diff: Arc>, debounce_query_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, @@ -160,35 +160,32 @@ pub fn start_query_editor_task( tokio::select! { Some(action) = action_rx.recv() => { let size = terminal::size()?; - let (editor_pane, completion_pane, maybe_text_for_debounce) = { + let (editor_pane, maybe_text_for_debounce) = { let mut editor = shared_editor.write().await; match action { QueryEditorAction::Focus(focus) => { - let mut completion = shared_completion.write().await; if focus { editor.focus(); } else { editor.defocus(); - completion.leave(); } } QueryEditorAction::CopyQuery => { let message = guide::copy_to_clipboard_message(&editor.text()); guide_action_tx.send(GuideAction::Show(message)).await?; } + QueryEditorAction::ReplaceText(text) => { + editor.replace_text(&text); + } QueryEditorAction::UserEvent(event) => { editor.handle_user_event(&event); } } - let completion = shared_completion.read().await; let current_text = editor.text(); - ( - editor.create_graphemes(size.0, size.1), - completion.create_graphemes(size.0, size.1), - current_text, - ) + (editor.create_graphemes(size.0, size.1), current_text) }; + // If the text has changed, send it to the debounce channel for processing. let mut diff = text_diff.write().await; if maybe_text_for_debounce != diff[1] { debounce_query_tx.send(maybe_text_for_debounce.clone()).await?; @@ -196,8 +193,9 @@ pub fn start_query_editor_task( diff[1] = maybe_text_for_debounce; } + // Update the renderer with the new editor pane and render it. shared_renderer - .update([(Index::Editor, editor_pane), (Index::Search, completion_pane)]) + .update([(Index::Editor, editor_pane)]) .render() .await?; } From b7181e6f400ba29bd8733eeb265db61933cac0f6 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 01:53:28 +0900 Subject: [PATCH 30/74] chore: remove text_diff from prompt.rs --- src/prompt.rs | 2 -- src/query_editor.rs | 24 +++++++++++------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/prompt.rs b/src/prompt.rs index f5b70af..b580b42 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -148,7 +148,6 @@ pub async fn run( mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - let text_diff = Arc::new(RwLock::new([editor.text(), editor.text()])); let shared_editor = Arc::new(RwLock::new(editor)); let shared_completion = Arc::new(RwLock::new(completion)); let editor_keybinds = keybinds.on_editor.clone(); @@ -330,7 +329,6 @@ pub async fn run( editor_action_rx, shared_renderer.clone(), shared_editor.clone(), - text_diff.clone(), debounce_query_tx.clone(), guide_action_tx.clone(), ); diff --git a/src/query_editor.rs b/src/query_editor.rs index d820920..aba6cc1 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -2,12 +2,9 @@ use std::sync::Arc; use promkit_widgets::{ core::{ - crossterm::{ - event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - terminal, - }, - grapheme::StyledGraphemes, - Widget, + Widget, crossterm::{ + cursor, event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, terminal + }, grapheme::StyledGraphemes }, text_editor, }; @@ -151,16 +148,19 @@ pub fn start_query_editor_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, shared_editor: Arc>, - text_diff: Arc>, debounce_query_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, ) -> JoinHandle> { tokio::spawn(async move { + let mut last_text = { + let editor = shared_editor.read().await; + editor.text() + }; loop { tokio::select! { Some(action) = action_rx.recv() => { let size = terminal::size()?; - let (editor_pane, maybe_text_for_debounce) = { + let (editor_pane, current_text) = { let mut editor = shared_editor.write().await; match action { QueryEditorAction::Focus(focus) => { @@ -186,11 +186,9 @@ pub fn start_query_editor_task( }; // If the text has changed, send it to the debounce channel for processing. - let mut diff = text_diff.write().await; - if maybe_text_for_debounce != diff[1] { - debounce_query_tx.send(maybe_text_for_debounce.clone()).await?; - diff[0] = diff[1].clone(); - diff[1] = maybe_text_for_debounce; + if current_text != last_text { + debounce_query_tx.send(current_text.clone()).await?; + last_text = current_text; } // Update the renderer with the new editor pane and render it. From ab2b7f720d79195715ff2c2da48869e172259f73 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 14:54:06 +0900 Subject: [PATCH 31/74] chore: use enter/leave for complete and editor --- src/completion.rs | 6 +++--- src/prompt.rs | 8 +++++--- src/query_editor.rs | 24 ++++++++++++------------ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 40c030f..8b3b972 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -190,9 +190,9 @@ impl CompletionNavigator { } pub enum CompletionAction { - Start(String), - UserEvent(Event), + Enter { prefix: String }, Leave, + UserEvent(Event), } pub fn start_completion_task( @@ -211,7 +211,7 @@ pub fn start_completion_task( let completion_pane = { let mut completion = shared_completion.write().await; match action { - CompletionAction::Start(prefix) => { + CompletionAction::Enter { prefix } => { let (head_item, load_progress) = completion.start(&prefix).await; match head_item { Some(head) => { diff --git a/src/prompt.rs b/src/prompt.rs index b580b42..2fdef1a 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -232,7 +232,7 @@ pub async fn run( if ctx.is_idle().await { focus = Focus::Processor; completion_action_tx.send(CompletionAction::Leave).await?; - editor_action_tx.send(QueryEditorAction::Focus(false)).await?; + editor_action_tx.send(QueryEditorAction::Leave).await?; execute!( io::stdout(), terminal::EnterAlternateScreen, @@ -248,7 +248,7 @@ pub async fn run( } Focus::Processor => { focus = Focus::Editor; - editor_action_tx.send(QueryEditorAction::Focus(true)).await?; + editor_action_tx.send(QueryEditorAction::Enter).await?; execute!( io::stdout(), terminal::LeaveAlternateScreen, @@ -268,7 +268,9 @@ pub async fn run( let editor = shared_editor.read().await; editor.text() }; - completion_action_tx.send(CompletionAction::Start(prefix)).await?; + completion_action_tx + .send(CompletionAction::Enter { prefix }) + .await?; } else { editor_action_tx .send(QueryEditorAction::UserEvent(event)) diff --git a/src/query_editor.rs b/src/query_editor.rs index aba6cc1..2a4de56 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -2,9 +2,12 @@ use std::sync::Arc; use promkit_widgets::{ core::{ - Widget, crossterm::{ - cursor, event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, terminal - }, grapheme::StyledGraphemes + crossterm::{ + event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + terminal, + }, + grapheme::StyledGraphemes, + Widget, }, text_editor, }; @@ -132,8 +135,10 @@ impl QueryEditor { /// Represent the actions that can be performed on the query editor, /// such as focusing, copying the query, or handling user events. pub enum QueryEditorAction { - /// Focus or defocus the query editor based on the boolean value. - Focus(bool), + /// Focus the query editor. + Enter, + /// Defocus the query editor. + Leave, /// Copy the current query text to clipboard. CopyQuery, /// Replace the current query text. @@ -163,13 +168,8 @@ pub fn start_query_editor_task( let (editor_pane, current_text) = { let mut editor = shared_editor.write().await; match action { - QueryEditorAction::Focus(focus) => { - if focus { - editor.focus(); - } else { - editor.defocus(); - } - } + QueryEditorAction::Enter => editor.focus(), + QueryEditorAction::Leave => editor.defocus(), QueryEditorAction::CopyQuery => { let message = guide::copy_to_clipboard_message(&editor.text()); guide_action_tx.send(GuideAction::Show(message)).await?; From 540ea4fc8261be13238f863172d7349a350df25d Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 18:03:43 +0900 Subject: [PATCH 32/74] chore: remove unnecessary clone --- src/completion.rs | 66 ++++++++++++++++++++++++++++------------------- src/prompt.rs | 3 +-- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 8b3b972..46bd521 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -14,7 +14,7 @@ use tokio::{ }; use crate::{ - config::EditorKeybinds, + config::CompletionKeybinds, guide::{GuideAction, GuideMessage}, json, prompt::Index, @@ -105,9 +105,13 @@ pub async fn initialize( Ok(shared) } +/// Navigator for managing the state of suggestions +/// and interactions in the completion pane. pub struct CompletionNavigator { shared_suggestions: SharedSuggestionStore, state: listbox::State, + /// Number of suggestions to load in each chunk + /// when the user scrolls near the end of the list. search_result_chunk_size: usize, search_chunk_remaining: Vec, } @@ -143,6 +147,20 @@ impl CompletionNavigator { } } + fn load_more(&mut self) { + if self.search_chunk_remaining.is_empty() { + return; + } + let items = self.search_chunk_remaining.drain( + ..self + .search_result_chunk_size + .min(self.search_chunk_remaining.len()), + ); + for item in items { + self.state.listbox.push_string(item); + } + } + pub fn get_current_item(&self) -> String { self.state.listbox.get().to_string() } @@ -156,6 +174,24 @@ impl CompletionNavigator { self.search_chunk_remaining = Vec::::new(); } + fn handle_user_event( + &mut self, + event: &Event, + completion_keybinds: &CompletionKeybinds, + ) -> Option { + if completion_keybinds.down.contains(event) { + self.down_with_load(); + return Some(self.get_current_item()); + } + + if completion_keybinds.up.contains(event) { + self.up(); + return Some(self.get_current_item()); + } + + None + } + fn apply_search_items(&mut self, mut items: Vec) -> Option { if items.is_empty() { return None; @@ -173,20 +209,6 @@ impl CompletionNavigator { let head_item = self.apply_search_items(items); (head_item, progress) } - - fn load_more(&mut self) { - if self.search_chunk_remaining.is_empty() { - return; - } - let items = self.search_chunk_remaining.drain( - ..self - .search_result_chunk_size - .min(self.search_chunk_remaining.len()), - ); - for item in items { - self.state.listbox.push_string(item); - } - } } pub enum CompletionAction { @@ -201,7 +223,7 @@ pub fn start_completion_task( shared_completion: Arc>, query_editor_action_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, - editor_keybinds: EditorKeybinds, + completion_keybinds: CompletionKeybinds, ) -> JoinHandle> { tokio::spawn(async move { loop { @@ -233,17 +255,9 @@ pub fn start_completion_task( } } CompletionAction::UserEvent(event) => { - if editor_keybinds.on_completion.down.contains(&event) - || editor_keybinds.completion.contains(&event) - { - completion.down_with_load(); - query_editor_action_tx - .send(QueryEditorAction::ReplaceText(completion.get_current_item())) - .await?; - } else if editor_keybinds.on_completion.up.contains(&event) { - completion.up(); + if let Some(text) = completion.handle_user_event(&event, &completion_keybinds) { query_editor_action_tx - .send(QueryEditorAction::ReplaceText(completion.get_current_item())) + .send(QueryEditorAction::ReplaceText(text)) .await?; } } diff --git a/src/prompt.rs b/src/prompt.rs index 2fdef1a..a848ff9 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -279,7 +279,6 @@ pub async fn run( } Focus::Searcher => { if editor_keybinds.on_completion.down.contains(&event) - || editor_keybinds.completion.contains(&event) { completion_action_tx .send(CompletionAction::UserEvent(event)) @@ -341,7 +340,7 @@ pub async fn run( shared_completion.clone(), editor_action_tx.clone(), guide_action_tx.clone(), - editor_keybinds.clone(), + editor_keybinds.on_completion, ); let shared_viewer_state = initializing.await?; From 75f3aec836640a809f8a8ecc3f88c595d80033ff Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 18:33:45 +0900 Subject: [PATCH 33/74] docs: complete --- src/completion.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 46bd521..e05f7bc 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -130,11 +130,17 @@ impl CompletionNavigator { } } - pub fn up(&mut self) { - self.state.listbox.backward(); + /// Get the currently selected item in listbox. + fn get_current_item(&self) -> String { + self.state.listbox.get().to_string() + } + + /// Create graphemes for rendering the completion navigator. + pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { + self.state.create_graphemes(width, height) } - pub fn down_with_load(&mut self) { + fn down_with_load(&mut self) { self.state.listbox.forward(); if self .state @@ -161,31 +167,26 @@ impl CompletionNavigator { } } - pub fn get_current_item(&self) -> String { - self.state.listbox.get().to_string() - } - - pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { - self.state.create_graphemes(width, height) - } - pub fn leave(&mut self) { self.state.listbox = Listbox::from(Vec::::new()); self.search_chunk_remaining = Vec::::new(); } + /// Handle a user input event to update the completion navigator's state accordingly. fn handle_user_event( &mut self, event: &Event, completion_keybinds: &CompletionKeybinds, ) -> Option { - if completion_keybinds.down.contains(event) { - self.down_with_load(); + // Move up. + if completion_keybinds.up.contains(event) { + self.state.listbox.backward(); return Some(self.get_current_item()); } - if completion_keybinds.up.contains(event) { - self.up(); + // Move down (and load more if near the end). + if completion_keybinds.down.contains(event) { + self.down_with_load(); return Some(self.get_current_item()); } From 5bb629d4a0b352903f878641f569022097f2049a Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 19:03:47 +0900 Subject: [PATCH 34/74] chore: refactor complete --- src/completion.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index e05f7bc..0f4ed22 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -140,8 +140,12 @@ impl CompletionNavigator { self.state.create_graphemes(width, height) } - fn down_with_load(&mut self) { + fn move_down(&mut self) { + // First, move the cursor down by one item. self.state.listbox.forward(); + + // Then, check if we need to load more items + // when the cursor is close to the end. if self .state .listbox @@ -149,11 +153,11 @@ impl CompletionNavigator { .saturating_sub(self.state.listbox.position()) < self.state.config.lines.unwrap_or(1) { - self.load_more(); + self.append_next_chunk_if_needed(); } } - fn load_more(&mut self) { + fn append_next_chunk_if_needed(&mut self) { if self.search_chunk_remaining.is_empty() { return; } @@ -167,11 +171,6 @@ impl CompletionNavigator { } } - pub fn leave(&mut self) { - self.state.listbox = Listbox::from(Vec::::new()); - self.search_chunk_remaining = Vec::::new(); - } - /// Handle a user input event to update the completion navigator's state accordingly. fn handle_user_event( &mut self, @@ -186,7 +185,7 @@ impl CompletionNavigator { // Move down (and load more if near the end). if completion_keybinds.down.contains(event) { - self.down_with_load(); + self.move_down(); return Some(self.get_current_item()); } @@ -205,11 +204,16 @@ impl CompletionNavigator { Some(self.state.listbox.get().to_string()) } - pub async fn start(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { + async fn enter(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; let head_item = self.apply_search_items(items); (head_item, progress) } + + fn reset_session(&mut self) { + self.state.listbox = Listbox::from(Vec::::new()); + self.search_chunk_remaining = Vec::::new(); + } } pub enum CompletionAction { @@ -235,7 +239,7 @@ pub fn start_completion_task( let mut completion = shared_completion.write().await; match action { CompletionAction::Enter { prefix } => { - let (head_item, load_progress) = completion.start(&prefix).await; + let (head_item, load_progress) = completion.enter(&prefix).await; match head_item { Some(head) => { let message = if load_progress.is_complete { @@ -263,7 +267,7 @@ pub fn start_completion_task( } } CompletionAction::Leave => { - completion.leave(); + completion.reset_session(); } } completion.create_graphemes(size.0, size.1) From 8e4ec31e96294a449cdbe9c99a0f1e5ffb03aa2c Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 19:18:46 +0900 Subject: [PATCH 35/74] chore: refactor complete --- src/completion.rs | 58 ++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 0f4ed22..7e49563 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -113,7 +113,8 @@ pub struct CompletionNavigator { /// Number of suggestions to load in each chunk /// when the user scrolls near the end of the list. search_result_chunk_size: usize, - search_chunk_remaining: Vec, + /// Buffered suggestions that are not yet visible in the listbox. + remaining_items: Vec, } impl CompletionNavigator { @@ -126,7 +127,7 @@ impl CompletionNavigator { shared_suggestions, state, search_result_chunk_size, - search_chunk_remaining: Default::default(), + remaining_items: Default::default(), } } @@ -140,31 +141,35 @@ impl CompletionNavigator { self.state.create_graphemes(width, height) } + /// Returns true when the cursor is close enough to the visible tail + /// and preloading the next chunk is beneficial. + fn is_near_visible_tail(&self) -> bool { + self.state + .listbox + .len() + .saturating_sub(self.state.listbox.position()) + < self.state.config.lines.unwrap_or(1) + } + fn move_down(&mut self) { // First, move the cursor down by one item. self.state.listbox.forward(); // Then, check if we need to load more items // when the cursor is close to the end. - if self - .state - .listbox - .len() - .saturating_sub(self.state.listbox.position()) - < self.state.config.lines.unwrap_or(1) - { + if self.is_near_visible_tail() { self.append_next_chunk_if_needed(); } } fn append_next_chunk_if_needed(&mut self) { - if self.search_chunk_remaining.is_empty() { + if self.remaining_items.is_empty() { return; } - let items = self.search_chunk_remaining.drain( + let items = self.remaining_items.drain( ..self .search_result_chunk_size - .min(self.search_chunk_remaining.len()), + .min(self.remaining_items.len()), ); for item in items { self.state.listbox.push_string(item); @@ -192,27 +197,34 @@ impl CompletionNavigator { None } - fn apply_search_items(&mut self, mut items: Vec) -> Option { + async fn enter(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { + let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; + let head_item = self.initialize_session_items(items); + (head_item, progress) + } + + /// Initialize a completion session with a new search result set. + /// This method always resets previous session state first. + fn initialize_session_items(&mut self, mut items: Vec) -> Option { + self.clear_session_state(); + if items.is_empty() { return None; } + let used = items .drain(..self.search_result_chunk_size.min(items.len())) .collect::>(); - self.search_chunk_remaining = items; + self.remaining_items = items; self.state.listbox = Listbox::from(used); Some(self.state.listbox.get().to_string()) } - async fn enter(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { - let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; - let head_item = self.apply_search_items(items); - (head_item, progress) - } - - fn reset_session(&mut self) { + /// Reset completion session state. + /// This clears both visible list items and buffered remaining items. + fn clear_session_state(&mut self) { self.state.listbox = Listbox::from(Vec::::new()); - self.search_chunk_remaining = Vec::::new(); + self.remaining_items.clear(); } } @@ -267,7 +279,7 @@ pub fn start_completion_task( } } CompletionAction::Leave => { - completion.reset_session(); + completion.clear_session_state(); } } completion.create_graphemes(size.0, size.1) From fe0b4f687a76919c4c2338579a480d1374fd5f0d Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 19:30:31 +0900 Subject: [PATCH 36/74] chore: TerminalCleanupGuard to run terminal-related operations on drop --- src/main.rs | 20 ++++++++++++++++++++ src/prompt.rs | 9 +-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index bc481f9..908f44a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use config::Config; use promkit_widgets::{ listbox::{self, Listbox}, text_editor::{self, TextEditor}, + core::crossterm, }; mod query_editor; @@ -145,6 +146,20 @@ fn determine_config_file(config_path: Option) -> anyhow::Result anyhow::Result<()> { let args = Args::parse(); @@ -164,6 +179,11 @@ async fn main() -> anyhow::Result<()> { Config::load_from(DEFAULT_CONFIG).expect("Failed to load default configuration") }); + // Set up terminal + crossterm::terminal::enable_raw_mode()?; + let _terminal_cleanup_guard = TerminalCleanupGuard; + crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?; + let listbox_state = listbox::State { listbox: Listbox::default(), config: config.completion.listbox.clone(), diff --git a/src/prompt.rs b/src/prompt.rs index a848ff9..3659b72 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -4,13 +4,12 @@ use futures::StreamExt; use promkit_widgets::{ core::{ crossterm::{ - cursor, event::{ DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, MouseEventKind, }, execute, - terminal::{self, disable_raw_mode, enable_raw_mode}, + terminal, }, grapheme::StyledGraphemes, render::{Renderer, SharedRenderer}, @@ -91,9 +90,6 @@ pub async fn run( keybinds: Keybinds, write_to_stdout: bool, ) -> anyhow::Result> { - enable_raw_mode()?; - execute!(io::stdout(), cursor::Hide)?; - let size = terminal::size()?; let shared_renderer = SharedRenderer::new( @@ -413,8 +409,5 @@ pub async fn run( completion_task.abort(); processor_task.abort(); - execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; - disable_raw_mode()?; - Ok(output) } From 7d1ae369c5e6856c0ac05408fba9e8eb17afdc6d Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 09:36:34 +0900 Subject: [PATCH 37/74] chore: refactor widgets construction --- src/main.rs | 44 ++++++++++++++++++++------------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 908f44a..412adee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,38 +184,34 @@ async fn main() -> anyhow::Result<()> { let _terminal_cleanup_guard = TerminalCleanupGuard; crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?; - let listbox_state = listbox::State { - listbox: Listbox::default(), - config: config.completion.listbox.clone(), - }; - - let text_editor_state = text_editor::State { - texteditor: if let Some(filter) = args.default_filter { - TextEditor::new(filter) - } else { - Default::default() - }, - history: Default::default(), - config: config.editor.on_focus.clone(), - }; - let shared_suggestions = completion::initialize( &input, config.json.max_streams, config.completion.search_load_chunk_size, ) .await?; - let completion = CompletionNavigator::new( + + // Initialize the completion navigator with shared suggestions and configuration. + let completion_navigator = CompletionNavigator::new( shared_suggestions.clone(), - listbox_state, + listbox::State { + listbox: Listbox::default(), + config: config.completion.listbox, + }, config.completion.search_result_chunk_size, ); - // TODO: re-consider put editor_task of prompt::run into Editor construction time. - // Overall, there are several cases where it would be sufficient to - // launch a background thread during construction. - let editor = QueryEditor::new( - text_editor_state, + // Initialize the query editor with the default filter, configuration, and keybindings. + let query_editor = QueryEditor::new( + text_editor::State { + texteditor: if let Some(filter) = args.default_filter { + TextEditor::new(filter) + } else { + Default::default() + }, + history: Default::default(), + config: config.editor.on_focus.clone(), + }, config.editor.on_focus, config.editor.on_defocus, // TODO: remove clones @@ -229,8 +225,8 @@ async fn main() -> anyhow::Result<()> { &input, config.json, config.reactivity_control, - editor, - completion, + query_editor, + completion_navigator, config.no_hint, config.keybinds, args.write_to_stdout, From 76e78a7b5120b5a1958c7ee4056260091a7e34c0 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 19:38:04 +0900 Subject: [PATCH 38/74] docs: try_new_for_tui --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index 412adee..4f57256 100644 --- a/src/main.rs +++ b/src/main.rs @@ -218,6 +218,7 @@ async fn main() -> anyhow::Result<()> { config.keybinds.on_editor.clone(), ); + // Redirects stdout to prevent interference with TUI interface. let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?; // TODO: put all logics here. From bec531d846ca4a48ad9111b2f6a1db439080ccf2 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 19:48:16 +0900 Subject: [PATCH 39/74] chore: rename Index --- src/completion.rs | 2 +- src/json_viewer.rs | 6 +++--- src/prompt.rs | 18 +++++++++--------- src/query_editor.rs | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 7e49563..8594bb6 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -286,7 +286,7 @@ pub fn start_completion_task( }; shared_renderer - .update([(Index::Search, completion_pane)]) + .update([(Index::Completion, completion_pane)]) .render() .await?; } diff --git a/src/json_viewer.rs b/src/json_viewer.rs index c909b8d..50b927a 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -208,7 +208,7 @@ pub async fn initialize( // TODO: error handling let _ = shared_renderer - .update([(Index::Processor, state.create_graphemes(area.0, area.1))]) + .update([(Index::JsonViewer, state.create_graphemes(area.0, area.1))]) .render() .await; } @@ -274,7 +274,7 @@ async fn handle_user_event( // TODO: error handling let _ = shared_renderer - .update([(Index::Processor, graphemes)]) + .update([(Index::JsonViewer, graphemes)]) .render() .await; } @@ -387,7 +387,7 @@ fn spawn_query_update_task( // TODO: error handling let _ = shared_renderer .update([( - Index::Processor, + Index::JsonViewer, maybe_resp.unwrap_or(StyledGraphemes::default()), )]) .render() diff --git a/src/prompt.rs b/src/prompt.rs index 3659b72..b95d67f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -73,10 +73,10 @@ enum GlobalAction { #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { - Editor = 0, + QueryEditor = 0, Guide = 1, - Search = 2, - Processor = 3, + Completion = 2, + JsonViewer = 3, } #[allow(clippy::too_many_arguments)] @@ -95,10 +95,10 @@ pub async fn run( let shared_renderer = SharedRenderer::new( Renderer::try_new_with_graphemes( [ - (Index::Editor, editor.create_graphemes(size.0, size.1)), + (Index::QueryEditor, editor.create_graphemes(size.0, size.1)), (Index::Guide, StyledGraphemes::default()), - (Index::Search, StyledGraphemes::default()), - (Index::Processor, StyledGraphemes::default()), + (Index::Completion, StyledGraphemes::default()), + (Index::JsonViewer, StyledGraphemes::default()), ] .into_iter(), true, @@ -133,7 +133,7 @@ pub async fn run( let spin_duration = reactivity_control.spin_duration; async move { let spinner = Spinner::default().duration(spin_duration); - let _ = spinner::run(&spinner, ctx, Index::Processor, shared_renderer).await; + let _ = spinner::run(&spinner, ctx, Index::JsonViewer, shared_renderer).await; } }); @@ -360,8 +360,8 @@ pub async fn run( }; shared_renderer .update([ - (Index::Editor, editor_pane), - (Index::Search, completion_pane), + (Index::QueryEditor, editor_pane), + (Index::Completion, completion_pane), ]) .render() .await?; diff --git a/src/query_editor.rs b/src/query_editor.rs index 2a4de56..4985d2d 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -193,7 +193,7 @@ pub fn start_query_editor_task( // Update the renderer with the new editor pane and render it. shared_renderer - .update([(Index::Editor, editor_pane)]) + .update([(Index::QueryEditor, editor_pane)]) .render() .await?; } From 21f18957fda24ec193aeaef3575d84f5bfb2d8b3 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 19:52:48 +0900 Subject: [PATCH 40/74] chore: move SharedRenderer into main --- src/main.rs | 30 ++++++++++++++++++++++++++++-- src/prompt.rs | 23 +++-------------------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4f57256..f148420 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,9 +8,13 @@ use anyhow::anyhow; use clap::Parser; use config::Config; use promkit_widgets::{ + core::{ + crossterm, + grapheme::StyledGraphemes, + render::{Renderer, SharedRenderer}, + }, listbox::{self, Listbox}, text_editor::{self, TextEditor}, - core::crossterm, }; mod query_editor; @@ -25,7 +29,7 @@ mod prompt; use completion::CompletionNavigator; mod json; -use crate::config::DEFAULT_CONFIG; +use crate::{config::DEFAULT_CONFIG, prompt::Index}; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] @@ -221,9 +225,31 @@ async fn main() -> anyhow::Result<()> { // Redirects stdout to prevent interference with TUI interface. let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?; + // Get terminal size for rendering purposes. + let terminal_size = crossterm::terminal::size()?; + + // Initialize the shared renderer with graphemes for each UI component. + let shared_renderer = SharedRenderer::new( + Renderer::try_new_with_graphemes( + [ + ( + Index::QueryEditor, + query_editor.create_graphemes(terminal_size.0, terminal_size.1), + ), + (Index::Guide, StyledGraphemes::default()), + (Index::Completion, StyledGraphemes::default()), + (Index::JsonViewer, StyledGraphemes::default()), + ] + .into_iter(), + true, + ) + .await?, + ); + // TODO: put all logics here. let maybe_output = prompt::run( &input, + shared_renderer, config.json, config.reactivity_control, query_editor, diff --git a/src/prompt.rs b/src/prompt.rs index b95d67f..6db0b5d 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -8,11 +8,9 @@ use promkit_widgets::{ DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, MouseEventKind, }, - execute, - terminal, + execute, terminal, }, - grapheme::StyledGraphemes, - render::{Renderer, SharedRenderer}, + render::SharedRenderer, }, spinner::{self, Spinner, State}, }; @@ -82,6 +80,7 @@ pub enum Index { #[allow(clippy::too_many_arguments)] pub async fn run( item: &'static str, + shared_renderer: SharedRenderer, json_config: JsonConfig, reactivity_control: ReactivityControl, editor: QueryEditor, @@ -90,22 +89,6 @@ pub async fn run( keybinds: Keybinds, write_to_stdout: bool, ) -> anyhow::Result> { - let size = terminal::size()?; - - let shared_renderer = SharedRenderer::new( - Renderer::try_new_with_graphemes( - [ - (Index::QueryEditor, editor.create_graphemes(size.0, size.1)), - (Index::Guide, StyledGraphemes::default()), - (Index::Completion, StyledGraphemes::default()), - (Index::JsonViewer, StyledGraphemes::default()), - ] - .into_iter(), - true, - ) - .await?, - ); - let ctx = SharedContext::try_default()?; let (last_query_tx, mut last_query_rx) = mpsc::channel(1); From 852c2be7eba65a1f85d547ddfc0b007f7c731917 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 20:18:50 +0900 Subject: [PATCH 41/74] chore: run crossterm::terminal::size() only at main.rs --- src/completion.rs | 12 +++++------- src/guide.rs | 11 ++++++----- src/json_viewer.rs | 39 +++++++++++++++++++++------------------ src/main.rs | 1 + src/prompt.rs | 20 ++++++++++++++------ src/query_editor.rs | 11 +++++------ 6 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 8594bb6..d6e8b67 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -1,11 +1,7 @@ use std::{collections::BTreeSet, sync::Arc}; use promkit_widgets::{ - core::{ - crossterm::{event::Event, terminal}, - grapheme::StyledGraphemes, - Widget, - }, + core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, listbox::{self, Listbox}, }; use tokio::{ @@ -17,6 +13,7 @@ use crate::{ config::CompletionKeybinds, guide::{GuideAction, GuideMessage}, json, + json_viewer::SharedContext, prompt::Index, query_editor::QueryEditorAction, }; @@ -238,6 +235,7 @@ pub fn start_completion_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, shared_completion: Arc>, + shared_ctx: SharedContext, query_editor_action_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, completion_keybinds: CompletionKeybinds, @@ -246,7 +244,7 @@ pub fn start_completion_task( loop { tokio::select! { Some(action) = action_rx.recv() => { - let size = terminal::size()?; + let area = shared_ctx.area().await; let completion_pane = { let mut completion = shared_completion.write().await; match action { @@ -282,7 +280,7 @@ pub fn start_completion_task( completion.clear_session_state(); } } - completion.create_graphemes(size.0, size.1) + completion.create_graphemes(area.0, area.1) }; shared_renderer diff --git a/src/guide.rs b/src/guide.rs index 38ee433..08dc524 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -1,11 +1,11 @@ use arboard::Clipboard; use promkit_widgets::{ - core::{crossterm::terminal, render::SharedRenderer, Widget}, + core::{render::SharedRenderer, Widget}, status::{self, Severity}, }; use tokio::{sync::mpsc, task::JoinHandle}; -use crate::prompt::Index; +use crate::{json_viewer::SharedContext, prompt::Index}; pub enum GuideMessage { CopiedToClipboard, @@ -79,19 +79,20 @@ pub fn copy_to_clipboard_message(content: &str) -> GuideMessage { pub fn start_guide_task( mut action_rx: mpsc::Receiver, shared_renderer: SharedRenderer, + shared_ctx: SharedContext, no_hint: bool, ) -> JoinHandle> { tokio::spawn(async move { loop { tokio::select! { Some(action) = action_rx.recv() => { - let size = terminal::size()?; + let area = shared_ctx.area().await; let pane = if no_hint { Default::default() } else { match action { - GuideAction::Clear => status::State::default().create_graphemes(size.0, size.1), - GuideAction::Show(message) => message_to_state(message).create_graphemes(size.0, size.1), + GuideAction::Clear => status::State::default().create_graphemes(area.0, area.1), + GuideAction::Show(message) => message_to_state(message).create_graphemes(area.0, area.1), } }; shared_renderer.update([(Index::Guide, pane)]).render().await?; diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 50b927a..42ef39d 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -1,12 +1,7 @@ use std::{future::Future, sync::Arc}; use promkit_widgets::{ - core::{ - crossterm::{event::Event, terminal}, - grapheme::StyledGraphemes, - render::SharedRenderer, - Widget, - }, + core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, jsonstream::{self, JsonStream}, serde_json::{self, Value}, spinner, @@ -41,6 +36,11 @@ struct Context { /// The current state of the processor, which can be Idle, Loading, or Processing. state: State, /// The current size of the terminal area. + /// + /// PERF NOTE: This currently lives with `state/current_task` in the same mutex + /// for simplicity. If lock contention becomes visible, this can be split into + /// a dedicated shared store (e.g. `Arc>`) to reduce lock + /// granularity. area: (u16, u16), /// The current task being executed, if any. current_task: Option>, @@ -50,13 +50,22 @@ struct Context { pub struct SharedContext(Arc>); impl SharedContext { - pub fn try_default() -> anyhow::Result { - let area = terminal::size()?; - Ok(Self(Arc::new(Mutex::new(Context { + pub fn new(area: (u16, u16)) -> Self { + Self(Arc::new(Mutex::new(Context { state: State::Idle, area, current_task: None, - })))) + }))) + } + + pub async fn area(&self) -> (u16, u16) { + let ctx = self.0.lock().await; + ctx.area + } + + pub async fn set_area(&self, area: (u16, u16)) { + let mut ctx = self.0.lock().await; + ctx.area = area; } async fn lock(&self) -> tokio::sync::MutexGuard<'_, Context> { @@ -81,7 +90,7 @@ pub enum RenderTrigger { /// Query changes such as new jq filter input QueryChanged { query: String }, /// Terminal resize events - AreaResized { area: (u16, u16), query: String }, + AreaResized { query: String }, } /// JSON viewer that maintains the state of JSON stream @@ -241,13 +250,12 @@ pub async fn render( ) .await; } - RenderTrigger::AreaResized { area, query } => { + RenderTrigger::AreaResized { query } => { handle_area_resized( shared_viewer_state, shared_renderer, shared_ctx, guide_action_tx, - area, query, ) .await; @@ -316,15 +324,10 @@ async fn handle_area_resized( shared_renderer: SharedRenderer, shared_ctx: SharedContext, guide_action_tx: mpsc::Sender, - area: (u16, u16), query: String, ) { { let mut ctx = shared_ctx.lock().await; - - // Update the terminal area in shared context for accurate rendering. - ctx.area = area; - // Abort any ongoing processing task to prevent race conditions // and ensure the new render reflects the latest terminal size. if let Some(task) = ctx.current_task.take() { diff --git a/src/main.rs b/src/main.rs index f148420..1c3a037 100644 --- a/src/main.rs +++ b/src/main.rs @@ -249,6 +249,7 @@ async fn main() -> anyhow::Result<()> { // TODO: put all logics here. let maybe_output = prompt::run( &input, + terminal_size, shared_renderer, config.json, config.reactivity_control, diff --git a/src/prompt.rs b/src/prompt.rs index 6db0b5d..f3bcd37 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -80,6 +80,7 @@ pub enum Index { #[allow(clippy::too_many_arguments)] pub async fn run( item: &'static str, + terminal_size: (u16, u16), shared_renderer: SharedRenderer, json_config: JsonConfig, reactivity_control: ReactivityControl, @@ -89,7 +90,7 @@ pub async fn run( keybinds: Keybinds, write_to_stdout: bool, ) -> anyhow::Result> { - let ctx = SharedContext::try_default()?; + let ctx = SharedContext::new(terminal_size); let (last_query_tx, mut last_query_rx) = mpsc::channel(1); let (debounce_query_tx, debounce_query_rx) = mpsc::channel(1); @@ -303,12 +304,18 @@ pub async fn run( }) }; - let guide_task = guide::start_guide_task(guide_action_rx, shared_renderer.clone(), no_hint); + let guide_task = guide::start_guide_task( + guide_action_rx, + shared_renderer.clone(), + ctx.clone(), + no_hint, + ); let editor_task = query_editor::start_query_editor_task( editor_action_rx, shared_renderer.clone(), shared_editor.clone(), + ctx.clone(), debounce_query_tx.clone(), guide_action_tx.clone(), ); @@ -317,6 +324,7 @@ pub async fn run( completion_action_rx, shared_renderer.clone(), shared_completion.clone(), + ctx.clone(), editor_action_tx.clone(), guide_action_tx.clone(), editor_keybinds.on_completion, @@ -332,13 +340,13 @@ pub async fn run( let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { while let Some(area) = last_resize_rx.recv().await { - let size = terminal::size()?; + ctx.set_area(area).await; let (editor_pane, completion_pane) = { let editor = shared_editor.read().await; let completion = shared_completion.read().await; ( - editor.create_graphemes(size.0, size.1), - completion.create_graphemes(size.0, size.1), + editor.create_graphemes(area.0, area.1), + completion.create_graphemes(area.0, area.1), ) }; shared_renderer @@ -357,7 +365,7 @@ pub async fn run( shared_renderer.clone(), ctx.clone(), guide_action_tx.clone(), - RenderTrigger::AreaResized { area, query: text }, + RenderTrigger::AreaResized { query: text }, ) .await; } diff --git a/src/query_editor.rs b/src/query_editor.rs index 4985d2d..bb3b2a9 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -2,10 +2,7 @@ use std::sync::Arc; use promkit_widgets::{ core::{ - crossterm::{ - event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - terminal, - }, + crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, grapheme::StyledGraphemes, Widget, }, @@ -19,6 +16,7 @@ use tokio::{ use crate::{ config::EditorKeybinds, guide::{self, GuideAction}, + json_viewer::SharedContext, prompt::Index, }; @@ -153,6 +151,7 @@ pub fn start_query_editor_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, shared_editor: Arc>, + shared_ctx: SharedContext, debounce_query_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, ) -> JoinHandle> { @@ -164,7 +163,7 @@ pub fn start_query_editor_task( loop { tokio::select! { Some(action) = action_rx.recv() => { - let size = terminal::size()?; + let area = shared_ctx.area().await; let (editor_pane, current_text) = { let mut editor = shared_editor.write().await; match action { @@ -182,7 +181,7 @@ pub fn start_query_editor_task( } } let current_text = editor.text(); - (editor.create_graphemes(size.0, size.1), current_text) + (editor.create_graphemes(area.0, area.1), current_text) }; // If the text has changed, send it to the debounce channel for processing. From 36230b01a06fe120ca179c8d2c19c8619e36f611 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 20:22:30 +0900 Subject: [PATCH 42/74] chore: SharedContext at main.rs --- src/main.rs | 8 ++++++-- src/prompt.rs | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1c3a037..1004f79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ mod prompt; use completion::CompletionNavigator; mod json; -use crate::{config::DEFAULT_CONFIG, prompt::Index}; +use crate::{config::DEFAULT_CONFIG, json_viewer::SharedContext, prompt::Index}; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] @@ -246,10 +246,14 @@ async fn main() -> anyhow::Result<()> { .await?, ); + // Initialize the shared context with the terminal size, + // which can be used by various components for rendering and state management. + let ctx = SharedContext::new(terminal_size); + // TODO: put all logics here. let maybe_output = prompt::run( &input, - terminal_size, + ctx, shared_renderer, config.json, config.reactivity_control, diff --git a/src/prompt.rs b/src/prompt.rs index f3bcd37..0647ac0 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -80,7 +80,7 @@ pub enum Index { #[allow(clippy::too_many_arguments)] pub async fn run( item: &'static str, - terminal_size: (u16, u16), + ctx: SharedContext, shared_renderer: SharedRenderer, json_config: JsonConfig, reactivity_control: ReactivityControl, @@ -90,8 +90,6 @@ pub async fn run( keybinds: Keybinds, write_to_stdout: bool, ) -> anyhow::Result> { - let ctx = SharedContext::new(terminal_size); - let (last_query_tx, mut last_query_rx) = mpsc::channel(1); let (debounce_query_tx, debounce_query_rx) = mpsc::channel(1); let query_debouncer = spawn_debouncer( From ae2580b3a772161498dbb337c6e6e98ce86b7b44 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 20:36:16 +0900 Subject: [PATCH 43/74] chore: separate debouncer into utils --- src/main.rs | 1 + src/prompt.rs | 48 ++++++------------------------------------ src/utils.rs | 1 + src/utils/debounce.rs | 49 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 42 deletions(-) create mode 100644 src/utils.rs create mode 100644 src/utils/debounce.rs diff --git a/src/main.rs b/src/main.rs index 1004f79..b767cb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ mod completion; mod prompt; use completion::CompletionNavigator; mod json; +mod utils; use crate::{config::DEFAULT_CONFIG, json_viewer::SharedContext, prompt::Index}; diff --git a/src/prompt.rs b/src/prompt.rs index 0647ac0..71137c2 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -1,4 +1,4 @@ -use std::{io, sync::Arc, time::Duration}; +use std::{io, sync::Arc}; use futures::StreamExt; use promkit_widgets::{ @@ -25,35 +25,9 @@ use crate::{ guide::{self, GuideAction, GuideMessage}, json_viewer::{self, RenderTrigger, SharedContext}, query_editor::{self, QueryEditor, QueryEditorAction}, + utils::debounce::setup_debouncer, }; -fn spawn_debouncer( - mut debounce_rx: mpsc::Receiver, - last_tx: mpsc::Sender, - duration: Duration, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut last_query = None; - let mut delay = tokio::time::interval(duration); - loop { - tokio::select! { - maybe_query = debounce_rx.recv() => { - if let Some(query) = maybe_query { - last_query = Some(query); - } else { - break; - } - }, - _ = delay.tick() => { - if let Some(text) = last_query.take() { - let _ = last_tx.send(text).await; - } - }, - } - } - }) -} - #[derive(Clone, Copy)] enum Focus { Editor, @@ -90,24 +64,14 @@ pub async fn run( keybinds: Keybinds, write_to_stdout: bool, ) -> anyhow::Result> { - let (last_query_tx, mut last_query_rx) = mpsc::channel(1); - let (debounce_query_tx, debounce_query_rx) = mpsc::channel(1); - let query_debouncer = spawn_debouncer( - debounce_query_rx, - last_query_tx, - reactivity_control.query_debounce_duration, - ); + let (debounce_query_tx, mut last_query_rx, query_debouncer) = + setup_debouncer(reactivity_control.query_debounce_duration); if !editor.text().is_empty() { debounce_query_tx.send(editor.text()).await?; } - let (last_resize_tx, mut last_resize_rx) = mpsc::channel::<(u16, u16)>(1); - let (debounce_resize_tx, debounce_resize_rx) = mpsc::channel(1); - let resize_debouncer = spawn_debouncer( - debounce_resize_rx, - last_resize_tx, - reactivity_control.resize_debounce_duration, - ); + let (debounce_resize_tx, mut last_resize_rx, resize_debouncer) = + setup_debouncer::<(u16, u16)>(reactivity_control.resize_debounce_duration); let spinning = tokio::spawn({ let shared_renderer = shared_renderer.clone(); diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..fdc9242 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1 @@ +pub mod debounce; diff --git a/src/utils/debounce.rs b/src/utils/debounce.rs new file mode 100644 index 0000000..ebf10f3 --- /dev/null +++ b/src/utils/debounce.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use tokio::{sync::mpsc, task::JoinHandle}; + +/// Initializes the debouncer input/output channels and task handle as a set. +/// +/// Returns: +/// - `debounce_tx`: Input channel for values that should be debounced. +/// - Send a value here whenever the source value changes. +/// - If multiple values arrive within a short period, only the latest one is kept as a candidate. +/// - `last_rx`: Output channel for debounced values. +/// - On each `duration` tick, one value is emitted if a latest candidate exists. +/// - Consumers can process only the "settled" latest value by reading from this channel. +/// - `debouncer`: Join handle of the background task running the debounce loop. +pub fn setup_debouncer( + duration: Duration, +) -> (mpsc::Sender, mpsc::Receiver, JoinHandle<()>) { + let (last_tx, last_rx) = mpsc::channel(1); + let (debounce_tx, debounce_rx) = mpsc::channel(1); + let debouncer = spawn_debouncer(debounce_rx, last_tx, duration); + (debounce_tx, last_rx, debouncer) +} + +fn spawn_debouncer( + mut debounce_rx: mpsc::Receiver, + last_tx: mpsc::Sender, + duration: Duration, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut last_query = None; + let mut delay = tokio::time::interval(duration); + loop { + tokio::select! { + maybe_query = debounce_rx.recv() => { + if let Some(query) = maybe_query { + last_query = Some(query); + } else { + break; + } + }, + _ = delay.tick() => { + if let Some(text) = last_query.take() { + let _ = last_tx.send(text).await; + } + }, + } + } + }) +} From 087281cc256cb2b829d71e20f145a794617d687f Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 20:40:31 +0900 Subject: [PATCH 44/74] docs: spowners --- src/completion.rs | 4 ++++ src/json_viewer.rs | 5 ++--- src/query_editor.rs | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index d6e8b67..9f7227f 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -226,11 +226,15 @@ impl CompletionNavigator { } pub enum CompletionAction { + /// Triggered when the user enters the completion pane with a current query as prefix. Enter { prefix: String }, + /// Triggered when the user leaves the completion pane. Leave, + /// Triggered on user input events within the completion pane, such as navigation keys. UserEvent(Event), } +/// Spawn a background task to manage the completion navigator's state and interactions. pub fn start_completion_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 42ef39d..8237217 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -351,9 +351,8 @@ async fn handle_area_resized( } } -/// Spawn a background task to process jq query -/// and update the viewer state -/// and rendered view accordingly. +// Spawn a background task to process the jq query and update the viewer state with the results, +// while managing the viewer state to prevent race conditions and ensure the view reflects the latest terminal size. fn spawn_query_update_task( shared_viewer_state: SharedJsonViewer, shared_ctx: SharedContext, diff --git a/src/query_editor.rs b/src/query_editor.rs index bb3b2a9..ab6a88c 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -146,7 +146,6 @@ pub enum QueryEditorAction { } /// Spawn a background task to manage the query editor's state and interactions. -/// It listens for actions such as focusing, copying the query, or handling user events. pub fn start_query_editor_task( mut action_rx: mpsc::Receiver, shared_renderer: promkit_widgets::core::render::SharedRenderer, From 3344bf19a3bfd3f5ae789fc8ba523dc4b5fc7e70 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:08:01 +0900 Subject: [PATCH 45/74] chore: debounce for query in main.rs --- src/main.rs | 8 ++++++++ src/prompt.rs | 7 ++++--- src/utils.rs | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index b767cb0..3b9a242 100644 --- a/src/main.rs +++ b/src/main.rs @@ -251,6 +251,11 @@ async fn main() -> anyhow::Result<()> { // which can be used by various components for rendering and state management. let ctx = SharedContext::new(terminal_size); + // Set up the debouncer for the query editor input, which will manage the timing of query updates + // to prevent excessive processing while the user is typing. + let (debounce_query_tx, last_query_rx, query_debouncer) = + utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); + // TODO: put all logics here. let maybe_output = prompt::run( &input, @@ -263,6 +268,9 @@ async fn main() -> anyhow::Result<()> { config.no_hint, config.keybinds, args.write_to_stdout, + debounce_query_tx, + last_query_rx, + query_debouncer, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 71137c2..5c15798 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -25,7 +25,7 @@ use crate::{ guide::{self, GuideAction, GuideMessage}, json_viewer::{self, RenderTrigger, SharedContext}, query_editor::{self, QueryEditor, QueryEditorAction}, - utils::debounce::setup_debouncer, + utils::setup_debouncer, }; #[derive(Clone, Copy)] @@ -63,9 +63,10 @@ pub async fn run( no_hint: bool, keybinds: Keybinds, write_to_stdout: bool, + debounce_query_tx: mpsc::Sender, + mut last_query_rx: mpsc::Receiver, + query_debouncer: JoinHandle<()>, ) -> anyhow::Result> { - let (debounce_query_tx, mut last_query_rx, query_debouncer) = - setup_debouncer(reactivity_control.query_debounce_duration); if !editor.text().is_empty() { debounce_query_tx.send(editor.text()).await?; } diff --git a/src/utils.rs b/src/utils.rs index fdc9242..f8fbd56 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1 +1,2 @@ -pub mod debounce; +mod debounce; +pub use debounce::setup_debouncer; From 7635ef30b8d2c469401d2ec6efca7c140fe0a4f2 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:10:56 +0900 Subject: [PATCH 46/74] chore: debounce for resize in main.rs --- src/main.rs | 8 ++++++++ src/prompt.rs | 7 +++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3b9a242..1c07fed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -256,6 +256,11 @@ async fn main() -> anyhow::Result<()> { let (debounce_query_tx, last_query_rx, query_debouncer) = utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); + // Set up the debouncer for terminal resize events, which will manage the timing of resize handling + // to prevent excessive re-rendering while the terminal is being resized. + let (debounce_resize_tx, last_resize_rx, resize_debouncer) = + utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); + // TODO: put all logics here. let maybe_output = prompt::run( &input, @@ -271,6 +276,9 @@ async fn main() -> anyhow::Result<()> { debounce_query_tx, last_query_rx, query_debouncer, + debounce_resize_tx, + last_resize_rx, + resize_debouncer, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 5c15798..7f316db 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -25,7 +25,6 @@ use crate::{ guide::{self, GuideAction, GuideMessage}, json_viewer::{self, RenderTrigger, SharedContext}, query_editor::{self, QueryEditor, QueryEditorAction}, - utils::setup_debouncer, }; #[derive(Clone, Copy)] @@ -66,14 +65,14 @@ pub async fn run( debounce_query_tx: mpsc::Sender, mut last_query_rx: mpsc::Receiver, query_debouncer: JoinHandle<()>, + debounce_resize_tx: mpsc::Sender<(u16, u16)>, + mut last_resize_rx: mpsc::Receiver<(u16, u16)>, + resize_debouncer: JoinHandle<()>, ) -> anyhow::Result> { if !editor.text().is_empty() { debounce_query_tx.send(editor.text()).await?; } - let (debounce_resize_tx, mut last_resize_rx, resize_debouncer) = - setup_debouncer::<(u16, u16)>(reactivity_control.resize_debounce_duration); - let spinning = tokio::spawn({ let shared_renderer = shared_renderer.clone(); let ctx = ctx.clone(); From 16cf11e8f88912f79e9286bcd3abbb1276253630 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:21:18 +0900 Subject: [PATCH 47/74] chore: initialize of completion is unnecessary to async and result --- src/completion.rs | 6 +++--- src/main.rs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 9f7227f..8d9a9e0 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -50,11 +50,11 @@ impl SharedSuggestionStore { } /// Initialize shared suggestion store by loading paths from JSON input -pub async fn initialize( +pub fn initialize( item: &'static str, max_streams: Option, chunk_size: usize, -) -> anyhow::Result { +) -> SharedSuggestionStore { let shared = SharedSuggestionStore(Arc::new(Mutex::new(SuggestionStore { paths: BTreeSet::new(), progress: SuggestionLoadProgress::default(), @@ -99,7 +99,7 @@ pub async fn initialize( store.progress.is_complete = true; }); - Ok(shared) + shared } /// Navigator for managing the state of suggestions diff --git a/src/main.rs b/src/main.rs index 1c07fed..7697561 100644 --- a/src/main.rs +++ b/src/main.rs @@ -193,8 +193,7 @@ async fn main() -> anyhow::Result<()> { &input, config.json.max_streams, config.completion.search_load_chunk_size, - ) - .await?; + ); // Initialize the completion navigator with shared suggestions and configuration. let completion_navigator = CompletionNavigator::new( From 46684c76faf4e210a45be615f2ed5d341b3b49e8 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:39:09 +0900 Subject: [PATCH 48/74] chore: completion::initialize => completion::spawn_initialize and return JoinHandle --- src/completion.rs | 10 +++++----- src/main.rs | 6 ++++-- src/prompt.rs | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 8d9a9e0..0581769 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -49,19 +49,19 @@ impl SharedSuggestionStore { } } -/// Initialize shared suggestion store by loading paths from JSON input -pub fn initialize( +/// Spawn a background loader and return shared suggestion store with task handle. +pub fn spawn_initialize( item: &'static str, max_streams: Option, chunk_size: usize, -) -> SharedSuggestionStore { +) -> (SharedSuggestionStore, JoinHandle<()>) { let shared = SharedSuggestionStore(Arc::new(Mutex::new(SuggestionStore { paths: BTreeSet::new(), progress: SuggestionLoadProgress::default(), }))); let shared_for_loading = shared.clone(); - task::spawn(async move { + let loader_task = task::spawn(async move { // Load paths in a streaming manner and update the shared store incrementally let iter = match json::get_all_paths(item, max_streams).await { Ok(iter) => iter, @@ -99,7 +99,7 @@ pub fn initialize( store.progress.is_complete = true; }); - shared + (shared, loader_task) } /// Navigator for managing the state of suggestions diff --git a/src/main.rs b/src/main.rs index 7697561..a9afed6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -189,7 +189,8 @@ async fn main() -> anyhow::Result<()> { let _terminal_cleanup_guard = TerminalCleanupGuard; crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?; - let shared_suggestions = completion::initialize( + // Spawn the completion loader task, which will asynchronously load suggestions based on the input data. + let (shared_suggestions, completion_loader_task) = completion::spawn_initialize( &input, config.json.max_streams, config.completion.search_load_chunk_size, @@ -197,7 +198,7 @@ async fn main() -> anyhow::Result<()> { // Initialize the completion navigator with shared suggestions and configuration. let completion_navigator = CompletionNavigator::new( - shared_suggestions.clone(), + shared_suggestions, listbox::State { listbox: Listbox::default(), config: config.completion.listbox, @@ -278,6 +279,7 @@ async fn main() -> anyhow::Result<()> { debounce_resize_tx, last_resize_rx, resize_debouncer, + completion_loader_task, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 7f316db..ed0f94c 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -68,6 +68,7 @@ pub async fn run( debounce_resize_tx: mpsc::Sender<(u16, u16)>, mut last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, + completion_loader_task: JoinHandle<()>, ) -> anyhow::Result> { if !editor.text().is_empty() { debounce_query_tx.send(editor.text()).await?; @@ -355,6 +356,7 @@ pub async fn run( spinning.abort(); query_debouncer.abort(); resize_debouncer.abort(); + completion_loader_task.abort(); query_action_forwarder.abort(); resize_action_forwarder.abort(); guide_task.abort(); From dc27df0961c88637b6f90918ea429c38eb3f7f71 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:43:22 +0900 Subject: [PATCH 49/74] chore: spinning at main.rs --- src/main.rs | 15 +++++++++++++-- src/prompt.rs | 15 +++------------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index a9afed6..552933b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use promkit_widgets::{ render::{Renderer, SharedRenderer}, }, listbox::{self, Listbox}, + spinner::{self, Spinner}, text_editor::{self, TextEditor}, }; @@ -230,7 +231,7 @@ async fn main() -> anyhow::Result<()> { let terminal_size = crossterm::terminal::size()?; // Initialize the shared renderer with graphemes for each UI component. - let shared_renderer = SharedRenderer::new( + let renderer = SharedRenderer::new( Renderer::try_new_with_graphemes( [ ( @@ -261,11 +262,20 @@ async fn main() -> anyhow::Result<()> { let (debounce_resize_tx, last_resize_rx, resize_debouncer) = utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); + let spinning = tokio::spawn({ + let shared_renderer = renderer.clone(); + let ctx = ctx.clone(); + async move { + let spinner = Spinner::default().duration(config.reactivity_control.spin_duration); + let _ = spinner::run(&spinner, ctx, Index::JsonViewer, shared_renderer).await; + } + }); + // TODO: put all logics here. let maybe_output = prompt::run( &input, ctx, - shared_renderer, + renderer, config.json, config.reactivity_control, query_editor, @@ -280,6 +290,7 @@ async fn main() -> anyhow::Result<()> { last_resize_rx, resize_debouncer, completion_loader_task, + spinning, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index ed0f94c..1b59180 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -12,7 +12,7 @@ use promkit_widgets::{ }, render::SharedRenderer, }, - spinner::{self, Spinner, State}, + spinner::State, }; use tokio::{ sync::{mpsc, RwLock}, @@ -56,7 +56,7 @@ pub async fn run( ctx: SharedContext, shared_renderer: SharedRenderer, json_config: JsonConfig, - reactivity_control: ReactivityControl, + _reactivity_control: ReactivityControl, editor: QueryEditor, completion: CompletionNavigator, no_hint: bool, @@ -69,21 +69,12 @@ pub async fn run( mut last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, completion_loader_task: JoinHandle<()>, + spinning: JoinHandle<()>, ) -> anyhow::Result> { if !editor.text().is_empty() { debounce_query_tx.send(editor.text()).await?; } - let spinning = tokio::spawn({ - let shared_renderer = shared_renderer.clone(); - let ctx = ctx.clone(); - let spin_duration = reactivity_control.spin_duration; - async move { - let spinner = Spinner::default().duration(spin_duration); - let _ = spinner::run(&spinner, ctx, Index::JsonViewer, shared_renderer).await; - } - }); - let mut focus = Focus::Editor; let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); From 78be89b63086e65a36c16555ac9b2dfe4376ef88 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:45:09 +0900 Subject: [PATCH 50/74] docs: spinner_task --- src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 552933b..8bb6b0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -262,7 +262,8 @@ async fn main() -> anyhow::Result<()> { let (debounce_resize_tx, last_resize_rx, resize_debouncer) = utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); - let spinning = tokio::spawn({ + // Spawn the spinner task, which will display a loading spinner in JSON viewer while processing is ongoing. + let spinner_task = tokio::spawn({ let shared_renderer = renderer.clone(); let ctx = ctx.clone(); async move { @@ -290,7 +291,7 @@ async fn main() -> anyhow::Result<()> { last_resize_rx, resize_debouncer, completion_loader_task, - spinning, + spinner_task, ) .await; From f39f6a8060f0f783fe59b23aaa4b0d20c2832bed Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:47:51 +0900 Subject: [PATCH 51/74] chore: focus internally --- src/prompt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompt.rs b/src/prompt.rs index 1b59180..4d87143 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -75,7 +75,6 @@ pub async fn run( debounce_query_tx.send(editor.text()).await?; } - let mut focus = Focus::Editor; let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); let (json_viewer_action_tx, json_viewer_action_rx) = @@ -94,6 +93,7 @@ pub async fn run( ); let main_task: JoinHandle> = { + let mut focus = Focus::Editor; let mut stream = EventStream::new(); let ctx = ctx.clone(); let shared_editor = shared_editor.clone(); From ba2a56a7787be6ff122ed3b37b8411037e6818dc Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 21:59:40 +0900 Subject: [PATCH 52/74] chore: json_viewer::initialize in main.rs --- src/main.rs | 13 ++++++++++--- src/prompt.rs | 17 ++++------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8bb6b0b..2f76ca2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -252,6 +252,15 @@ async fn main() -> anyhow::Result<()> { // which can be used by various components for rendering and state management. let ctx = SharedContext::new(terminal_size); + // Load input data into JSON viewer, initializing it with the provided configuration and keybindings. + let load_for_json_viewer = json_viewer::initialize( + input, + config.json, + config.keybinds.on_json_viewer.clone(), + renderer.clone(), + ctx.clone(), + ); + // Set up the debouncer for the query editor input, which will manage the timing of query updates // to prevent excessive processing while the user is typing. let (debounce_query_tx, last_query_rx, query_debouncer) = @@ -274,11 +283,8 @@ async fn main() -> anyhow::Result<()> { // TODO: put all logics here. let maybe_output = prompt::run( - &input, ctx, renderer, - config.json, - config.reactivity_control, query_editor, completion_navigator, config.no_hint, @@ -292,6 +298,7 @@ async fn main() -> anyhow::Result<()> { resize_debouncer, completion_loader_task, spinner_task, + load_for_json_viewer, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 4d87143..39ee269 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -21,9 +21,9 @@ use tokio::{ use crate::{ completion::{self, CompletionAction, CompletionNavigator}, - config::{JsonConfig, Keybinds, ReactivityControl}, + config::Keybinds, guide::{self, GuideAction, GuideMessage}, - json_viewer::{self, RenderTrigger, SharedContext}, + json_viewer::{self, RenderTrigger, SharedContext, SharedJsonViewer}, query_editor::{self, QueryEditor, QueryEditorAction}, }; @@ -52,11 +52,8 @@ pub enum Index { #[allow(clippy::too_many_arguments)] pub async fn run( - item: &'static str, ctx: SharedContext, shared_renderer: SharedRenderer, - json_config: JsonConfig, - _reactivity_control: ReactivityControl, editor: QueryEditor, completion: CompletionNavigator, no_hint: bool, @@ -70,6 +67,7 @@ pub async fn run( resize_debouncer: JoinHandle<()>, completion_loader_task: JoinHandle<()>, spinning: JoinHandle<()>, + json_viewer_bootstrap: impl std::future::Future>, ) -> anyhow::Result> { if !editor.text().is_empty() { debounce_query_tx.send(editor.text()).await?; @@ -84,13 +82,6 @@ pub async fn run( let shared_editor = Arc::new(RwLock::new(editor)); let shared_completion = Arc::new(RwLock::new(completion)); let editor_keybinds = keybinds.on_editor.clone(); - let initializing = json_viewer::initialize( - item, - json_config, - keybinds.on_json_viewer, - shared_renderer.clone(), - ctx.clone(), - ); let main_task: JoinHandle> = { let mut focus = Focus::Editor; @@ -284,7 +275,7 @@ pub async fn run( editor_keybinds.on_completion, ); - let shared_viewer_state = initializing.await?; + let shared_viewer_state = json_viewer_bootstrap.await?; let resize_action_forwarder = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); From b75989de78725f621d1b7f959c024252ed8dd5b2 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 22:05:04 +0900 Subject: [PATCH 53/74] chore: shared editor/navi in main.rs --- src/main.rs | 40 ++++++++++++++++++++++++++-------------- src/prompt.rs | 10 ++-------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2f76ca2..e9793b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,11 @@ use std::{ fs::File, io::{self, Read, Write}, path::PathBuf, + sync::Arc, }; use anyhow::anyhow; use clap::Parser; -use config::Config; use promkit_widgets::{ core::{ crossterm, @@ -17,10 +17,12 @@ use promkit_widgets::{ spinner::{self, Spinner}, text_editor::{self, TextEditor}, }; +use tokio::sync::RwLock; mod query_editor; use query_editor::QueryEditor; mod config; +use config::Config; mod guide; mod json_viewer; mod stdout_redirect; @@ -210,7 +212,7 @@ async fn main() -> anyhow::Result<()> { // Initialize the query editor with the default filter, configuration, and keybindings. let query_editor = QueryEditor::new( text_editor::State { - texteditor: if let Some(filter) = args.default_filter { + texteditor: if let Some(ref filter) = args.default_filter { TextEditor::new(filter) } else { Default::default() @@ -261,16 +263,6 @@ async fn main() -> anyhow::Result<()> { ctx.clone(), ); - // Set up the debouncer for the query editor input, which will manage the timing of query updates - // to prevent excessive processing while the user is typing. - let (debounce_query_tx, last_query_rx, query_debouncer) = - utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); - - // Set up the debouncer for terminal resize events, which will manage the timing of resize handling - // to prevent excessive re-rendering while the terminal is being resized. - let (debounce_resize_tx, last_resize_rx, resize_debouncer) = - utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); - // Spawn the spinner task, which will display a loading spinner in JSON viewer while processing is ongoing. let spinner_task = tokio::spawn({ let shared_renderer = renderer.clone(); @@ -281,12 +273,32 @@ async fn main() -> anyhow::Result<()> { } }); + // Wrap the query editor and completion navigator in Arc> to allow shared mutable access across async tasks. + let shared_query_editor = Arc::new(RwLock::new(query_editor)); + let shared_completion_navigator = Arc::new(RwLock::new(completion_navigator)); + + // Set up the debouncer for the query editor input, which will manage the timing of query updates + // to prevent excessive processing while the user is typing. + let (debounce_query_tx, last_query_rx, query_debouncer) = + utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); + + // If a default filter is provided via command-line arguments, send it to the query debouncer + // to initialize the interface with that filter applied. + if let Some(default_filter) = args.default_filter { + debounce_query_tx.send(default_filter).await?; + } + + // Set up the debouncer for terminal resize events, which will manage the timing of resize handling + // to prevent excessive re-rendering while the terminal is being resized. + let (debounce_resize_tx, last_resize_rx, resize_debouncer) = + utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); + // TODO: put all logics here. let maybe_output = prompt::run( ctx, renderer, - query_editor, - completion_navigator, + shared_query_editor, + shared_completion_navigator, config.no_hint, config.keybinds, args.write_to_stdout, diff --git a/src/prompt.rs b/src/prompt.rs index 39ee269..aecc0a6 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -54,8 +54,8 @@ pub enum Index { pub async fn run( ctx: SharedContext, shared_renderer: SharedRenderer, - editor: QueryEditor, - completion: CompletionNavigator, + shared_editor: Arc>, + shared_completion: Arc>, no_hint: bool, keybinds: Keybinds, write_to_stdout: bool, @@ -69,18 +69,12 @@ pub async fn run( spinning: JoinHandle<()>, json_viewer_bootstrap: impl std::future::Future>, ) -> anyhow::Result> { - if !editor.text().is_empty() { - debounce_query_tx.send(editor.text()).await?; - } - let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); let (json_viewer_action_tx, json_viewer_action_rx) = mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - let shared_editor = Arc::new(RwLock::new(editor)); - let shared_completion = Arc::new(RwLock::new(completion)); let editor_keybinds = keybinds.on_editor.clone(); let main_task: JoinHandle> = { From 084c3f1ae6ae2cafa8c3fb00e2046b4cb7c55888 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 22:32:11 +0900 Subject: [PATCH 54/74] chore: prepare context.rs to store SharedContext --- src/completion.rs | 2 +- src/context.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++ src/guide.rs | 2 +- src/json_viewer.rs | 69 ++------------------------------------------- src/main.rs | 3 +- src/prompt.rs | 3 +- src/query_editor.rs | 2 +- 7 files changed, 78 insertions(+), 72 deletions(-) create mode 100644 src/context.rs diff --git a/src/completion.rs b/src/completion.rs index 0581769..ceb3b7d 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -11,9 +11,9 @@ use tokio::{ use crate::{ config::CompletionKeybinds, + context::SharedContext, guide::{GuideAction, GuideMessage}, json, - json_viewer::SharedContext, prompt::Index, query_editor::QueryEditorAction, }; diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..a4c4e1b --- /dev/null +++ b/src/context.rs @@ -0,0 +1,69 @@ +use std::{future::Future, sync::Arc}; + +use promkit_widgets::spinner; +use tokio::{sync::Mutex, task::JoinHandle}; + +#[derive(PartialEq)] +/// Represent the current state of the JSON viewer, +/// which can be used to control rendering behavior +/// and manage concurrent tasks like query processing and spinner animation. +pub enum State { + /// The viewer is idle and ready for user interactions or query processing. + Idle, + /// The viewer is currently loading the JSON stream, which may involve deserialization + Loading, + /// The viewer is actively processing a jq query, which may involve executing the query + /// and updating the view with the results. + Processing, +} + +pub(crate) struct Context { + /// The current state of the processor, which can be Idle, Loading, or Processing. + pub(crate) state: State, + /// The current size of the terminal area. + /// + /// PERF NOTE: This currently lives with `state/current_task` in the same mutex + /// for simplicity. If lock contention becomes visible, this can be split into + /// a dedicated shared store (e.g. `Arc>`) to reduce lock + /// granularity. + pub(crate) area: (u16, u16), + /// The current task being executed, if any. + pub(crate) current_task: Option>, +} + +#[derive(Clone)] +pub struct SharedContext(Arc>); + +impl SharedContext { + pub fn new(area: (u16, u16)) -> Self { + Self(Arc::new(Mutex::new(Context { + state: State::Idle, + area, + current_task: None, + }))) + } + + pub async fn area(&self) -> (u16, u16) { + let ctx = self.0.lock().await; + ctx.area + } + + pub async fn set_area(&self, area: (u16, u16)) { + let mut ctx = self.0.lock().await; + ctx.area = area; + } + + pub(crate) async fn lock(&self) -> tokio::sync::MutexGuard<'_, Context> { + self.0.lock().await + } +} + +impl spinner::State for SharedContext { + fn is_idle(&self) -> impl Future + Send { + let shared = self.0.clone(); + async move { + let context = shared.lock().await; + context.state == State::Idle + } + } +} diff --git a/src/guide.rs b/src/guide.rs index 08dc524..9a0da03 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -5,7 +5,7 @@ use promkit_widgets::{ }; use tokio::{sync::mpsc, task::JoinHandle}; -use crate::{json_viewer::SharedContext, prompt::Index}; +use crate::{context::SharedContext, prompt::Index}; pub enum GuideMessage { CopiedToClipboard, diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 8237217..9424853 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -1,10 +1,9 @@ -use std::{future::Future, sync::Arc}; +use std::sync::Arc; use promkit_widgets::{ core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, jsonstream::{self, JsonStream}, serde_json::{self, Value}, - spinner, }; use tokio::{ sync::{mpsc, Mutex}, @@ -13,76 +12,12 @@ use tokio::{ use crate::{ config::{JsonConfig, JsonViewerKeybinds}, + context::{SharedContext, State}, guide::{self, GuideAction, GuideMessage}, json, prompt::Index, }; -#[derive(PartialEq)] -/// Represent the current state of the JSON viewer, -/// which can be used to control rendering behavior -/// and manage concurrent tasks like query processing and spinner animation. -pub enum State { - /// The viewer is idle and ready for user interactions or query processing. - Idle, - /// The viewer is currently loading the JSON stream, which may involve deserialization - Loading, - /// The viewer is actively processing a jq query, which may involve executing the query - /// and updating the view with the results. - Processing, -} - -struct Context { - /// The current state of the processor, which can be Idle, Loading, or Processing. - state: State, - /// The current size of the terminal area. - /// - /// PERF NOTE: This currently lives with `state/current_task` in the same mutex - /// for simplicity. If lock contention becomes visible, this can be split into - /// a dedicated shared store (e.g. `Arc>`) to reduce lock - /// granularity. - area: (u16, u16), - /// The current task being executed, if any. - current_task: Option>, -} - -#[derive(Clone)] -pub struct SharedContext(Arc>); - -impl SharedContext { - pub fn new(area: (u16, u16)) -> Self { - Self(Arc::new(Mutex::new(Context { - state: State::Idle, - area, - current_task: None, - }))) - } - - pub async fn area(&self) -> (u16, u16) { - let ctx = self.0.lock().await; - ctx.area - } - - pub async fn set_area(&self, area: (u16, u16)) { - let mut ctx = self.0.lock().await; - ctx.area = area; - } - - async fn lock(&self) -> tokio::sync::MutexGuard<'_, Context> { - self.0.lock().await - } -} - -impl spinner::State for SharedContext { - fn is_idle(&self) -> impl Future + Send { - let shared = self.0.clone(); - async move { - let context = shared.lock().await; - context.state == State::Idle - } - } -} - /// Represent the trigger for rendering views. pub enum RenderTrigger { /// User actions such as key presses diff --git a/src/main.rs b/src/main.rs index e9793b7..cbfb35f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod config; use config::Config; mod guide; mod json_viewer; +mod context; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod completion; @@ -33,7 +34,7 @@ use completion::CompletionNavigator; mod json; mod utils; -use crate::{config::DEFAULT_CONFIG, json_viewer::SharedContext, prompt::Index}; +use crate::{config::DEFAULT_CONFIG, context::SharedContext, prompt::Index}; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] diff --git a/src/prompt.rs b/src/prompt.rs index aecc0a6..8cbf690 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -22,8 +22,9 @@ use tokio::{ use crate::{ completion::{self, CompletionAction, CompletionNavigator}, config::Keybinds, + context::SharedContext, guide::{self, GuideAction, GuideMessage}, - json_viewer::{self, RenderTrigger, SharedContext, SharedJsonViewer}, + json_viewer::{self, RenderTrigger, SharedJsonViewer}, query_editor::{self, QueryEditor, QueryEditorAction}, }; diff --git a/src/query_editor.rs b/src/query_editor.rs index ab6a88c..17b72cd 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -15,8 +15,8 @@ use tokio::{ use crate::{ config::EditorKeybinds, + context::SharedContext, guide::{self, GuideAction}, - json_viewer::SharedContext, prompt::Index, }; From 4c3f5a1439f1808a8c0b05ca8797408c997a6378 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 22:56:19 +0900 Subject: [PATCH 55/74] chore: focus => index and into shared_context --- src/completion.rs | 12 ++++++- src/context.rs | 28 +++++++++++++--- src/prompt.rs | 82 ++++++++++++++------------------------------- src/query_editor.rs | 19 +++++++++-- 4 files changed, 77 insertions(+), 64 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index ceb3b7d..36eaf3d 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeSet, sync::Arc}; +use std::{collections::{BTreeSet, HashSet}, sync::Arc}; use promkit_widgets::{ core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, @@ -174,6 +174,7 @@ impl CompletionNavigator { } /// Handle a user input event to update the completion navigator's state accordingly. + /// Returns `Some(String)` if the event triggers a selection change that should update the query editor, fn handle_user_event( &mut self, event: &Event, @@ -243,6 +244,7 @@ pub fn start_completion_task( query_editor_action_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, completion_keybinds: CompletionKeybinds, + completion_trigger_keybinds: HashSet, ) -> JoinHandle> { tokio::spawn(async move { loop { @@ -278,6 +280,14 @@ pub fn start_completion_task( query_editor_action_tx .send(QueryEditorAction::ReplaceText(text)) .await?; + } else { + shared_ctx.set_active_index(Index::QueryEditor).await; + completion.clear_session_state(); + if !completion_trigger_keybinds.contains(&event) { + query_editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; + } } } CompletionAction::Leave => { diff --git a/src/context.rs b/src/context.rs index a4c4e1b..3ed5d24 100644 --- a/src/context.rs +++ b/src/context.rs @@ -3,6 +3,8 @@ use std::{future::Future, sync::Arc}; use promkit_widgets::spinner; use tokio::{sync::Mutex, task::JoinHandle}; +use crate::prompt::Index; + #[derive(PartialEq)] /// Represent the current state of the JSON viewer, /// which can be used to control rendering behavior @@ -17,18 +19,20 @@ pub enum State { Processing, } -pub(crate) struct Context { +pub struct Context { /// The current state of the processor, which can be Idle, Loading, or Processing. - pub(crate) state: State, + pub state: State, + /// Current active index for user input handling. + pub active_index: Index, /// The current size of the terminal area. /// /// PERF NOTE: This currently lives with `state/current_task` in the same mutex /// for simplicity. If lock contention becomes visible, this can be split into /// a dedicated shared store (e.g. `Arc>`) to reduce lock /// granularity. - pub(crate) area: (u16, u16), + pub area: (u16, u16), /// The current task being executed, if any. - pub(crate) current_task: Option>, + pub current_task: Option>, } #[derive(Clone)] @@ -38,6 +42,7 @@ impl SharedContext { pub fn new(area: (u16, u16)) -> Self { Self(Arc::new(Mutex::new(Context { state: State::Idle, + active_index: Index::QueryEditor, area, current_task: None, }))) @@ -53,6 +58,21 @@ impl SharedContext { ctx.area = area; } + pub async fn active_index(&self) -> Index { + let ctx = self.0.lock().await; + ctx.active_index + } + + /// Set the active index, which controls which input field is currently focused. + /// If the index is `Guide`, it will be ignored to prevent focus on the guide section. + pub async fn set_active_index(&self, index: Index) { + if index == Index::Guide { + return; + } + let mut ctx = self.0.lock().await; + ctx.active_index = index; + } + pub(crate) async fn lock(&self) -> tokio::sync::MutexGuard<'_, Context> { self.0.lock().await } diff --git a/src/prompt.rs b/src/prompt.rs index 8cbf690..b12c767 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -28,13 +28,6 @@ use crate::{ query_editor::{self, QueryEditor, QueryEditorAction}, }; -#[derive(Clone, Copy)] -enum Focus { - Editor, - Searcher, - Processor, -} - enum GlobalAction { Resize(u16, u16), Exit, @@ -43,7 +36,7 @@ enum GlobalAction { SwitchMode, } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { QueryEditor = 0, Guide = 1, @@ -75,16 +68,13 @@ pub async fn run( let (json_viewer_action_tx, json_viewer_action_rx) = mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - - let editor_keybinds = keybinds.on_editor.clone(); + let editor_completion_keybinds = keybinds.on_editor.completion.clone(); let main_task: JoinHandle> = { - let mut focus = Focus::Editor; let mut stream = EventStream::new(); let ctx = ctx.clone(); - let shared_editor = shared_editor.clone(); let editor_action_tx = editor_action_tx.clone(); - let editor_keybinds = editor_keybinds.clone(); + let completion_action_tx = completion_action_tx.clone(); let json_viewer_action_tx = json_viewer_action_tx.clone(); let guide_action_tx = guide_action_tx.clone(); tokio::spawn(async move { @@ -147,10 +137,10 @@ pub async fn run( .await?; } } - GlobalAction::SwitchMode => match focus { - Focus::Editor | Focus::Searcher => { + GlobalAction::SwitchMode => match ctx.active_index().await { + Index::QueryEditor | Index::Completion => { if ctx.is_idle().await { - focus = Focus::Processor; + ctx.set_active_index(Index::JsonViewer).await; completion_action_tx.send(CompletionAction::Leave).await?; editor_action_tx.send(QueryEditorAction::Leave).await?; execute!( @@ -162,12 +152,12 @@ pub async fn run( guide_action_tx .send(GuideAction::Show( GuideMessage::FailedToSwitchPaneWhileRenderingInProgress, - )) - .await?; + )) + .await?; } } - Focus::Processor => { - focus = Focus::Editor; + Index::JsonViewer => { + ctx.set_active_index(Index::QueryEditor).await; editor_action_tx.send(QueryEditorAction::Enter).await?; execute!( io::stdout(), @@ -175,53 +165,29 @@ pub async fn run( DisableMouseCapture, )?; } + Index::Guide => {} }, } continue; } - match focus { - Focus::Editor => { - if editor_keybinds.completion.contains(&event) { - focus = Focus::Searcher; - let prefix = { - let editor = shared_editor.read().await; - editor.text() - }; - completion_action_tx - .send(CompletionAction::Enter { prefix }) - .await?; - } else { - editor_action_tx - .send(QueryEditorAction::UserEvent(event)) - .await?; - } + match ctx.active_index().await { + Index::QueryEditor => { + editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; } - Focus::Searcher => { - if editor_keybinds.on_completion.down.contains(&event) - { - completion_action_tx - .send(CompletionAction::UserEvent(event)) - .await?; - } else if editor_keybinds.on_completion.up.contains(&event) { - completion_action_tx - .send(CompletionAction::UserEvent(event)) - .await?; - } else { - focus = Focus::Editor; - completion_action_tx.send(CompletionAction::Leave).await?; - if !editor_keybinds.completion.contains(&event) { - editor_action_tx - .send(QueryEditorAction::UserEvent(event)) - .await?; - } - } + Index::Completion => { + completion_action_tx + .send(CompletionAction::UserEvent(event)) + .await?; } - Focus::Processor => { + Index::JsonViewer => { json_viewer_action_tx .send(json_viewer::ViewerAction::UserEvent(event)) .await?; } + Index::Guide => {} } }, else => { @@ -256,6 +222,7 @@ pub async fn run( shared_renderer.clone(), shared_editor.clone(), ctx.clone(), + completion_action_tx.clone(), debounce_query_tx.clone(), guide_action_tx.clone(), ); @@ -267,7 +234,8 @@ pub async fn run( ctx.clone(), editor_action_tx.clone(), guide_action_tx.clone(), - editor_keybinds.on_completion, + keybinds.on_editor.on_completion, + editor_completion_keybinds, ); let shared_viewer_state = json_viewer_bootstrap.await?; diff --git a/src/query_editor.rs b/src/query_editor.rs index 17b72cd..f73bb28 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -14,6 +14,7 @@ use tokio::{ }; use crate::{ + completion::CompletionAction, config::EditorKeybinds, context::SharedContext, guide::{self, GuideAction}, @@ -70,7 +71,12 @@ impl QueryEditor { } /// Handle a user input event to update the query editor's state accordingly. - fn handle_user_event(&mut self, event: &Event) { + /// Returns `true` if the event triggers the completion action, otherwise `false`. + fn handle_user_event(&mut self, event: &Event) -> bool { + if self.editor_keybinds.completion.contains(event) { + return true; + } + match event { key if self.editor_keybinds.backward.contains(key) => { self.state.texteditor.backward(); @@ -127,6 +133,7 @@ impl QueryEditor { }, _ => {} } + false } } @@ -151,6 +158,7 @@ pub fn start_query_editor_task( shared_renderer: promkit_widgets::core::render::SharedRenderer, shared_editor: Arc>, shared_ctx: SharedContext, + completion_action_tx: mpsc::Sender, debounce_query_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, ) -> JoinHandle> { @@ -176,7 +184,14 @@ pub fn start_query_editor_task( editor.replace_text(&text); } QueryEditorAction::UserEvent(event) => { - editor.handle_user_event(&event); + if editor.handle_user_event(&event) { + shared_ctx.set_active_index(Index::Completion).await; + completion_action_tx + .send(CompletionAction::Enter { + prefix: editor.text(), + }) + .await?; + } } } let current_text = editor.text(); From 2bdf5afcee529d30e1b07c796e11c7846a108c5e Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 22:59:13 +0900 Subject: [PATCH 56/74] chore: rm completion_trigger_keybinds from --- src/completion.rs | 11 ++++------- src/prompt.rs | 2 -- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 36eaf3d..d8a45bb 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -1,4 +1,4 @@ -use std::{collections::{BTreeSet, HashSet}, sync::Arc}; +use std::{collections::BTreeSet, sync::Arc}; use promkit_widgets::{ core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, @@ -244,7 +244,6 @@ pub fn start_completion_task( query_editor_action_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, completion_keybinds: CompletionKeybinds, - completion_trigger_keybinds: HashSet, ) -> JoinHandle> { tokio::spawn(async move { loop { @@ -283,11 +282,9 @@ pub fn start_completion_task( } else { shared_ctx.set_active_index(Index::QueryEditor).await; completion.clear_session_state(); - if !completion_trigger_keybinds.contains(&event) { - query_editor_action_tx - .send(QueryEditorAction::UserEvent(event)) - .await?; - } + query_editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; } } CompletionAction::Leave => { diff --git a/src/prompt.rs b/src/prompt.rs index b12c767..b0cba45 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -68,7 +68,6 @@ pub async fn run( let (json_viewer_action_tx, json_viewer_action_rx) = mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - let editor_completion_keybinds = keybinds.on_editor.completion.clone(); let main_task: JoinHandle> = { let mut stream = EventStream::new(); @@ -235,7 +234,6 @@ pub async fn run( editor_action_tx.clone(), guide_action_tx.clone(), keybinds.on_editor.on_completion, - editor_completion_keybinds, ); let shared_viewer_state = json_viewer_bootstrap.await?; From 01c6224cb6286e5b41c15c30b6d2e5ffdafc037c Mon Sep 17 00:00:00 2001 From: ynqa Date: Sun, 29 Mar 2026 23:09:48 +0900 Subject: [PATCH 57/74] chore: order for arguments --- src/completion.rs | 4 ++-- src/json_viewer.rs | 18 +++++++++--------- src/main.rs | 2 +- src/prompt.rs | 16 ++++++++-------- src/query_editor.rs | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index d8a45bb..839c942 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -238,9 +238,9 @@ pub enum CompletionAction { /// Spawn a background task to manage the completion navigator's state and interactions. pub fn start_completion_task( mut action_rx: mpsc::Receiver, - shared_renderer: promkit_widgets::core::render::SharedRenderer, - shared_completion: Arc>, shared_ctx: SharedContext, + shared_completion: Arc>, + shared_renderer: promkit_widgets::core::render::SharedRenderer, query_editor_action_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, completion_keybinds: CompletionKeybinds, diff --git a/src/json_viewer.rs b/src/json_viewer.rs index 9424853..fbb335e 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -120,8 +120,8 @@ pub async fn initialize( input: &'static str, config: JsonConfig, keybinds: JsonViewerKeybinds, - shared_renderer: SharedRenderer, shared_ctx: SharedContext, + shared_renderer: SharedRenderer, ) -> anyhow::Result { // Set state to Loading to prevent overwriting by spinner frames in terminal. { @@ -165,11 +165,11 @@ pub async fn initialize( } pub async fn render( + trigger: RenderTrigger, + shared_ctx: SharedContext, shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, - shared_ctx: SharedContext, guide_action_tx: mpsc::Sender, - trigger: RenderTrigger, ) { match trigger { RenderTrigger::UserAction(event) => { @@ -347,10 +347,10 @@ pub enum ViewerAction { /// and update the viewer state and rendered view accordingly. pub fn start_viewer_task( mut action_rx: mpsc::Receiver, - guide_action_tx: mpsc::Sender, + shared_ctx: SharedContext, shared_viewer_state: SharedJsonViewer, shared_renderer: SharedRenderer, - shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, ) -> JoinHandle> { tokio::spawn(async move { loop { @@ -364,21 +364,21 @@ pub fn start_viewer_task( } ViewerAction::UserEvent(event) => { render( + RenderTrigger::UserAction(event), + shared_ctx.clone(), shared_viewer_state.clone(), shared_renderer.clone(), - shared_ctx.clone(), guide_action_tx.clone(), - RenderTrigger::UserAction(event), ) .await; } ViewerAction::QueryChanged(query) => { render( + RenderTrigger::QueryChanged { query }, + shared_ctx.clone(), shared_viewer_state.clone(), shared_renderer.clone(), - shared_ctx.clone(), guide_action_tx.clone(), - RenderTrigger::QueryChanged { query }, ) .await; } diff --git a/src/main.rs b/src/main.rs index cbfb35f..b09da79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -260,8 +260,8 @@ async fn main() -> anyhow::Result<()> { input, config.json, config.keybinds.on_json_viewer.clone(), - renderer.clone(), ctx.clone(), + renderer.clone(), ); // Spawn the spinner task, which will display a loading spinner in JSON viewer while processing is ongoing. diff --git a/src/prompt.rs b/src/prompt.rs index b0cba45..abd2daf 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -218,9 +218,9 @@ pub async fn run( let editor_task = query_editor::start_query_editor_task( editor_action_rx, - shared_renderer.clone(), - shared_editor.clone(), ctx.clone(), + shared_editor.clone(), + shared_renderer.clone(), completion_action_tx.clone(), debounce_query_tx.clone(), guide_action_tx.clone(), @@ -228,9 +228,9 @@ pub async fn run( let completion_task = completion::start_completion_task( completion_action_rx, - shared_renderer.clone(), - shared_completion.clone(), ctx.clone(), + shared_completion.clone(), + shared_renderer.clone(), editor_action_tx.clone(), guide_action_tx.clone(), keybinds.on_editor.on_completion, @@ -267,11 +267,11 @@ pub async fn run( editor.text() }; json_viewer::render( + RenderTrigger::AreaResized { query: text }, + ctx.clone(), shared_viewer_state.clone(), shared_renderer.clone(), - ctx.clone(), guide_action_tx.clone(), - RenderTrigger::AreaResized { query: text }, ) .await; } @@ -281,10 +281,10 @@ pub async fn run( let processor_task = json_viewer::start_viewer_task( json_viewer_action_rx, - guide_action_tx.clone(), + ctx.clone(), shared_viewer_state.clone(), shared_renderer.clone(), - ctx.clone(), + guide_action_tx.clone(), ); main_task.await??; diff --git a/src/query_editor.rs b/src/query_editor.rs index f73bb28..50c4eef 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -155,9 +155,9 @@ pub enum QueryEditorAction { /// Spawn a background task to manage the query editor's state and interactions. pub fn start_query_editor_task( mut action_rx: mpsc::Receiver, - shared_renderer: promkit_widgets::core::render::SharedRenderer, - shared_editor: Arc>, shared_ctx: SharedContext, + shared_editor: Arc>, + shared_renderer: promkit_widgets::core::render::SharedRenderer, completion_action_tx: mpsc::Sender, debounce_query_tx: mpsc::Sender, guide_action_tx: mpsc::Sender, From df3a55c493b93b1e58864cb4ade739dfd48e8893 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 00:34:53 +0900 Subject: [PATCH 58/74] chore: separate main_task into prompt_event_loop --- src/main.rs | 1 + src/prompt.rs | 163 +++---------------------------------- src/prompt_event_loop.rs | 171 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 150 deletions(-) create mode 100644 src/prompt_event_loop.rs diff --git a/src/main.rs b/src/main.rs index b09da79..d7eb49b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod completion; mod prompt; +mod prompt_event_loop; use completion::CompletionNavigator; mod json; mod utils; diff --git a/src/prompt.rs b/src/prompt.rs index abd2daf..2ad271b 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -1,18 +1,7 @@ -use std::{io, sync::Arc}; +use std::sync::Arc; -use futures::StreamExt; use promkit_widgets::{ - core::{ - crossterm::{ - event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, - MouseEventKind, - }, - execute, terminal, - }, - render::SharedRenderer, - }, - spinner::State, + core::render::SharedRenderer, }; use tokio::{ sync::{mpsc, RwLock}, @@ -23,19 +12,12 @@ use crate::{ completion::{self, CompletionAction, CompletionNavigator}, config::Keybinds, context::SharedContext, - guide::{self, GuideAction, GuideMessage}, + guide::{self, GuideAction}, json_viewer::{self, RenderTrigger, SharedJsonViewer}, + prompt_event_loop, query_editor::{self, QueryEditor, QueryEditorAction}, }; -enum GlobalAction { - Resize(u16, u16), - Exit, - CopyQuery, - CopyResult, - SwitchMode, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Index { QueryEditor = 0, @@ -69,134 +51,15 @@ pub async fn run( mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - let main_task: JoinHandle> = { - let mut stream = EventStream::new(); - let ctx = ctx.clone(); - let editor_action_tx = editor_action_tx.clone(); - let completion_action_tx = completion_action_tx.clone(); - let json_viewer_action_tx = json_viewer_action_tx.clone(); - let guide_action_tx = guide_action_tx.clone(); - tokio::spawn(async move { - 'main: loop { - tokio::select! { - Some(Ok(event)) = stream.next() => { - // Note: `HashSet::contains` compares full mouse events (including `column`/`row`), - // so wheel events are normalized to `(0, 0)` to match configured `ScrollUp`/`ScrollDown` bindings. - let event = match event { - Event::Mouse(mouse) - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) => - { - Event::Mouse(MouseEvent { - kind: mouse.kind, - column: 0, - row: 0, - modifiers: mouse.modifiers, - }) - } - other => other, - }; - guide_action_tx.send(GuideAction::Clear).await?; - - let global_action = if let Event::Resize(width, height) = event { - Some(GlobalAction::Resize(width, height)) - } else if keybinds.exit.contains(&event) { - Some(GlobalAction::Exit) - } else if keybinds.copy_query.contains(&event) { - Some(GlobalAction::CopyQuery) - } else if keybinds.copy_result.contains(&event) { - Some(GlobalAction::CopyResult) - } else if keybinds.switch_mode.contains(&event) { - Some(GlobalAction::SwitchMode) - } else { - None - }; - - if let Some(action) = global_action { - match action { - GlobalAction::Resize(width, height) => { - debounce_resize_tx.send((width, height)).await?; - } - GlobalAction::Exit => break 'main, - GlobalAction::CopyQuery => { - editor_action_tx.send(QueryEditorAction::CopyQuery).await?; - } - GlobalAction::CopyResult => { - if ctx.is_idle().await { - json_viewer_action_tx - .send(json_viewer::ViewerAction::CopyResult) - .await?; - } else { - guide_action_tx - .send(GuideAction::Show( - GuideMessage::FailedToCopyWhileRenderingInProgress, - )) - .await?; - } - } - GlobalAction::SwitchMode => match ctx.active_index().await { - Index::QueryEditor | Index::Completion => { - if ctx.is_idle().await { - ctx.set_active_index(Index::JsonViewer).await; - completion_action_tx.send(CompletionAction::Leave).await?; - editor_action_tx.send(QueryEditorAction::Leave).await?; - execute!( - io::stdout(), - terminal::EnterAlternateScreen, - EnableMouseCapture, - )?; - } else { - guide_action_tx - .send(GuideAction::Show( - GuideMessage::FailedToSwitchPaneWhileRenderingInProgress, - )) - .await?; - } - } - Index::JsonViewer => { - ctx.set_active_index(Index::QueryEditor).await; - editor_action_tx.send(QueryEditorAction::Enter).await?; - execute!( - io::stdout(), - terminal::LeaveAlternateScreen, - DisableMouseCapture, - )?; - } - Index::Guide => {} - }, - } - continue; - } - - match ctx.active_index().await { - Index::QueryEditor => { - editor_action_tx - .send(QueryEditorAction::UserEvent(event)) - .await?; - } - Index::Completion => { - completion_action_tx - .send(CompletionAction::UserEvent(event)) - .await?; - } - Index::JsonViewer => { - json_viewer_action_tx - .send(json_viewer::ViewerAction::UserEvent(event)) - .await?; - } - Index::Guide => {} - } - }, - else => { - break 'main; - } - } - } - Ok(()) - }) - }; + let main_task = prompt_event_loop::spawn_terminal_event_dispatch_task( + ctx.clone(), + keybinds.clone(), + debounce_resize_tx, + editor_action_tx.clone(), + completion_action_tx.clone(), + json_viewer_action_tx.clone(), + guide_action_tx.clone(), + ); let query_action_forwarder = { let json_viewer_action_tx = json_viewer_action_tx.clone(); diff --git a/src/prompt_event_loop.rs b/src/prompt_event_loop.rs new file mode 100644 index 0000000..fd71d23 --- /dev/null +++ b/src/prompt_event_loop.rs @@ -0,0 +1,171 @@ +use std::io; + +use futures::StreamExt; +use promkit_widgets::{ + core::crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, MouseEventKind, + }, + execute, terminal, + }, + spinner::State, +}; +use tokio::{ + sync::mpsc, + task::JoinHandle, +}; + +use crate::{ + completion::CompletionAction, + config::Keybinds, + context::SharedContext, + guide::{GuideAction, GuideMessage}, + json_viewer, + prompt::Index, + query_editor::QueryEditorAction, +}; + +/// Actions that can be triggered by terminal events, +/// which are dispatched to the appropriate components. +enum Action { + Resize(u16, u16), + Exit, + CopyQuery, + CopyResult, + /// Switch between query-editor/completion and JSON viewer. + SwitchMode, +} + +/// Spawn a background task to listen for terminal events and dispatch corresponding actions +/// to the appropriate components (query editor, completion navigator, JSON viewer, guide). +pub fn spawn_terminal_event_dispatch_task( + ctx: SharedContext, + keybinds: Keybinds, + debounce_resize_tx: mpsc::Sender<(u16, u16)>, + editor_action_tx: mpsc::Sender, + completion_action_tx: mpsc::Sender, + json_viewer_action_tx: mpsc::Sender, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + let mut stream = EventStream::new(); + tokio::spawn(async move { + 'main: loop { + tokio::select! { + Some(Ok(event)) = stream.next() => { + // Note: `HashSet::contains` compares full mouse events (including `column`/`row`), + // so wheel events are normalized to `(0, 0)` to match configured `ScrollUp`/`ScrollDown` bindings. + let event = match event { + Event::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) => + { + Event::Mouse(MouseEvent { + kind: mouse.kind, + column: 0, + row: 0, + modifiers: mouse.modifiers, + }) + } + other => other, + }; + guide_action_tx.send(GuideAction::Clear).await?; + + let action = if let Event::Resize(width, height) = event { + Some(Action::Resize(width, height)) + } else if keybinds.exit.contains(&event) { + Some(Action::Exit) + } else if keybinds.copy_query.contains(&event) { + Some(Action::CopyQuery) + } else if keybinds.copy_result.contains(&event) { + Some(Action::CopyResult) + } else if keybinds.switch_mode.contains(&event) { + Some(Action::SwitchMode) + } else { + None + }; + + if let Some(action) = action { + match action { + Action::Resize(width, height) => { + debounce_resize_tx.send((width, height)).await?; + } + Action::Exit => break 'main, + Action::CopyQuery => { + editor_action_tx.send(QueryEditorAction::CopyQuery).await?; + } + Action::CopyResult => { + if ctx.is_idle().await { + json_viewer_action_tx + .send(json_viewer::ViewerAction::CopyResult) + .await?; + } else { + guide_action_tx + .send(GuideAction::Show( + GuideMessage::FailedToCopyWhileRenderingInProgress, + )) + .await?; + } + } + Action::SwitchMode => match ctx.active_index().await { + Index::QueryEditor | Index::Completion => { + if ctx.is_idle().await { + ctx.set_active_index(Index::JsonViewer).await; + completion_action_tx.send(CompletionAction::Leave).await?; + editor_action_tx.send(QueryEditorAction::Leave).await?; + execute!( + io::stdout(), + terminal::EnterAlternateScreen, + EnableMouseCapture, + )?; + } else { + guide_action_tx + .send(GuideAction::Show( + GuideMessage::FailedToSwitchPaneWhileRenderingInProgress, + )) + .await?; + } + } + Index::JsonViewer => { + ctx.set_active_index(Index::QueryEditor).await; + editor_action_tx.send(QueryEditorAction::Enter).await?; + execute!( + io::stdout(), + terminal::LeaveAlternateScreen, + DisableMouseCapture, + )?; + } + Index::Guide => {} + }, + } + continue; + } + + match ctx.active_index().await { + Index::QueryEditor => { + editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; + } + Index::Completion => { + completion_action_tx + .send(CompletionAction::UserEvent(event)) + .await?; + } + Index::JsonViewer => { + json_viewer_action_tx + .send(json_viewer::ViewerAction::UserEvent(event)) + .await?; + } + Index::Guide => {} + } + }, + else => { + break 'main; + } + } + } + Ok(()) + }) +} From c78b2ed24de408913c28ec62bd50b1787e134801 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 00:56:55 +0900 Subject: [PATCH 59/74] chore: prompt_event_loop => event_dispatcher --- src/{prompt_event_loop.rs => event_dispatcher.rs} | 0 src/main.rs | 2 +- src/prompt.rs | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/{prompt_event_loop.rs => event_dispatcher.rs} (100%) diff --git a/src/prompt_event_loop.rs b/src/event_dispatcher.rs similarity index 100% rename from src/prompt_event_loop.rs rename to src/event_dispatcher.rs diff --git a/src/main.rs b/src/main.rs index d7eb49b..504f38a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod completion; mod prompt; -mod prompt_event_loop; +mod event_dispatcher; use completion::CompletionNavigator; mod json; mod utils; diff --git a/src/prompt.rs b/src/prompt.rs index 2ad271b..8e6468a 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -12,9 +12,9 @@ use crate::{ completion::{self, CompletionAction, CompletionNavigator}, config::Keybinds, context::SharedContext, + event_dispatcher, guide::{self, GuideAction}, json_viewer::{self, RenderTrigger, SharedJsonViewer}, - prompt_event_loop, query_editor::{self, QueryEditor, QueryEditorAction}, }; @@ -51,7 +51,7 @@ pub async fn run( mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - let main_task = prompt_event_loop::spawn_terminal_event_dispatch_task( + let main_task = event_dispatcher::spawn_terminal_event_dispatch_task( ctx.clone(), keybinds.clone(), debounce_resize_tx, From 0aae7d64d91a54710385630f950022f9f13ca280 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 00:57:41 +0900 Subject: [PATCH 60/74] chore: action channels in main.rs --- src/completion.rs | 4 ++-- src/event_dispatcher.rs | 5 +---- src/main.rs | 29 ++++++++++++++++++++++++----- src/prompt.rs | 18 +++++++++--------- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 839c942..41294eb 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -51,7 +51,7 @@ impl SharedSuggestionStore { /// Spawn a background loader and return shared suggestion store with task handle. pub fn spawn_initialize( - item: &'static str, + input: &'static str, max_streams: Option, chunk_size: usize, ) -> (SharedSuggestionStore, JoinHandle<()>) { @@ -63,7 +63,7 @@ pub fn spawn_initialize( let shared_for_loading = shared.clone(); let loader_task = task::spawn(async move { // Load paths in a streaming manner and update the shared store incrementally - let iter = match json::get_all_paths(item, max_streams).await { + let iter = match json::get_all_paths(input, max_streams).await { Ok(iter) => iter, Err(_) => { let mut store = shared_for_loading.0.lock().await; diff --git a/src/event_dispatcher.rs b/src/event_dispatcher.rs index fd71d23..696a542 100644 --- a/src/event_dispatcher.rs +++ b/src/event_dispatcher.rs @@ -10,10 +10,7 @@ use promkit_widgets::{ }, spinner::State, }; -use tokio::{ - sync::mpsc, - task::JoinHandle, -}; +use tokio::{sync::mpsc, task::JoinHandle}; use crate::{ completion::CompletionAction, diff --git a/src/main.rs b/src/main.rs index 504f38a..58c149e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,25 +17,28 @@ use promkit_widgets::{ spinner::{self, Spinner}, text_editor::{self, TextEditor}, }; -use tokio::sync::RwLock; +use tokio::sync::{mpsc, RwLock}; mod query_editor; use query_editor::QueryEditor; mod config; use config::Config; +mod context; mod guide; mod json_viewer; -mod context; mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod completion; -mod prompt; mod event_dispatcher; +mod prompt; use completion::CompletionNavigator; mod json; mod utils; -use crate::{config::DEFAULT_CONFIG, context::SharedContext, prompt::Index}; +use crate::{ + completion::CompletionAction, config::DEFAULT_CONFIG, context::SharedContext, + guide::GuideAction, prompt::Index, query_editor::QueryEditorAction, +}; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] @@ -196,7 +199,7 @@ async fn main() -> anyhow::Result<()> { // Spawn the completion loader task, which will asynchronously load suggestions based on the input data. let (shared_suggestions, completion_loader_task) = completion::spawn_initialize( - &input, + input, config.json.max_streams, config.completion.search_load_chunk_size, ); @@ -295,6 +298,14 @@ async fn main() -> anyhow::Result<()> { let (debounce_resize_tx, last_resize_rx, resize_debouncer) = utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); + // Create channels for communication between the main event loop and various components + // (query editor, completion navigator, JSON viewer, and guide). + let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); + let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); + let (json_viewer_action_tx, json_viewer_action_rx) = + mpsc::channel::(8); + let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); + // TODO: put all logics here. let maybe_output = prompt::run( ctx, @@ -313,6 +324,14 @@ async fn main() -> anyhow::Result<()> { completion_loader_task, spinner_task, load_for_json_viewer, + editor_action_tx, + editor_action_rx, + completion_action_tx, + completion_action_rx, + json_viewer_action_tx, + json_viewer_action_rx, + guide_action_tx, + guide_action_rx, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 8e6468a..545a215 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -1,8 +1,6 @@ use std::sync::Arc; -use promkit_widgets::{ - core::render::SharedRenderer, -}; +use promkit_widgets::core::render::SharedRenderer; use tokio::{ sync::{mpsc, RwLock}, task::JoinHandle, @@ -44,13 +42,15 @@ pub async fn run( completion_loader_task: JoinHandle<()>, spinning: JoinHandle<()>, json_viewer_bootstrap: impl std::future::Future>, + editor_action_tx: mpsc::Sender, + editor_action_rx: mpsc::Receiver, + completion_action_tx: mpsc::Sender, + completion_action_rx: mpsc::Receiver, + json_viewer_action_tx: mpsc::Sender, + json_viewer_action_rx: mpsc::Receiver, + guide_action_tx: mpsc::Sender, + guide_action_rx: mpsc::Receiver, ) -> anyhow::Result> { - let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); - let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); - let (json_viewer_action_tx, json_viewer_action_rx) = - mpsc::channel::(8); - let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); - let main_task = event_dispatcher::spawn_terminal_event_dispatch_task( ctx.clone(), keybinds.clone(), From d7cfe7855e4ea79b552057c2d0f0b75860d57d3e Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 01:15:55 +0900 Subject: [PATCH 61/74] chore: event_dispacher_task in main.rs --- src/main.rs | 14 +++++++++++++- src/prompt.rs | 13 +------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 58c149e..3962e8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -306,6 +306,18 @@ async fn main() -> anyhow::Result<()> { mpsc::channel::(8); let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); + // Spawn the terminal event dispatcher task, which will listen for user input and terminal events, + // and forward them to the appropriate channels for handling by the main event loop and components. + let event_dispacher_task = event_dispatcher::spawn_terminal_event_dispatch_task( + ctx.clone(), + config.keybinds.clone(), + debounce_resize_tx, + editor_action_tx.clone(), + completion_action_tx.clone(), + json_viewer_action_tx.clone(), + guide_action_tx.clone(), + ); + // TODO: put all logics here. let maybe_output = prompt::run( ctx, @@ -318,7 +330,6 @@ async fn main() -> anyhow::Result<()> { debounce_query_tx, last_query_rx, query_debouncer, - debounce_resize_tx, last_resize_rx, resize_debouncer, completion_loader_task, @@ -332,6 +343,7 @@ async fn main() -> anyhow::Result<()> { json_viewer_action_rx, guide_action_tx, guide_action_rx, + event_dispacher_task, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 545a215..5919939 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -10,7 +10,6 @@ use crate::{ completion::{self, CompletionAction, CompletionNavigator}, config::Keybinds, context::SharedContext, - event_dispatcher, guide::{self, GuideAction}, json_viewer::{self, RenderTrigger, SharedJsonViewer}, query_editor::{self, QueryEditor, QueryEditorAction}, @@ -36,7 +35,6 @@ pub async fn run( debounce_query_tx: mpsc::Sender, mut last_query_rx: mpsc::Receiver, query_debouncer: JoinHandle<()>, - debounce_resize_tx: mpsc::Sender<(u16, u16)>, mut last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, completion_loader_task: JoinHandle<()>, @@ -50,17 +48,8 @@ pub async fn run( json_viewer_action_rx: mpsc::Receiver, guide_action_tx: mpsc::Sender, guide_action_rx: mpsc::Receiver, + main_task: JoinHandle>, ) -> anyhow::Result> { - let main_task = event_dispatcher::spawn_terminal_event_dispatch_task( - ctx.clone(), - keybinds.clone(), - debounce_resize_tx, - editor_action_tx.clone(), - completion_action_tx.clone(), - json_viewer_action_tx.clone(), - guide_action_tx.clone(), - ); - let query_action_forwarder = { let json_viewer_action_tx = json_viewer_action_tx.clone(); tokio::spawn(async move { From 7b5895d44cdcef81115914940a30161e7c784203 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 01:22:50 +0900 Subject: [PATCH 62/74] chore: query_change_forward_task in main.rs --- src/main.rs | 17 +++++++++++++++-- src/prompt.rs | 12 +----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3962e8c..1a4e652 100644 --- a/src/main.rs +++ b/src/main.rs @@ -284,7 +284,7 @@ async fn main() -> anyhow::Result<()> { // Set up the debouncer for the query editor input, which will manage the timing of query updates // to prevent excessive processing while the user is typing. - let (debounce_query_tx, last_query_rx, query_debouncer) = + let (debounce_query_tx, mut last_query_rx, query_debouncer) = utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); // If a default filter is provided via command-line arguments, send it to the query debouncer @@ -318,6 +318,19 @@ async fn main() -> anyhow::Result<()> { guide_action_tx.clone(), ); + // Spawn a task to forward query changes from the debouncer to the JSON viewer, ensuring that + // the viewer updates in response to user input in the query editor. + let query_change_forward_task = { + let json_viewer_action_tx = json_viewer_action_tx.clone(); + tokio::spawn(async move { + while let Some(query) = last_query_rx.recv().await { + let _ = json_viewer_action_tx + .send(json_viewer::ViewerAction::QueryChanged(query)) + .await; + } + }) + }; + // TODO: put all logics here. let maybe_output = prompt::run( ctx, @@ -328,7 +341,6 @@ async fn main() -> anyhow::Result<()> { config.keybinds, args.write_to_stdout, debounce_query_tx, - last_query_rx, query_debouncer, last_resize_rx, resize_debouncer, @@ -344,6 +356,7 @@ async fn main() -> anyhow::Result<()> { guide_action_tx, guide_action_rx, event_dispacher_task, + query_change_forward_task, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index 5919939..d153c75 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -33,7 +33,6 @@ pub async fn run( keybinds: Keybinds, write_to_stdout: bool, debounce_query_tx: mpsc::Sender, - mut last_query_rx: mpsc::Receiver, query_debouncer: JoinHandle<()>, mut last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, @@ -49,17 +48,8 @@ pub async fn run( guide_action_tx: mpsc::Sender, guide_action_rx: mpsc::Receiver, main_task: JoinHandle>, + query_action_forwarder: JoinHandle<()>, ) -> anyhow::Result> { - let query_action_forwarder = { - let json_viewer_action_tx = json_viewer_action_tx.clone(); - tokio::spawn(async move { - while let Some(query) = last_query_rx.recv().await { - let _ = json_viewer_action_tx - .send(json_viewer::ViewerAction::QueryChanged(query)) - .await; - } - }) - }; let guide_task = guide::start_guide_task( guide_action_rx, From 4ce007a71071be646f41ffc9c283c03676d6e8d3 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 01:43:43 +0900 Subject: [PATCH 63/74] chore: spawn each viewer task in main.rs --- src/main.rs | 65 ++++++++++++++++++++++++++++++++++++++++----------- src/prompt.rs | 62 +++++++++--------------------------------------- 2 files changed, 63 insertions(+), 64 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1a4e652..e8fe5b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -278,10 +278,6 @@ async fn main() -> anyhow::Result<()> { } }); - // Wrap the query editor and completion navigator in Arc> to allow shared mutable access across async tasks. - let shared_query_editor = Arc::new(RwLock::new(query_editor)); - let shared_completion_navigator = Arc::new(RwLock::new(completion_navigator)); - // Set up the debouncer for the query editor input, which will manage the timing of query updates // to prevent excessive processing while the user is typing. let (debounce_query_tx, mut last_query_rx, query_debouncer) = @@ -331,12 +327,59 @@ async fn main() -> anyhow::Result<()> { }) }; + // Spawn the guide task, which will manage the display of hints and guidance + // to the user based on their interactions with the interface. + let guide_task = guide::start_guide_task( + guide_action_rx, + renderer.clone(), + ctx.clone(), + config.no_hint, + ); + + // Wrap the query editor and completion navigator in Arc> to allow shared mutable access across async tasks. + let shared_query_editor = Arc::new(RwLock::new(query_editor)); + let shared_completion_navigator = Arc::new(RwLock::new(completion_navigator)); + + // Spawn the query editor task, which will handle user input in the query editor and update the interface accordingly. + let query_editor_task = query_editor::start_query_editor_task( + editor_action_rx, + ctx.clone(), + shared_query_editor.clone(), + renderer.clone(), + completion_action_tx.clone(), + debounce_query_tx.clone(), + guide_action_tx.clone(), + ); + + // Spawn the completion task, which will handle user input in the completion navigator and update the interface accordingly. + let completion_navigator_task = completion::start_completion_task( + completion_action_rx, + ctx.clone(), + shared_completion_navigator.clone(), + renderer.clone(), + editor_action_tx.clone(), + guide_action_tx.clone(), + config.keybinds.on_editor.on_completion.clone(), + ); + + // Await JSON viewer bootstrap to complete, which will initialize the viewer with the input data and configuration. + let shared_json_viewer = load_for_json_viewer.await?; + // Spawn the JSON viewer processor task, which will handle updates to the JSON viewer based on user input and query changes. + let json_viewer_task = json_viewer::start_viewer_task( + json_viewer_action_rx, + ctx.clone(), + shared_json_viewer.clone(), + renderer.clone(), + guide_action_tx.clone(), + ); + // TODO: put all logics here. let maybe_output = prompt::run( ctx, renderer, shared_query_editor, shared_completion_navigator, + shared_json_viewer.clone(), config.no_hint, config.keybinds, args.write_to_stdout, @@ -346,17 +389,13 @@ async fn main() -> anyhow::Result<()> { resize_debouncer, completion_loader_task, spinner_task, - load_for_json_viewer, - editor_action_tx, - editor_action_rx, - completion_action_tx, - completion_action_rx, - json_viewer_action_tx, - json_viewer_action_rx, - guide_action_tx, - guide_action_rx, + guide_action_tx.clone(), event_dispacher_task, query_change_forward_task, + guide_task, + query_editor_task, + completion_navigator_task, + json_viewer_task, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index d153c75..b75850f 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -7,12 +7,12 @@ use tokio::{ }; use crate::{ - completion::{self, CompletionAction, CompletionNavigator}, + completion::CompletionNavigator, config::Keybinds, context::SharedContext, - guide::{self, GuideAction}, + guide::GuideAction, json_viewer::{self, RenderTrigger, SharedJsonViewer}, - query_editor::{self, QueryEditor, QueryEditorAction}, + query_editor::QueryEditor, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -29,56 +29,24 @@ pub async fn run( shared_renderer: SharedRenderer, shared_editor: Arc>, shared_completion: Arc>, - no_hint: bool, - keybinds: Keybinds, + shared_viewer_state: SharedJsonViewer, + _no_hint: bool, + _keybinds: Keybinds, write_to_stdout: bool, - debounce_query_tx: mpsc::Sender, + _debounce_query_tx: mpsc::Sender, query_debouncer: JoinHandle<()>, mut last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, completion_loader_task: JoinHandle<()>, spinning: JoinHandle<()>, - json_viewer_bootstrap: impl std::future::Future>, - editor_action_tx: mpsc::Sender, - editor_action_rx: mpsc::Receiver, - completion_action_tx: mpsc::Sender, - completion_action_rx: mpsc::Receiver, - json_viewer_action_tx: mpsc::Sender, - json_viewer_action_rx: mpsc::Receiver, guide_action_tx: mpsc::Sender, - guide_action_rx: mpsc::Receiver, main_task: JoinHandle>, query_action_forwarder: JoinHandle<()>, + guide_task: JoinHandle>, + editor_task: JoinHandle>, + completion_task: JoinHandle>, + processor_task: JoinHandle>, ) -> anyhow::Result> { - - let guide_task = guide::start_guide_task( - guide_action_rx, - shared_renderer.clone(), - ctx.clone(), - no_hint, - ); - - let editor_task = query_editor::start_query_editor_task( - editor_action_rx, - ctx.clone(), - shared_editor.clone(), - shared_renderer.clone(), - completion_action_tx.clone(), - debounce_query_tx.clone(), - guide_action_tx.clone(), - ); - - let completion_task = completion::start_completion_task( - completion_action_rx, - ctx.clone(), - shared_completion.clone(), - shared_renderer.clone(), - editor_action_tx.clone(), - guide_action_tx.clone(), - keybinds.on_editor.on_completion, - ); - - let shared_viewer_state = json_viewer_bootstrap.await?; let resize_action_forwarder = { let shared_renderer = shared_renderer.clone(); let shared_editor = shared_editor.clone(); @@ -121,14 +89,6 @@ pub async fn run( }) }; - let processor_task = json_viewer::start_viewer_task( - json_viewer_action_rx, - ctx.clone(), - shared_viewer_state.clone(), - shared_renderer.clone(), - guide_action_tx.clone(), - ); - main_task.await??; let output = if write_to_stdout { From 6f4a745e38301004c3acf4768a368dff27328eb5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 01:54:32 +0900 Subject: [PATCH 64/74] chore: runtime_tasks about last_query_rx and last_resize_rx related tasks --- src/main.rs | 17 ++++------ src/prompt.rs | 62 ++++++++---------------------------- src/runtime_tasks.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 60 deletions(-) create mode 100644 src/runtime_tasks.rs diff --git a/src/main.rs b/src/main.rs index e8fe5b3..b2ce467 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ use stdout_redirect::StdoutRedirect; mod completion; mod event_dispatcher; mod prompt; +mod runtime_tasks; use completion::CompletionNavigator; mod json; mod utils; @@ -280,7 +281,7 @@ async fn main() -> anyhow::Result<()> { // Set up the debouncer for the query editor input, which will manage the timing of query updates // to prevent excessive processing while the user is typing. - let (debounce_query_tx, mut last_query_rx, query_debouncer) = + let (debounce_query_tx, last_query_rx, query_debouncer) = utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); // If a default filter is provided via command-line arguments, send it to the query debouncer @@ -316,16 +317,10 @@ async fn main() -> anyhow::Result<()> { // Spawn a task to forward query changes from the debouncer to the JSON viewer, ensuring that // the viewer updates in response to user input in the query editor. - let query_change_forward_task = { - let json_viewer_action_tx = json_viewer_action_tx.clone(); - tokio::spawn(async move { - while let Some(query) = last_query_rx.recv().await { - let _ = json_viewer_action_tx - .send(json_viewer::ViewerAction::QueryChanged(query)) - .await; - } - }) - }; + let query_change_forward_task = runtime_tasks::spawn_query_change_forward_task( + last_query_rx, + json_viewer_action_tx.clone(), + ); // Spawn the guide task, which will manage the display of hints and guidance // to the user based on their interactions with the interface. diff --git a/src/prompt.rs b/src/prompt.rs index b75850f..76f706c 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -7,12 +7,8 @@ use tokio::{ }; use crate::{ - completion::CompletionNavigator, - config::Keybinds, - context::SharedContext, - guide::GuideAction, - json_viewer::{self, RenderTrigger, SharedJsonViewer}, - query_editor::QueryEditor, + completion::CompletionNavigator, config::Keybinds, context::SharedContext, guide::GuideAction, + json_viewer::SharedJsonViewer, query_editor::QueryEditor, runtime_tasks, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -35,7 +31,7 @@ pub async fn run( write_to_stdout: bool, _debounce_query_tx: mpsc::Sender, query_debouncer: JoinHandle<()>, - mut last_resize_rx: mpsc::Receiver<(u16, u16)>, + last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, completion_loader_task: JoinHandle<()>, spinning: JoinHandle<()>, @@ -47,47 +43,15 @@ pub async fn run( completion_task: JoinHandle>, processor_task: JoinHandle>, ) -> anyhow::Result> { - let resize_action_forwarder = { - let shared_renderer = shared_renderer.clone(); - let shared_editor = shared_editor.clone(); - let shared_completion = shared_completion.clone(); - let shared_viewer_state = shared_viewer_state.clone(); - let ctx = ctx.clone(); - let guide_action_tx = guide_action_tx.clone(); - tokio::spawn(async move { - while let Some(area) = last_resize_rx.recv().await { - ctx.set_area(area).await; - let (editor_pane, completion_pane) = { - let editor = shared_editor.read().await; - let completion = shared_completion.read().await; - ( - editor.create_graphemes(area.0, area.1), - completion.create_graphemes(area.0, area.1), - ) - }; - shared_renderer - .update([ - (Index::QueryEditor, editor_pane), - (Index::Completion, completion_pane), - ]) - .render() - .await?; - let text = { - let editor = shared_editor.read().await; - editor.text() - }; - json_viewer::render( - RenderTrigger::AreaResized { query: text }, - ctx.clone(), - shared_viewer_state.clone(), - shared_renderer.clone(), - guide_action_tx.clone(), - ) - .await; - } - Ok::<(), anyhow::Error>(()) - }) - }; + let resize_render_task = runtime_tasks::spawn_resize_render_task( + last_resize_rx, + ctx.clone(), + shared_renderer.clone(), + shared_editor.clone(), + shared_completion.clone(), + shared_viewer_state.clone(), + guide_action_tx.clone(), + ); main_task.await??; @@ -103,7 +67,7 @@ pub async fn run( resize_debouncer.abort(); completion_loader_task.abort(); query_action_forwarder.abort(); - resize_action_forwarder.abort(); + resize_render_task.abort(); guide_task.abort(); editor_task.abort(); completion_task.abort(); diff --git a/src/runtime_tasks.rs b/src/runtime_tasks.rs new file mode 100644 index 0000000..5b57ef6 --- /dev/null +++ b/src/runtime_tasks.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use promkit_widgets::core::render::SharedRenderer; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; + +use crate::{ + completion::CompletionNavigator, + context::SharedContext, + guide::GuideAction, + json_viewer::{self, RenderTrigger, SharedJsonViewer}, + prompt::Index, + query_editor::QueryEditor, +}; + +/// Spawns a task that listens for query changes and forwards them to the JSON viewer action channel. +pub fn spawn_query_change_forward_task( + mut last_query_rx: mpsc::Receiver, + json_viewer_action_tx: mpsc::Sender, +) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(query) = last_query_rx.recv().await { + let _ = json_viewer_action_tx + .send(json_viewer::ViewerAction::QueryChanged(query)) + .await; + } + }) +} + +/// Spawns a task that listens for terminal resize events and triggers re-rendering of the UI components accordingly. +#[allow(clippy::too_many_arguments)] +pub fn spawn_resize_render_task( + mut last_resize_rx: mpsc::Receiver<(u16, u16)>, + ctx: SharedContext, + shared_renderer: SharedRenderer, + shared_editor: Arc>, + shared_completion: Arc>, + shared_viewer_state: SharedJsonViewer, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + tokio::spawn(async move { + while let Some(area) = last_resize_rx.recv().await { + ctx.set_area(area).await; + let (editor_pane, completion_pane) = { + let editor = shared_editor.read().await; + let completion = shared_completion.read().await; + ( + editor.create_graphemes(area.0, area.1), + completion.create_graphemes(area.0, area.1), + ) + }; + shared_renderer + .update([ + (Index::QueryEditor, editor_pane), + (Index::Completion, completion_pane), + ]) + .render() + .await?; + let text = { + let editor = shared_editor.read().await; + editor.text() + }; + json_viewer::render( + RenderTrigger::AreaResized { query: text }, + ctx.clone(), + shared_viewer_state.clone(), + shared_renderer.clone(), + guide_action_tx.clone(), + ) + .await; + } + Ok::<(), anyhow::Error>(()) + }) +} From 0b57bffd08e0638ce91bd0c0a277868a9668c94e Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 01:59:32 +0900 Subject: [PATCH 65/74] chore: move Index into context.rs --- src/completion.rs | 3 +-- src/context.rs | 9 ++++++++- src/event_dispatcher.rs | 3 +-- src/guide.rs | 2 +- src/json_viewer.rs | 3 +-- src/main.rs | 7 +++++-- src/prompt.rs | 17 +++++++---------- src/query_editor.rs | 3 +-- src/runtime_tasks.rs | 3 +-- 9 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 41294eb..027e38a 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -11,10 +11,9 @@ use tokio::{ use crate::{ config::CompletionKeybinds, - context::SharedContext, + context::{Index, SharedContext}, guide::{GuideAction, GuideMessage}, json, - prompt::Index, query_editor::QueryEditorAction, }; diff --git a/src/context.rs b/src/context.rs index 3ed5d24..0b8bcc7 100644 --- a/src/context.rs +++ b/src/context.rs @@ -3,7 +3,14 @@ use std::{future::Future, sync::Arc}; use promkit_widgets::spinner; use tokio::{sync::Mutex, task::JoinHandle}; -use crate::prompt::Index; +/// Represent the different sections of the UI, which can be used to manage focus and input handling. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Index { + QueryEditor = 0, + Guide = 1, + Completion = 2, + JsonViewer = 3, +} #[derive(PartialEq)] /// Represent the current state of the JSON viewer, diff --git a/src/event_dispatcher.rs b/src/event_dispatcher.rs index 696a542..3de2e72 100644 --- a/src/event_dispatcher.rs +++ b/src/event_dispatcher.rs @@ -15,10 +15,9 @@ use tokio::{sync::mpsc, task::JoinHandle}; use crate::{ completion::CompletionAction, config::Keybinds, - context::SharedContext, + context::{Index, SharedContext}, guide::{GuideAction, GuideMessage}, json_viewer, - prompt::Index, query_editor::QueryEditorAction, }; diff --git a/src/guide.rs b/src/guide.rs index 9a0da03..ca00221 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -5,7 +5,7 @@ use promkit_widgets::{ }; use tokio::{sync::mpsc, task::JoinHandle}; -use crate::{context::SharedContext, prompt::Index}; +use crate::context::{Index, SharedContext}; pub enum GuideMessage { CopiedToClipboard, diff --git a/src/json_viewer.rs b/src/json_viewer.rs index fbb335e..be1fcf5 100644 --- a/src/json_viewer.rs +++ b/src/json_viewer.rs @@ -12,10 +12,9 @@ use tokio::{ use crate::{ config::{JsonConfig, JsonViewerKeybinds}, - context::{SharedContext, State}, + context::{Index, SharedContext, State}, guide::{self, GuideAction, GuideMessage}, json, - prompt::Index, }; /// Represent the trigger for rendering views. diff --git a/src/main.rs b/src/main.rs index b2ce467..0b1e55d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,8 +37,11 @@ mod json; mod utils; use crate::{ - completion::CompletionAction, config::DEFAULT_CONFIG, context::SharedContext, - guide::GuideAction, prompt::Index, query_editor::QueryEditorAction, + completion::CompletionAction, + config::DEFAULT_CONFIG, + context::{Index, SharedContext}, + guide::GuideAction, + query_editor::QueryEditorAction, }; /// JSON navigator and interactive filter leveraging jq diff --git a/src/prompt.rs b/src/prompt.rs index 76f706c..f3336e8 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -7,18 +7,15 @@ use tokio::{ }; use crate::{ - completion::CompletionNavigator, config::Keybinds, context::SharedContext, guide::GuideAction, - json_viewer::SharedJsonViewer, query_editor::QueryEditor, runtime_tasks, + completion::CompletionNavigator, + config::Keybinds, + context::{Index, SharedContext}, + guide::GuideAction, + json_viewer::SharedJsonViewer, + query_editor::QueryEditor, + runtime_tasks, }; -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Index { - QueryEditor = 0, - Guide = 1, - Completion = 2, - JsonViewer = 3, -} - #[allow(clippy::too_many_arguments)] pub async fn run( ctx: SharedContext, diff --git a/src/query_editor.rs b/src/query_editor.rs index 50c4eef..95acaa3 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -16,9 +16,8 @@ use tokio::{ use crate::{ completion::CompletionAction, config::EditorKeybinds, - context::SharedContext, + context::{Index, SharedContext}, guide::{self, GuideAction}, - prompt::Index, }; /// Editor for inputting jq query. It manages the state of the text editor diff --git a/src/runtime_tasks.rs b/src/runtime_tasks.rs index 5b57ef6..2425445 100644 --- a/src/runtime_tasks.rs +++ b/src/runtime_tasks.rs @@ -8,10 +8,9 @@ use tokio::{ use crate::{ completion::CompletionNavigator, - context::SharedContext, + context::{Index, SharedContext}, guide::GuideAction, json_viewer::{self, RenderTrigger, SharedJsonViewer}, - prompt::Index, query_editor::QueryEditor, }; From 9657639c9a90051132224361202d657af71459d8 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 02:06:19 +0900 Subject: [PATCH 66/74] chore: resize_render_task in main.rs --- src/main.rs | 14 ++++++++++++-- src/prompt.rs | 23 +++++------------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0b1e55d..6878d76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -371,6 +371,17 @@ async fn main() -> anyhow::Result<()> { guide_action_tx.clone(), ); + // Spawn the resize render task, which will listen for terminal resize events and trigger re-rendering of the UI components accordingly. + let resize_render_task = runtime_tasks::spawn_resize_render_task( + last_resize_rx, + ctx.clone(), + renderer.clone(), + shared_query_editor.clone(), + shared_completion_navigator.clone(), + shared_json_viewer.clone(), + guide_action_tx.clone(), + ); + // TODO: put all logics here. let maybe_output = prompt::run( ctx, @@ -383,17 +394,16 @@ async fn main() -> anyhow::Result<()> { args.write_to_stdout, debounce_query_tx, query_debouncer, - last_resize_rx, resize_debouncer, completion_loader_task, spinner_task, - guide_action_tx.clone(), event_dispacher_task, query_change_forward_task, guide_task, query_editor_task, completion_navigator_task, json_viewer_task, + resize_render_task, ) .await; diff --git a/src/prompt.rs b/src/prompt.rs index f3336e8..d6a0ba6 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -10,46 +10,33 @@ use crate::{ completion::CompletionNavigator, config::Keybinds, context::{Index, SharedContext}, - guide::GuideAction, json_viewer::SharedJsonViewer, query_editor::QueryEditor, - runtime_tasks, }; #[allow(clippy::too_many_arguments)] pub async fn run( - ctx: SharedContext, - shared_renderer: SharedRenderer, - shared_editor: Arc>, - shared_completion: Arc>, + _ctx: SharedContext, + _shared_renderer: SharedRenderer, + _shared_editor: Arc>, + _shared_completion: Arc>, shared_viewer_state: SharedJsonViewer, _no_hint: bool, _keybinds: Keybinds, write_to_stdout: bool, _debounce_query_tx: mpsc::Sender, query_debouncer: JoinHandle<()>, - last_resize_rx: mpsc::Receiver<(u16, u16)>, resize_debouncer: JoinHandle<()>, completion_loader_task: JoinHandle<()>, spinning: JoinHandle<()>, - guide_action_tx: mpsc::Sender, main_task: JoinHandle>, query_action_forwarder: JoinHandle<()>, guide_task: JoinHandle>, editor_task: JoinHandle>, completion_task: JoinHandle>, processor_task: JoinHandle>, + resize_render_task: JoinHandle>, ) -> anyhow::Result> { - let resize_render_task = runtime_tasks::spawn_resize_render_task( - last_resize_rx, - ctx.clone(), - shared_renderer.clone(), - shared_editor.clone(), - shared_completion.clone(), - shared_viewer_state.clone(), - guide_action_tx.clone(), - ); - main_task.await??; let output = if write_to_stdout { From a94fdb38d84d0e061cb7c16ce52457dcb2faea9b Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 02:23:46 +0900 Subject: [PATCH 67/74] chore: remove prompt.rs --- src/main.rs | 54 ++++++++++++++++++++++----------------------- src/prompt.rs | 61 --------------------------------------------------- 2 files changed, 26 insertions(+), 89 deletions(-) delete mode 100644 src/prompt.rs diff --git a/src/main.rs b/src/main.rs index 6878d76..99f41fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,6 @@ mod stdout_redirect; use stdout_redirect::StdoutRedirect; mod completion; mod event_dispatcher; -mod prompt; mod runtime_tasks; use completion::CompletionNavigator; mod json; @@ -382,35 +381,34 @@ async fn main() -> anyhow::Result<()> { guide_action_tx.clone(), ); - // TODO: put all logics here. - let maybe_output = prompt::run( - ctx, - renderer, - shared_query_editor, - shared_completion_navigator, - shared_json_viewer.clone(), - config.no_hint, - config.keybinds, - args.write_to_stdout, - debounce_query_tx, - query_debouncer, - resize_debouncer, - completion_loader_task, - spinner_task, - event_dispacher_task, - query_change_forward_task, - guide_task, - query_editor_task, - completion_navigator_task, - json_viewer_task, - resize_render_task, - ) - .await; - + let maybe_output_result: anyhow::Result> = match event_dispacher_task.await { + Ok(Ok(())) if args.write_to_stdout => { + let runtime = shared_json_viewer.lock().await; + Ok(Some(runtime.formatted_content())) + } + Ok(Ok(())) => Ok(None), + // `event_dispacher_task` itself joined successfully, but the task body returned an application error. + Ok(Err(err)) => Err(err), + // The join operation failed (e.g. panic/cancel) before the task body could return its own result. + Err(err) => Err(err.into()), + }; + + spinner_task.abort(); + query_debouncer.abort(); + resize_debouncer.abort(); + completion_loader_task.abort(); + query_change_forward_task.abort(); + resize_render_task.abort(); + guide_task.abort(); + query_editor_task.abort(); + completion_navigator_task.abort(); + json_viewer_task.abort(); + + // Restore terminal state and write output to stdout if the option is enabled. stdout_redirect.restore()?; - let maybe_output = maybe_output?; - if let Some(output) = maybe_output { + // If the user has enabled the option to write the current JSON result to stdout on exit, output it now. + if let Some(output) = maybe_output_result? { let mut stdout = io::stdout(); stdout.write_all(output.as_bytes())?; if !output.ends_with('\n') { diff --git a/src/prompt.rs b/src/prompt.rs deleted file mode 100644 index d6a0ba6..0000000 --- a/src/prompt.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::sync::Arc; - -use promkit_widgets::core::render::SharedRenderer; -use tokio::{ - sync::{mpsc, RwLock}, - task::JoinHandle, -}; - -use crate::{ - completion::CompletionNavigator, - config::Keybinds, - context::{Index, SharedContext}, - json_viewer::SharedJsonViewer, - query_editor::QueryEditor, -}; - -#[allow(clippy::too_many_arguments)] -pub async fn run( - _ctx: SharedContext, - _shared_renderer: SharedRenderer, - _shared_editor: Arc>, - _shared_completion: Arc>, - shared_viewer_state: SharedJsonViewer, - _no_hint: bool, - _keybinds: Keybinds, - write_to_stdout: bool, - _debounce_query_tx: mpsc::Sender, - query_debouncer: JoinHandle<()>, - resize_debouncer: JoinHandle<()>, - completion_loader_task: JoinHandle<()>, - spinning: JoinHandle<()>, - main_task: JoinHandle>, - query_action_forwarder: JoinHandle<()>, - guide_task: JoinHandle>, - editor_task: JoinHandle>, - completion_task: JoinHandle>, - processor_task: JoinHandle>, - resize_render_task: JoinHandle>, -) -> anyhow::Result> { - main_task.await??; - - let output = if write_to_stdout { - let runtime = shared_viewer_state.lock().await; - Some(runtime.formatted_content()) - } else { - None - }; - - spinning.abort(); - query_debouncer.abort(); - resize_debouncer.abort(); - completion_loader_task.abort(); - query_action_forwarder.abort(); - resize_render_task.abort(); - guide_task.abort(); - editor_task.abort(); - completion_task.abort(); - processor_task.abort(); - - Ok(output) -} From 6121fc71231ed6dc2359b14958a764d4d4a1c8cd Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 02:48:01 +0900 Subject: [PATCH 68/74] chore: tidy up to convert pane into somethings --- src/completion.rs | 12 ++++++------ src/event_dispatcher.rs | 2 +- src/guide.rs | 12 +++++++----- src/query_editor.rs | 6 +++--- src/runtime_tasks.rs | 6 +++--- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 027e38a..4e61ca3 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -102,7 +102,7 @@ pub fn spawn_initialize( } /// Navigator for managing the state of suggestions -/// and interactions in the completion pane. +/// and interactions in the completion view. pub struct CompletionNavigator { shared_suggestions: SharedSuggestionStore, state: listbox::State, @@ -226,11 +226,11 @@ impl CompletionNavigator { } pub enum CompletionAction { - /// Triggered when the user enters the completion pane with a current query as prefix. + /// Triggered when the user enters the completion view with a current query as prefix. Enter { prefix: String }, - /// Triggered when the user leaves the completion pane. + /// Triggered when the user leaves the completion view. Leave, - /// Triggered on user input events within the completion pane, such as navigation keys. + /// Triggered on user input events within the completion view, such as navigation keys. UserEvent(Event), } @@ -249,7 +249,7 @@ pub fn start_completion_task( tokio::select! { Some(action) = action_rx.recv() => { let area = shared_ctx.area().await; - let completion_pane = { + let completion_view = { let mut completion = shared_completion.write().await; match action { CompletionAction::Enter { prefix } => { @@ -294,7 +294,7 @@ pub fn start_completion_task( }; shared_renderer - .update([(Index::Completion, completion_pane)]) + .update([(Index::Completion, completion_view)]) .render() .await?; } diff --git a/src/event_dispatcher.rs b/src/event_dispatcher.rs index 3de2e72..047b193 100644 --- a/src/event_dispatcher.rs +++ b/src/event_dispatcher.rs @@ -118,7 +118,7 @@ pub fn spawn_terminal_event_dispatch_task( } else { guide_action_tx .send(GuideAction::Show( - GuideMessage::FailedToSwitchPaneWhileRenderingInProgress, + GuideMessage::FailedToSwitchModeWhileRenderingInProgress, )) .await?; } diff --git a/src/guide.rs b/src/guide.rs index ca00221..4131fdf 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -7,12 +7,14 @@ use tokio::{sync::mpsc, task::JoinHandle}; use crate::context::{Index, SharedContext}; +/// Represent a message to be shown in the guide. +/// This is used to decouple the logic of generating messages from the logic of rendering them. pub enum GuideMessage { CopiedToClipboard, FailedToCopyToClipboard(String), FailedToSetupClipboard(String), FailedToCopyWhileRenderingInProgress, - FailedToSwitchPaneWhileRenderingInProgress, + FailedToSwitchModeWhileRenderingInProgress, LoadedAllSuggestions(usize), LoadedPartiallySuggestions(usize), NoSuggestionFound(String), @@ -40,8 +42,8 @@ fn message_to_state(message: GuideMessage) -> status::State { "Failed to copy while rendering is in progress.", Severity::Warning, ), - GuideMessage::FailedToSwitchPaneWhileRenderingInProgress => status::State::new( - "Failed to switch pane while rendering is in progress.", + GuideMessage::FailedToSwitchModeWhileRenderingInProgress => status::State::new( + "Failed to switch mode while rendering is in progress.", Severity::Warning, ), GuideMessage::LoadedAllSuggestions(count) => status::State::new( @@ -87,7 +89,7 @@ pub fn start_guide_task( tokio::select! { Some(action) = action_rx.recv() => { let area = shared_ctx.area().await; - let pane = if no_hint { + let view = if no_hint { Default::default() } else { match action { @@ -95,7 +97,7 @@ pub fn start_guide_task( GuideAction::Show(message) => message_to_state(message).create_graphemes(area.0, area.1), } }; - shared_renderer.update([(Index::Guide, pane)]).render().await?; + shared_renderer.update([(Index::Guide, view)]).render().await?; } else => break, } diff --git a/src/query_editor.rs b/src/query_editor.rs index 95acaa3..54dac54 100644 --- a/src/query_editor.rs +++ b/src/query_editor.rs @@ -170,7 +170,7 @@ pub fn start_query_editor_task( tokio::select! { Some(action) = action_rx.recv() => { let area = shared_ctx.area().await; - let (editor_pane, current_text) = { + let (editor_view, current_text) = { let mut editor = shared_editor.write().await; match action { QueryEditorAction::Enter => editor.focus(), @@ -203,9 +203,9 @@ pub fn start_query_editor_task( last_text = current_text; } - // Update the renderer with the new editor pane and render it. + // Update the renderer with the new editor view and render it. shared_renderer - .update([(Index::QueryEditor, editor_pane)]) + .update([(Index::QueryEditor, editor_view)]) .render() .await?; } diff --git a/src/runtime_tasks.rs b/src/runtime_tasks.rs index 2425445..74d4217 100644 --- a/src/runtime_tasks.rs +++ b/src/runtime_tasks.rs @@ -42,7 +42,7 @@ pub fn spawn_resize_render_task( tokio::spawn(async move { while let Some(area) = last_resize_rx.recv().await { ctx.set_area(area).await; - let (editor_pane, completion_pane) = { + let (editor_view, completion_view) = { let editor = shared_editor.read().await; let completion = shared_completion.read().await; ( @@ -52,8 +52,8 @@ pub fn spawn_resize_render_task( }; shared_renderer .update([ - (Index::QueryEditor, editor_pane), - (Index::Completion, completion_pane), + (Index::QueryEditor, editor_view), + (Index::Completion, completion_view), ]) .render() .await?; From 5022efb2d0f1e1900e96ca531332023b7b67933a Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 02:49:54 +0900 Subject: [PATCH 69/74] docs: guide --- src/guide.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/guide.rs b/src/guide.rs index 4131fdf..f3f51eb 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -22,6 +22,7 @@ pub enum GuideMessage { JqFailed(String), } +/// Represent an action to be performed on the guide. pub enum GuideAction { Clear, Show(GuideMessage), @@ -68,6 +69,7 @@ fn message_to_state(message: GuideMessage) -> status::State { } } +/// Copy the given content to the clipboard and return a message indicating the result. pub fn copy_to_clipboard_message(content: &str) -> GuideMessage { match Clipboard::new() { Ok(mut clipboard) => match clipboard.set_text(content) { @@ -78,6 +80,7 @@ pub fn copy_to_clipboard_message(content: &str) -> GuideMessage { } } +/// Spawn a task that listens for guide actions and updates the guide view accordingly. pub fn start_guide_task( mut action_rx: mpsc::Receiver, shared_renderer: SharedRenderer, From 3fb8f8930657ed888868b5d9006b3622aa009d29 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 19:02:38 +0900 Subject: [PATCH 70/74] chore: bump up `toml` crate version --- Cargo.lock | 18 ++++++------------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25f817d..ae940c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,9 +1436,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", @@ -1446,14 +1446,14 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -1464,7 +1464,7 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 1.0.0", + "winnow", ] [[package]] @@ -1811,12 +1811,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 8d3fde2..e452fac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.50.0", features = ["full"] } -toml = "0.9.8" +toml = "1.1.0+spec-1.1.0" # jaq dependencies jaq-core = "2.2.1" From 471b5218f62aaad76ff02d0396cdf3b30bd18ff0 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 04:15:15 +0900 Subject: [PATCH 71/74] tests: await after abort --- src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main.rs b/src/main.rs index 99f41fb..2a88509 100644 --- a/src/main.rs +++ b/src/main.rs @@ -404,6 +404,18 @@ async fn main() -> anyhow::Result<()> { completion_navigator_task.abort(); json_viewer_task.abort(); + // Ensure all aborted tasks are fully stopped before terminal cleanup runs. + let _ = spinner_task.await; + let _ = query_debouncer.await; + let _ = resize_debouncer.await; + let _ = completion_loader_task.await; + let _ = query_change_forward_task.await; + let _ = resize_render_task.await; + let _ = guide_task.await; + let _ = query_editor_task.await; + let _ = completion_navigator_task.await; + let _ = json_viewer_task.await; + // Restore terminal state and write output to stdout if the option is enabled. stdout_redirect.restore()?; From ecb4685a4ee50efd59b62f61cba8fcb4bfcfe14d Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 21:23:00 +0900 Subject: [PATCH 72/74] chore: import ordering --- src/main.rs | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2a88509..15807d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,30 +19,24 @@ use promkit_widgets::{ }; use tokio::sync::{mpsc, RwLock}; -mod query_editor; -use query_editor::QueryEditor; +mod completion; +use completion::{CompletionAction, CompletionNavigator}; mod config; -use config::Config; +use config::{Config, DEFAULT_CONFIG}; mod context; +use context::{Index, SharedContext}; +mod event_dispatcher; mod guide; +use guide::GuideAction; +mod json; mod json_viewer; +mod query_editor; +use query_editor::{QueryEditor, QueryEditorAction}; +mod runtime_tasks; mod stdout_redirect; use stdout_redirect::StdoutRedirect; -mod completion; -mod event_dispatcher; -mod runtime_tasks; -use completion::CompletionNavigator; -mod json; mod utils; -use crate::{ - completion::CompletionAction, - config::DEFAULT_CONFIG, - context::{Index, SharedContext}, - guide::GuideAction, - query_editor::QueryEditorAction, -}; - /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] #[command( From 84535aa0aac88bdf23c373abf0d37fc51226a5b1 Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 22:38:03 +0900 Subject: [PATCH 73/74] fix: do not replace text when suggestions are empty --- src/completion.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/completion.rs b/src/completion.rs index 4e61ca3..dba824b 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -179,6 +179,10 @@ impl CompletionNavigator { event: &Event, completion_keybinds: &CompletionKeybinds, ) -> Option { + if self.state.listbox.len() == 0 { + return None; + } + // Move up. if completion_keybinds.up.contains(event) { self.state.listbox.backward(); @@ -270,6 +274,8 @@ pub fn start_completion_task( guide_action_tx .send(GuideAction::Show(GuideMessage::NoSuggestionFound(prefix))) .await?; + shared_ctx.set_active_index(Index::QueryEditor).await; + completion.clear_session_state(); } } } From 3899506a8381dfe5db90f7281053b1ebaf20070e Mon Sep 17 00:00:00 2001 From: ynqa Date: Mon, 30 Mar 2026 22:48:48 +0900 Subject: [PATCH 74/74] cargo-clippy --- Cargo.toml | 2 +- src/completion.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e452fac..7d5489e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.50.0", features = ["full"] } -toml = "1.1.0+spec-1.1.0" +toml = "1.1.0" # jaq dependencies jaq-core = "2.2.1" diff --git a/src/completion.rs b/src/completion.rs index dba824b..419f016 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -179,7 +179,7 @@ impl CompletionNavigator { event: &Event, completion_keybinds: &CompletionKeybinds, ) -> Option { - if self.state.listbox.len() == 0 { + if self.state.listbox.is_empty() { return None; }