A file-based task manager
0
fork

Configure Feed

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

Add foreign workspace support with remote command

- tsk-10: Implement remote workspace mappings via .tsk/remotes file
- Add ParsedLink::Foreign variant for cross-workspace links like [[jira-123]]
- Add tsk remote {list,add,remove} commands to manage workspace remotes
- Update follow command to resolve foreign links

+164
+65
src/main.rs
··· 177 177 178 178 /// Cleans up orphaned task files in .tsk/tasks/ that are no longer in the stack index. 179 179 Clean, 180 + 181 + /// Manage remote workspace mappings for cross-workspace task linking. 182 + Remote { 183 + #[command(subcommand)] 184 + action: RemoteAction, 185 + }, 186 + } 187 + 188 + #[derive(Subcommand)] 189 + enum RemoteAction { 190 + /// List configured remote workspaces. 191 + List, 192 + /// Add a remote workspace mapping. 193 + Add { 194 + /// The prefix to use for this remote (e.g. "jira", "gl"). 195 + prefix: String, 196 + /// The path to the remote workspace. 197 + path: String, 198 + }, 199 + /// Remove a remote workspace mapping. 200 + Remove { 201 + /// The prefix of the remote to remove. 202 + prefix: String, 203 + }, 180 204 } 181 205 182 206 #[derive(Args)] ··· 284 308 Commands::Prioritize { task_id } => command_prioritize(dir, task_id), 285 309 Commands::Deprioritize { task_id } => command_deprioritize(dir, task_id), 286 310 Commands::Clean => command_clean(dir), 311 + Commands::Remote { action } => command_remote(dir, action), 287 312 }; 288 313 let result = var_name; 289 314 match result { ··· 500 525 command_show(dir, taskid, false, false) 501 526 } 502 527 } 528 + ParsedLink::Foreign { prefix, id } => { 529 + let workspace = Workspace::from_path(dir.clone())?; 530 + if let Some(task) = workspace.resolve_foreign_link(prefix, *id)? { 531 + if edit { 532 + eprintln!("Editing foreign tasks is not supported."); 533 + exit(1); 534 + } else { 535 + println!("{task}"); 536 + } 537 + } else { 538 + eprintln!("Task {prefix}-{id} not found in remote workspace."); 539 + exit(1); 540 + } 541 + Ok(()) 542 + } 503 543 } 504 544 } else { 505 545 eprintln!("Unable to parse any links from body."); ··· 511 551 Workspace::from_path(dir)?.clean()?; 512 552 Ok(()) 513 553 } 554 + 555 + fn command_remote(dir: PathBuf, action: RemoteAction) -> Result<()> { 556 + let workspace = Workspace::from_path(dir)?; 557 + match action { 558 + RemoteAction::List => { 559 + let remotes = workspace.read_remotes()?; 560 + if remotes.is_empty() { 561 + println!("No remotes configured."); 562 + } else { 563 + for remote in remotes { 564 + println!("{remote}"); 565 + } 566 + } 567 + } 568 + RemoteAction::Add { prefix, path } => { 569 + workspace.add_remote(&prefix, &path)?; 570 + eprintln!("Added remote '{prefix}' -> {path}"); 571 + } 572 + RemoteAction::Remove { prefix } => { 573 + workspace.remove_remote(&prefix)?; 574 + eprintln!("Removed remote '{prefix}'"); 575 + } 576 + } 577 + Ok(()) 578 + }
+16
src/task.rs
··· 50 50 #[derive(Debug, Eq, PartialEq, Clone)] 51 51 pub(crate) enum ParsedLink { 52 52 Internal(Id), 53 + Foreign { prefix: String, id: u32 }, 53 54 External(Url), 54 55 } 55 56 ··· 99 100 ); 100 101 out.replace_range(il - 1..out.len(), &linktext); 101 102 links.push(ParsedLink::Internal(id)); 103 + } else if let Some((prefix, id_str)) = contents.split_once('-') { 104 + if let Ok(id) = id_str.parse::<u32>() { 105 + let linktext = format!( 106 + "{}{}", 107 + contents.cyan(), 108 + super_num(links.len() + 1).cyan() 109 + ); 110 + out.replace_range(il - 1..out.len(), &linktext); 111 + links.push(ParsedLink::Foreign { 112 + prefix: prefix.to_string(), 113 + id, 114 + }); 115 + } else { 116 + panic!("Internal link is not a valid id: {contents}"); 117 + } 102 118 } else { 103 119 panic!("Internal link is not a valid id: {contents}"); 104 120 }
+83
src/workspace.rs
··· 21 21 22 22 const INDEXFILE: &str = "index"; 23 23 const TITLECACHEFILE: &str = "cache"; 24 + const REMOTESFILE: &str = "remotes"; 24 25 const XATTRPREFIX: &str = "user.tsk."; 25 26 const BACKREFXATTR: &str = "user.tsk.references"; 26 27 /// A unique identifier for a task. When referenced in text, it is prefixed with `tsk-`. ··· 75 76 /// The path to the workspace root, excluding the .tsk directory. This should *contain* the 76 77 /// .tsk directory. 77 78 path: PathBuf, 79 + } 80 + 81 + #[derive(Clone, Debug, Eq, PartialEq)] 82 + pub struct Remote { 83 + pub prefix: String, 84 + pub path: PathBuf, 85 + } 86 + 87 + impl Display for Remote { 88 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 89 + write!(f, "{}\t{}", self.prefix, self.path.display()) 90 + } 78 91 } 79 92 80 93 impl Workspace { ··· 429 442 } 430 443 } 431 444 Ok(()) 445 + } 446 + 447 + pub fn read_remotes(&self) -> Result<Vec<Remote>> { 448 + let remotes_path = self.path.join(REMOTESFILE); 449 + if !remotes_path.exists() { 450 + return Ok(Vec::new()); 451 + } 452 + let file = util::flopen(remotes_path, FlockArg::LockShared)?; 453 + let reader = BufReader::new(&*file); 454 + let mut remotes = Vec::new(); 455 + for line in reader.lines() { 456 + let line = line?; 457 + let line = line.trim(); 458 + if line.is_empty() || line.starts_with('#') { 459 + continue; 460 + } 461 + if let Some((prefix, path)) = line.split_once('\t') { 462 + remotes.push(Remote { 463 + prefix: prefix.trim().to_string(), 464 + path: PathBuf::from(path.trim()), 465 + }); 466 + } 467 + } 468 + Ok(remotes) 469 + } 470 + 471 + pub fn add_remote(&self, prefix: &str, path: &str) -> Result<()> { 472 + let mut remotes = self.read_remotes()?; 473 + if remotes.iter().any(|r| r.prefix == prefix) { 474 + return Err(Error::Parse(format!("Remote '{prefix}' already exists"))); 475 + } 476 + remotes.push(Remote { 477 + prefix: prefix.to_string(), 478 + path: PathBuf::from(path), 479 + }); 480 + self.write_remotes(&remotes) 481 + } 482 + 483 + pub fn remove_remote(&self, prefix: &str) -> Result<()> { 484 + let remotes = self.read_remotes()?; 485 + let len = remotes.len(); 486 + let new_remotes: Vec<Remote> = remotes.into_iter().filter(|r| r.prefix != prefix).collect(); 487 + if new_remotes.len() == len { 488 + return Err(Error::Parse(format!("Remote '{prefix}' not found"))); 489 + } 490 + self.write_remotes(&new_remotes) 491 + } 492 + 493 + fn write_remotes(&self, remotes: &[Remote]) -> Result<()> { 494 + let remotes_path = self.path.join(REMOTESFILE); 495 + let mut file = OpenOptions::new() 496 + .write(true) 497 + .create(true) 498 + .truncate(true) 499 + .open(remotes_path)?; 500 + for remote in remotes { 501 + writeln!(file, "{}\t{}", remote.prefix, remote.path.display())?; 502 + } 503 + Ok(()) 504 + } 505 + 506 + pub fn resolve_foreign_link(&self, prefix: &str, id: u32) -> Result<Option<Task>> { 507 + let remotes = self.read_remotes()?; 508 + let remote = remotes 509 + .iter() 510 + .find(|r| r.prefix == prefix) 511 + .ok_or_else(|| Error::Parse(format!("Unknown remote prefix: {prefix}")))?; 512 + let workspace = Workspace::from_path(remote.path.clone())?; 513 + let task = workspace.task(TaskIdentifier::Id(Id(id)))?; 514 + Ok(Some(task)) 432 515 } 433 516 } 434 517