A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fold link listing + fzf-pick into tsk follow

The previously-separate `tsk links` command duplicated tsk follow's
resolve+open logic. Drop it; instead extend follow:

tsk follow -T <id> list links and exit
tsk follow -T <id> -l N open link N (existing behavior)
tsk follow -T <id> -s fzf-pick a link, then open it

URLs go to the system handler, [[tsk-N]] internal links are shown,
foreign refs resolve through the configured remote — same paths as
before, just exposed as different invocations of one command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+90 -95
+90 -95
src/main.rs
··· 130 130 task_id: TaskId, 131 131 }, 132 132 133 - /// Follow a link that is parsed from a task body. It may be an internal or external link (ie. 134 - /// a url or a wiki-style link using double square brackets). When using the `tsk show` 135 - /// command, links that are successfully parsed get a numeric superscript that may be used to 136 - /// address the link. That number should be supplied to the -l/link_index where it will be 137 - /// subsequently followed opened or shown. 133 + /// List or follow a link parsed from a task's body. Without -l or -s, 134 + /// prints the numbered list and exits. With -l N, opens link N; URLs go 135 + /// to the system handler, [[tsk-N]] internal links are shown, foreign 136 + /// refs resolve through the configured remote. With -s, pipes the list 137 + /// through fzf and opens the picked one. 138 138 Follow { 139 139 /// The task whose body will be searched for links. 140 140 #[command(flatten)] 141 141 task_id: TaskId, 142 - /// The index of the link to open. Must be supplied. 143 - #[arg(short = 'l', default_value_t = 1)] 144 - link_index: usize, 145 - /// When opening an internal link, whether to show or edit the addressed task. 142 + /// The index of the link to open. Omit (along with -s) to just list. 143 + #[arg(short = 'l')] 144 + link_index: Option<usize>, 145 + /// fzf-pick a link to open instead of supplying -l. 146 + #[arg(short = 's', default_value_t = false)] 147 + select: bool, 148 + /// When opening an internal link, edit the addressed task instead of showing. 146 149 #[arg(short = 'e', default_value_t = false)] 147 150 edit: bool, 148 151 }, ··· 267 270 /// Switch to a different namespace. Shorthand for `tsk namespace switch`. 268 271 Switch { name: String }, 269 272 270 - /// List the hyperlinks parsed from a task's body. With -s, pipe the list 271 - /// through fzf and open the selected link via the existing follow path: 272 - /// URLs go to the system handler, [[tsk-N]] internal links are shown, 273 - /// foreign refs resolve through the configured remote. 274 - Links { 275 - #[command(flatten)] 276 - task_id: TaskId, 277 - /// Use fzf to select a link, then open it. 278 - #[arg(short = 's', default_value_t = false)] 279 - select: bool, 280 - }, 281 - 282 273 /// Reopens an archived task, recreating the symlink and adding it back to the stack. 283 274 Reopen { 284 275 #[command(flatten)] ··· 443 434 Commands::Follow { 444 435 task_id, 445 436 link_index, 437 + select, 446 438 edit, 447 - } => command_follow(dir, task_id, link_index, edit), 439 + } => command_follow(dir, task_id, link_index, select, edit), 448 440 Commands::Edit { task_id } => command_edit(dir, task_id), 449 441 Commands::Completion { shell } => command_completion(shell), 450 442 Commands::Drop { task_id } => command_drop(dir, task_id), ··· 463 455 Commands::Accept { key } => command_accept(dir, key), 464 456 Commands::Bundle { output } => command_bundle(dir, output), 465 457 Commands::Migrate => command_migrate(dir), 466 - Commands::Links { task_id, select } => command_links(dir, task_id, select), 467 458 Commands::Reopen { task_id } => command_reopen(dir, task_id), 468 459 Commands::Log { tsk_id } => command_log(dir, tsk_id), 469 460 Commands::Prop { action } => command_prop(dir, action), ··· 669 660 Ok(()) 670 661 } 671 662 672 - fn command_follow(dir: PathBuf, task_id: TaskId, link_index: usize, edit: bool) -> Result<()> { 663 + fn render_link(link: &ParsedLink) -> String { 664 + match link { 665 + ParsedLink::External(url) => url.to_string(), 666 + ParsedLink::Internal(id) => format!("[[{id}]]"), 667 + ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 668 + } 669 + } 670 + 671 + fn command_follow( 672 + dir: PathBuf, 673 + task_id: TaskId, 674 + link_index: Option<usize>, 675 + select: bool, 676 + edit: bool, 677 + ) -> Result<()> { 673 678 let task = Workspace::from_path(dir.clone())?.task(task_id.into())?; 674 - if let Some(parsed_task) = task::parse(&task.to_string()) { 675 - if link_index == 0 || link_index > parsed_task.links.len() { 676 - eprintln!("Link index out of bounds."); 677 - exit(1); 679 + let Some(parsed_task) = task::parse(&task.to_string()) else { 680 + eprintln!("Unable to parse any links from body."); 681 + exit(1); 682 + }; 683 + if parsed_task.links.is_empty() { 684 + eprintln!("No links found in {}.", task.id); 685 + return Ok(()); 686 + } 687 + 688 + // Resolve which link index to act on, or fall through to listing. 689 + let idx = match (link_index, select) { 690 + (Some(n), _) => n, 691 + (None, true) => { 692 + let lines: Vec<String> = parsed_task 693 + .links 694 + .iter() 695 + .enumerate() 696 + .map(|(i, l)| format!("{}\t{}", i + 1, render_link(l))) 697 + .collect(); 698 + match fzf::select::<_, usize, _>(lines, ["--delimiter=\t", "--accept-nth=1"])? { 699 + Some(n) => n, 700 + None => { 701 + eprintln!("No link selected."); 702 + exit(1); 703 + } 704 + } 705 + } 706 + (None, false) => { 707 + // Just list. 708 + for (i, link) in parsed_task.links.iter().enumerate() { 709 + println!("{}\t{}", i + 1, render_link(link)); 710 + } 711 + return Ok(()); 712 + } 713 + }; 714 + 715 + if idx == 0 || idx > parsed_task.links.len() { 716 + eprintln!("Link index out of bounds."); 717 + exit(1); 718 + } 719 + match &parsed_task.links[idx - 1] { 720 + ParsedLink::External(url) => { 721 + open::that_detached(url.as_str())?; 722 + Ok(()) 678 723 } 679 - let link = &parsed_task.links[link_index - 1]; 680 - match link { 681 - ParsedLink::External(url) => { 682 - open::that_detached(url.as_str())?; 683 - Ok(()) 724 + ParsedLink::Internal(id) => { 725 + let taskid = taskid_from_tsk_id(*id); 726 + if edit { 727 + command_edit(dir, taskid) 728 + } else { 729 + command_show(dir, taskid, false, false) 684 730 } 685 - ParsedLink::Internal(id) => { 686 - let taskid = taskid_from_tsk_id(*id); 731 + } 732 + ParsedLink::Foreign { prefix, id } => { 733 + let workspace = Workspace::from_path(dir.clone())?; 734 + if let Some(task) = workspace.resolve_foreign_link(prefix, *id)? { 687 735 if edit { 688 - command_edit(dir, taskid) 689 - } else { 690 - command_show(dir, taskid, false, false) 691 - } 692 - } 693 - ParsedLink::Foreign { prefix, id } => { 694 - let workspace = Workspace::from_path(dir.clone())?; 695 - if let Some(task) = workspace.resolve_foreign_link(prefix, *id)? { 696 - if edit { 697 - eprintln!("Editing foreign tasks is not supported."); 698 - exit(1); 699 - } else { 700 - println!("{task}"); 701 - } 736 + eprintln!("Editing foreign tasks is not supported."); 737 + exit(1); 702 738 } else { 703 - eprintln!("Task {prefix}-{id} not found in remote workspace."); 704 - exit(1); 739 + println!("{task}"); 705 740 } 706 - Ok(()) 741 + } else { 742 + eprintln!("Task {prefix}-{id} not found in remote workspace."); 743 + exit(1); 707 744 } 745 + Ok(()) 708 746 } 709 - } else { 710 - eprintln!("Unable to parse any links from body."); 711 - exit(1); 712 747 } 713 748 } 714 749 ··· 996 1031 } 997 1032 } 998 1033 Ok(()) 999 - } 1000 - 1001 - fn render_link(link: &ParsedLink) -> String { 1002 - match link { 1003 - ParsedLink::External(url) => url.to_string(), 1004 - ParsedLink::Internal(id) => format!("[[{id}]]"), 1005 - ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 1006 - } 1007 - } 1008 - 1009 - fn command_links(dir: PathBuf, task_id: TaskId, select: bool) -> Result<()> { 1010 - let workspace = Workspace::from_path(dir.clone())?; 1011 - let task = workspace.task(task_id.into())?; 1012 - let parsed = task::parse(&task.to_string()); 1013 - let links: Vec<ParsedLink> = parsed.map(|p| p.links).unwrap_or_default(); 1014 - if links.is_empty() { 1015 - eprintln!("No links found in {}.", task.id); 1016 - return Ok(()); 1017 - } 1018 - 1019 - if !select { 1020 - for (i, link) in links.iter().enumerate() { 1021 - println!("{}\t{}", i + 1, render_link(link)); 1022 - } 1023 - return Ok(()); 1024 - } 1025 - 1026 - // -s: pipe through fzf and open the picked link via command_follow. 1027 - let lines: Vec<String> = links 1028 - .iter() 1029 - .enumerate() 1030 - .map(|(i, l)| format!("{}\t{}", i + 1, render_link(l))) 1031 - .collect(); 1032 - let chosen: Option<usize> = 1033 - fzf::select::<_, usize, _>(lines, ["--delimiter=\t", "--accept-nth=1"])?; 1034 - let Some(idx) = chosen else { 1035 - eprintln!("No link selected."); 1036 - exit(1); 1037 - }; 1038 - command_follow(dir, taskid_from_tsk_id(task.id), idx, false) 1039 1034 } 1040 1035 1041 1036 fn command_reopen(dir: PathBuf, task_id: TaskId) -> Result<()> {