A file-based task manager
0
fork

Configure Feed

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

Add tsk links command for listing and opening hyperlinks

Surfaces every link parsed from a task body — URLs (raw and markdown),
internal [[tsk-N]] refs, foreign [[ns-N]] refs — as a numbered list:

tsk links -T tsk-12

With -s, the list is piped through fzf and the picked link is opened
via the existing tsk follow path: URLs go to the system handler,
internal links are shown, foreign refs resolve through the configured
remote.

Test exercises the link parser directly to confirm all four link kinds
appear in the right order.

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

+88 -33
+1
.tsk/namespace
··· 1 + default
+16 -33
Cargo.lock
··· 65 65 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 66 66 67 67 [[package]] 68 - name = "arbitrary" 69 - version = "1.4.2" 70 - source = "registry+https://github.com/rust-lang/crates.io-index" 71 - checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 72 - dependencies = [ 73 - "derive_arbitrary", 74 - ] 75 - 76 - [[package]] 77 68 name = "bitflags" 78 69 version = "2.11.1" 79 70 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 187 178 ] 188 179 189 180 [[package]] 190 - name = "crossbeam-utils" 191 - version = "0.8.21" 192 - source = "registry+https://github.com/rust-lang/crates.io-index" 193 - checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 194 - 195 - [[package]] 196 - name = "derive_arbitrary" 197 - version = "1.4.2" 198 - source = "registry+https://github.com/rust-lang/crates.io-index" 199 - checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 200 - dependencies = [ 201 - "proc-macro2", 202 - "quote", 203 - "syn", 204 - ] 205 - 206 - [[package]] 207 181 name = "displaydoc" 208 182 version = "0.2.5" 209 183 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 264 238 source = "registry+https://github.com/rust-lang/crates.io-index" 265 239 checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 266 240 dependencies = [ 267 - "crc32fast", 268 241 "miniz_oxide", 242 + "zlib-rs", 269 243 ] 270 244 271 245 [[package]] ··· 883 857 ] 884 858 885 859 [[package]] 860 + name = "typed-path" 861 + version = "0.12.3" 862 + source = "registry+https://github.com/rust-lang/crates.io-index" 863 + checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" 864 + 865 + [[package]] 886 866 name = "unicode-ident" 887 867 version = "1.0.24" 888 868 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1265 1245 1266 1246 [[package]] 1267 1247 name = "zip" 1268 - version = "2.4.2" 1248 + version = "8.6.0" 1269 1249 source = "registry+https://github.com/rust-lang/crates.io-index" 1270 - checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" 1250 + checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" 1271 1251 dependencies = [ 1272 - "arbitrary", 1273 1252 "crc32fast", 1274 - "crossbeam-utils", 1275 - "displaydoc", 1276 1253 "flate2", 1277 1254 "indexmap", 1278 1255 "memchr", 1279 - "thiserror", 1256 + "typed-path", 1280 1257 "zopfli", 1281 1258 ] 1259 + 1260 + [[package]] 1261 + name = "zlib-rs" 1262 + version = "0.6.3" 1263 + source = "registry+https://github.com/rust-lang/crates.io-index" 1264 + checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" 1282 1265 1283 1266 [[package]] 1284 1267 name = "zmij"
+53
src/main.rs
··· 267 267 /// Switch to a different namespace. Shorthand for `tsk namespace switch`. 268 268 Switch { name: String }, 269 269 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 + 270 282 /// Reopens an archived task, recreating the symlink and adding it back to the stack. 271 283 Reopen { 272 284 #[command(flatten)] ··· 451 463 Commands::Accept { key } => command_accept(dir, key), 452 464 Commands::Bundle { output } => command_bundle(dir, output), 453 465 Commands::Migrate => command_migrate(dir), 466 + Commands::Links { task_id, select } => command_links(dir, task_id, select), 454 467 Commands::Reopen { task_id } => command_reopen(dir, task_id), 455 468 Commands::Log { tsk_id } => command_log(dir, tsk_id), 456 469 Commands::Prop { action } => command_prop(dir, action), ··· 983 996 } 984 997 } 985 998 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) 986 1039 } 987 1040 988 1041 fn command_reopen(dir: PathBuf, task_id: TaskId) -> Result<()> {
+18
src/workspace.rs
··· 1998 1998 } 1999 1999 2000 2000 #[test] 2001 + fn test_parsed_links_returns_all_kinds() { 2002 + // Verifies the data the `tsk links` command consumes: internal, 2003 + // foreign, raw URL, and labeled markdown link should all surface. 2004 + let body = "see <https://a.example> and [[tsk-1]] and [b](https://b.example) and [[gh-99]]"; 2005 + let parsed = parse_task(&format!("\n\n{body}")).expect("parse"); 2006 + let kinds: Vec<&str> = parsed 2007 + .links 2008 + .iter() 2009 + .map(|l| match l { 2010 + crate::task::ParsedLink::External(_) => "ext", 2011 + crate::task::ParsedLink::Internal(_) => "int", 2012 + crate::task::ParsedLink::Foreign { .. } => "for", 2013 + }) 2014 + .collect(); 2015 + assert_eq!(kinds, vec!["ext", "int", "ext", "for"]); 2016 + } 2017 + 2018 + #[test] 2001 2019 fn test_export_and_accept_across_namespaces() { 2002 2020 let dir = tempfile::tempdir().unwrap(); 2003 2021 let root = dir.path().to_path_buf();