A file-based task manager

Make better use of fzf's search

+67 -42
+1 -1
src/attrs.rs
··· 1 use std::collections::btree_map::Entry; 2 use std::collections::btree_map::{IntoIter as BTreeIntoIter, Iter as BTreeMapIter}; 3 - use std::collections::BTreeMap; 4 use std::iter::Chain; 5 6 type Map = BTreeMap<String, String>;
··· 1 + use std::collections::BTreeMap; 2 use std::collections::btree_map::Entry; 3 use std::collections::btree_map::{IntoIter as BTreeIntoIter, Iter as BTreeMapIter}; 4 use std::iter::Chain; 5 6 type Map = BTreeMap<String, String>;
+14 -6
src/fzf.rs
··· 1 use crate::errors::{Error, Result}; 2 use std::fmt::Display; 3 use std::io::Write; 4 use std::process::{Command, Stdio}; ··· 6 7 /// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string 8 /// representation as output 9 - pub fn select<I>(input: impl IntoIterator<Item = I>) -> Result<Option<I>> 10 where 11 - I: Display + FromStr, 12 - Error: From<<I as FromStr>::Err>, 13 { 14 - let mut child = Command::new("fzf") 15 - .args(["-d", "\t"]) 16 .stderr(Stdio::inherit()) 17 .stdin(Stdio::piped()) 18 .stdout(Stdio::piped()) ··· 20 // unwrap: this can never fail 21 let child_in = child.stdin.as_mut().unwrap(); 22 for item in input.into_iter() { 23 - writeln!(child_in, "{item}")?; 24 } 25 let output = child.wait_with_output()?; 26 if output.stdout.is_empty() {
··· 1 use crate::errors::{Error, Result}; 2 + use std::ffi::OsStr; 3 use std::fmt::Display; 4 use std::io::Write; 5 use std::process::{Command, Stdio}; ··· 7 8 /// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string 9 /// representation as output 10 + pub fn select<I, O, S>( 11 + input: impl IntoIterator<Item = I>, 12 + extra: impl IntoIterator<Item = S>, 13 + ) -> Result<Option<O>> 14 where 15 + O: FromStr, 16 + I: Display, 17 + Error: From<<O as FromStr>::Err>, 18 + S: AsRef<OsStr>, 19 { 20 + let mut command = Command::new("fzf"); 21 + let mut child = command 22 + .args(extra) 23 + .arg("--read0") 24 .stderr(Stdio::inherit()) 25 .stdin(Stdio::piped()) 26 .stdout(Stdio::piped()) ··· 28 // unwrap: this can never fail 29 let child_in = child.stdin.as_mut().unwrap(); 30 for item in input.into_iter() { 31 + write!(child_in, "{item}\0")?; 32 } 33 let output = child.wait_with_output()?; 34 if output.stdout.is_empty() {
+1 -1
src/main.rs
··· 5 mod task; 6 mod util; 7 mod workspace; 8 - use clap_complete::{generate, Shell}; 9 use errors::Result; 10 use std::io::{self, Write}; 11 use std::path::PathBuf;
··· 5 mod task; 6 mod util; 7 mod workspace; 8 + use clap_complete::{Shell, generate}; 9 use errors::Result; 10 use std::io::{self, Write}; 11 use std::path::PathBuf;
+1 -1
src/stack.rs
··· 4 5 use crate::errors::{Error, Result}; 6 use crate::util; 7 - use std::collections::vec_deque::Iter; 8 use std::collections::VecDeque; 9 use std::fmt::Display; 10 use std::fs::File; 11 use std::io::{self, BufRead, BufReader, Seek, Write};
··· 4 5 use crate::errors::{Error, Result}; 6 use crate::util; 7 use std::collections::VecDeque; 8 + use std::collections::vec_deque::Iter; 9 use std::fmt::Display; 10 use std::fs::File; 11 use std::io::{self, BufRead, BufReader, Seek, Write};
+50 -33
src/workspace.rs
··· 7 use crate::stack::{StackItem, TaskStack}; 8 use crate::task::parse as parse_task; 9 use crate::{fzf, util}; 10 - use std::collections::{vec_deque, BTreeMap, HashSet}; 11 use std::ffi::OsString; 12 use std::fmt::Display; 13 - use std::fs::{remove_file, File}; 14 use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom}; 15 use std::ops::Deref; 16 use std::os::unix::fs::symlink; 17 use std::path::PathBuf; 18 use std::str::FromStr; 19 use std::{fs::OpenOptions, io::Write}; 20 ··· 30 type Err = Error; 31 32 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 33 - let s = s 34 .trim() 35 - .strip_prefix("tsk-") 36 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; 37 Ok(Self(s.parse()?)) 38 } ··· 93 .create(true) 94 .truncate(true) 95 .open(tsk_dir.join("next"))?; 96 next.write_all(b"1\n")?; 97 Ok(()) 98 } ··· 347 workspace: self, 348 }; 349 // search the entirety of a task 350 - Ok(fzf::select(loader)?.map(|bt| bt.id)) 351 } else { 352 // just search the stack 353 - Ok(fzf::select(stack)?.map(|si| si.id)) 354 } 355 } 356 ··· 406 Ok(()) 407 } 408 409 fn bare(self) -> SearchTask { 410 SearchTask { 411 id: self.id, ··· 422 pub body: String, 423 } 424 425 - impl FromStr for SearchTask { 426 - type Err = Error; 427 - 428 - fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 429 - let (tsk_id, task_content) = s.split_once('\t').ok_or(Error::Parse( 430 - "Missing TSK-ID or content or task parse.".to_owned(), 431 - ))?; 432 - let (title, body) = task_content 433 - .split_once('\t') 434 - .ok_or(Error::Parse("Missing body for task parse.".to_owned()))?; 435 - Ok(Self { 436 - id: tsk_id.parse()?, 437 - title: title.to_string(), 438 - body: body.to_string(), 439 - }) 440 - } 441 - } 442 - 443 impl Display for SearchTask { 444 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 445 - write!( 446 - f, 447 - "{}\t{}\t{}", 448 - self.id, 449 - self.title.trim(), 450 - self.body.replace('\n', " ").replace('\r', "") 451 - ) 452 } 453 } 454 ··· 470 } 471 } 472 473 #[cfg(test)] 474 mod test { 475 use super::*; ··· 479 let task = SearchTask { 480 id: Id(123), 481 title: "Hello, world".to_string(), 482 - body: "The body of the task.\nAnother line\r\nis here.".to_string(), 483 }; 484 assert_eq!( 485 - "tsk-123\tHello, world\tThe body of the task. Another line is here.", 486 task.to_string() 487 ); 488 }
··· 7 use crate::stack::{StackItem, TaskStack}; 8 use crate::task::parse as parse_task; 9 use crate::{fzf, util}; 10 + use std::collections::{BTreeMap, HashSet, vec_deque}; 11 use std::ffi::OsString; 12 use std::fmt::Display; 13 + use std::fs::{File, remove_file}; 14 use std::io::{BufRead as _, BufReader, Read, Seek, SeekFrom}; 15 use std::ops::Deref; 16 use std::os::unix::fs::symlink; 17 use std::path::PathBuf; 18 + use std::process::{Command, Stdio}; 19 use std::str::FromStr; 20 use std::{fs::OpenOptions, io::Write}; 21 ··· 31 type Err = Error; 32 33 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { 34 + let upper = s.to_uppercase(); 35 + let s = upper 36 .trim() 37 + .strip_prefix("TSK-") 38 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?; 39 Ok(Self(s.parse()?)) 40 } ··· 95 .create(true) 96 .truncate(true) 97 .open(tsk_dir.join("next"))?; 98 + // initialize the next file with ID 1 99 next.write_all(b"1\n")?; 100 Ok(()) 101 } ··· 350 workspace: self, 351 }; 352 // search the entirety of a task 353 + Ok(fzf::select::<_, Id, _>( 354 + loader, 355 + [ 356 + "--no-multi-line", 357 + "--accept-nth=1", 358 + "--delimiter=\t", 359 + "--preview=tsk show -T {1}", 360 + "--preview-window=top", 361 + "--ansi", 362 + "--info-command=tsk show -T {1} | head -n1", 363 + "--info=inline-right", 364 + ], 365 + )?) 366 } else { 367 // just search the stack 368 + Ok(fzf::select::<_, Id, _>( 369 + stack, 370 + ["--delimiter=\t", "--accept-nth=1"], 371 + )?) 372 } 373 } 374 ··· 424 Ok(()) 425 } 426 427 + /// Returns a [`SearchTas`] which is plain task data with no file or attrs 428 fn bare(self) -> SearchTask { 429 SearchTask { 430 id: self.id, ··· 441 pub body: String, 442 } 443 444 impl Display for SearchTask { 445 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 446 + write!(f, "{}\t{}", self.id, self.title.trim())?; 447 + if !self.body.is_empty() { 448 + write!(f, "\n\n{}", self.body)?; 449 + } 450 + Ok(()) 451 } 452 } 453 ··· 469 } 470 } 471 472 + fn select_task(input: impl IntoIterator<Item = SearchTask>) -> Result<Option<Id>> { 473 + let mut child = Command::new("cat") 474 + .stderr(Stdio::inherit()) 475 + .stdin(Stdio::piped()) 476 + .stdout(Stdio::piped()) 477 + .spawn()?; 478 + let child_in = child.stdin.as_mut().unwrap(); 479 + for item in input.into_iter() { 480 + writeln!(child_in, "{item}\0")?; 481 + } 482 + let output = child.wait_with_output()?; 483 + if output.stdout.is_empty() { 484 + Ok(None) 485 + } else { 486 + Ok(Some(String::from_utf8(output.stdout)?.parse()?)) 487 + } 488 + } 489 + 490 #[cfg(test)] 491 mod test { 492 use super::*; ··· 496 let task = SearchTask { 497 id: Id(123), 498 title: "Hello, world".to_string(), 499 + body: "The body of the task.\nAnother line is here.".to_string(), 500 }; 501 assert_eq!( 502 + "tsk-123\tHello, world\n\nThe body of the task.\nAnother line is here.", 503 task.to_string() 504 ); 505 }