|
1 | | -use std::{collections::HashSet, env}; |
| 1 | +use std::{collections::HashSet, env, path::PathBuf, process::Command}; |
2 | 2 |
|
3 | 3 | use clap::{CommandFactory, Parser}; |
4 | 4 | use clap_complete::CompleteEnv; |
5 | | -use error_stack::Report; |
| 5 | +use error_stack::{Report, ResultExt}; |
6 | 6 |
|
7 | 7 | use tms::{ |
8 | 8 | cli::{Cli, SubCommandGiven}, |
9 | 9 | configs::SessionSortOrderConfig, |
10 | | - error::{Result, Suggestion}, |
| 10 | + error::{Result, Suggestion, TmsError}, |
11 | 11 | session::{create_sessions, SessionContainer}, |
12 | 12 | tmux::Tmux, |
13 | 13 | }; |
@@ -68,6 +68,12 @@ fn main() -> Result<()> { |
68 | 68 | return Ok(()); |
69 | 69 | }; |
70 | 70 |
|
| 71 | + // Check if user wants to create a new directory |
| 72 | + if let Some(name) = selected_str.strip_prefix("__TMS_CREATE_NEW__:") { |
| 73 | + create_new_directory(name, &config, &tmux)?; |
| 74 | + return Ok(()); |
| 75 | + } |
| 76 | + |
71 | 77 | if let Some(session) = sessions.find_session(&selected_str) { |
72 | 78 | session.switch_to(&tmux, &config)?; |
73 | 79 | } |
@@ -143,3 +149,189 @@ fn get_session_list( |
143 | 149 | (all_sessions, None) |
144 | 150 | } |
145 | 151 | } |
| 152 | + |
| 153 | +/// Default create hook template embedded in binary |
| 154 | +const DEFAULT_HOOK: &str = r#"#!/usr/bin/env bash |
| 155 | +# tms create hook |
| 156 | +# |
| 157 | +# Called when you type a non-existent name in the tms picker. |
| 158 | +# Example: create-hook "my-app" "/home/user/code" "/home/user/work" |
| 159 | +# |
| 160 | +# Parameters: |
| 161 | +# $1 = name you typed |
| 162 | +# $2, $3... = search directories from your config |
| 163 | +# |
| 164 | +# Output (optional): |
| 165 | +# Print directory name to stdout to override session name |
| 166 | +# If no output, session name defaults to $1 |
| 167 | +# |
| 168 | +# Must: Create a directory that tms can discover, exit 0 on success |
| 169 | +
|
| 170 | +set -e |
| 171 | +
|
| 172 | +NAME="$1" |
| 173 | +FIRST_DIR="$2" |
| 174 | +
|
| 175 | +# Detect Git URL (GitHub, GitLab, etc.) and clone it |
| 176 | +if [[ "$NAME" =~ ^https?://[^/]+/([^/]+)/([^/]+)(\.git)?$ ]]; then |
| 177 | + REPO_NAME="${BASH_REMATCH[2]%.git}" |
| 178 | + TARGET_DIR="$FIRST_DIR/$REPO_NAME" |
| 179 | +
|
| 180 | + # Only clone if directory doesn't exist |
| 181 | + if [[ ! -d "$TARGET_DIR" ]]; then |
| 182 | + echo "Cloning $REPO_NAME..." >&2 |
| 183 | + git clone "$NAME" "$TARGET_DIR" 2>&1 | sed 's/^/ /' >&2 |
| 184 | + echo "Done!" >&2 |
| 185 | + else |
| 186 | + echo "Directory $REPO_NAME already exists, opening..." >&2 |
| 187 | + fi |
| 188 | +
|
| 189 | + echo "$REPO_NAME" # Tell tms the actual directory name |
| 190 | + exit 0 |
| 191 | +fi |
| 192 | +
|
| 193 | +# Default: create new directory with git init |
| 194 | +DIR="$FIRST_DIR/$NAME" |
| 195 | +mkdir -p "$DIR" |
| 196 | +cd "$DIR" |
| 197 | +git init -q |
| 198 | +
|
| 199 | +# No output = use original name from picker |
| 200 | +
|
| 201 | +# Example: customize based on name pattern |
| 202 | +# if [[ "$NAME" == *-rs ]]; then |
| 203 | +# cargo init --name "${NAME%-rs}" |
| 204 | +# echo "${NAME%-rs}" # Return the actual directory name |
| 205 | +# fi |
| 206 | +"#; |
| 207 | + |
| 208 | +/// Ensure the create hook exists at the conventional location |
| 209 | +fn ensure_hook_exists(hook_path: &PathBuf) -> Result<()> { |
| 210 | + if hook_path.exists() { |
| 211 | + return Ok(()); |
| 212 | + } |
| 213 | + |
| 214 | + // Create parent directory if needed |
| 215 | + if let Some(parent) = hook_path.parent() { |
| 216 | + std::fs::create_dir_all(parent) |
| 217 | + .change_context(TmsError::IoError) |
| 218 | + .attach_printable(format!("Failed to create directory: {}", parent.display()))?; |
| 219 | + } |
| 220 | + |
| 221 | + // Write default hook |
| 222 | + std::fs::write(hook_path, DEFAULT_HOOK) |
| 223 | + .change_context(TmsError::IoError) |
| 224 | + .attach_printable(format!("Failed to write hook: {}", hook_path.display()))?; |
| 225 | + |
| 226 | + // Make executable on Unix |
| 227 | + #[cfg(unix)] |
| 228 | + { |
| 229 | + use std::os::unix::fs::PermissionsExt; |
| 230 | + let mut perms = std::fs::metadata(hook_path) |
| 231 | + .change_context(TmsError::IoError)? |
| 232 | + .permissions(); |
| 233 | + perms.set_mode(0o755); |
| 234 | + std::fs::set_permissions(hook_path, perms) |
| 235 | + .change_context(TmsError::IoError) |
| 236 | + .attach_printable("Failed to set hook permissions")?; |
| 237 | + } |
| 238 | + |
| 239 | + eprintln!("✓ Created default hook at {}", hook_path.display()); |
| 240 | + eprintln!(" Customize it: nvim {}", hook_path.display()); |
| 241 | + |
| 242 | + Ok(()) |
| 243 | +} |
| 244 | + |
| 245 | +/// Check if a file is executable |
| 246 | +#[cfg(unix)] |
| 247 | +fn is_executable(path: &PathBuf) -> bool { |
| 248 | + use std::os::unix::fs::PermissionsExt; |
| 249 | + std::fs::metadata(path) |
| 250 | + .map(|m| m.permissions().mode() & 0o111 != 0) |
| 251 | + .unwrap_or(false) |
| 252 | +} |
| 253 | + |
| 254 | +#[cfg(not(unix))] |
| 255 | +fn is_executable(_path: &PathBuf) -> bool { |
| 256 | + true |
| 257 | +} |
| 258 | + |
| 259 | +/// Handle creation of a new directory via the create hook |
| 260 | +fn create_new_directory(name: &str, config: &tms::configs::Config, tmux: &Tmux) -> Result<()> { |
| 261 | + // Convention: hook is always at ~/.config/tms/create-hook |
| 262 | + let hook_path = dirs::config_dir() |
| 263 | + .ok_or(TmsError::ConfigError) |
| 264 | + .attach_printable("Could not determine config directory")? |
| 265 | + .join("tms/create-hook"); |
| 266 | + |
| 267 | + // Ensure hook exists (create from template if needed) |
| 268 | + ensure_hook_exists(&hook_path)?; |
| 269 | + |
| 270 | + // Check if executable |
| 271 | + if !is_executable(&hook_path) { |
| 272 | + return Err(TmsError::ConfigError) |
| 273 | + .attach_printable(format!("Hook is not executable: {}", hook_path.display())) |
| 274 | + .attach_printable(format!("Run: chmod +x {}", hook_path.display())); |
| 275 | + } |
| 276 | + |
| 277 | + // Get search directories from config |
| 278 | + let search_dirs = config |
| 279 | + .search_dirs |
| 280 | + .as_ref() |
| 281 | + .ok_or(TmsError::ConfigError) |
| 282 | + .attach_printable("No search directories configured in config.toml")?; |
| 283 | + |
| 284 | + let search_paths: Vec<String> = search_dirs |
| 285 | + .iter() |
| 286 | + .map(|d| d.path.to_string_lossy().to_string()) |
| 287 | + .collect(); |
| 288 | + |
| 289 | + if search_paths.is_empty() { |
| 290 | + return Err(TmsError::ConfigError).attach_printable("search_dirs is empty in config.toml"); |
| 291 | + } |
| 292 | + |
| 293 | + // Execute hook: create-hook "name" "/path1" "/path2" ... |
| 294 | + // Inherit stderr so user sees progress messages, but capture stdout for directory name |
| 295 | + let output = Command::new(&hook_path) |
| 296 | + .arg(name) |
| 297 | + .args(&search_paths) |
| 298 | + .stderr(std::process::Stdio::inherit()) |
| 299 | + .output() |
| 300 | + .change_context(TmsError::IoError) |
| 301 | + .attach_printable("Failed to execute create hook")?; |
| 302 | + |
| 303 | + // Check exit status |
| 304 | + if !output.status.success() { |
| 305 | + return Err(TmsError::IoError) |
| 306 | + .attach_printable(format!( |
| 307 | + "Hook failed with exit code: {}", |
| 308 | + output.status.code().unwrap_or(-1) |
| 309 | + )) |
| 310 | + .attach_printable("Check hook output for details"); |
| 311 | + } |
| 312 | + |
| 313 | + // Get session name from hook's stdout, or fall back to the typed name |
| 314 | + let hook_output = String::from_utf8_lossy(&output.stdout); |
| 315 | + let session_name = hook_output.trim(); |
| 316 | + let session_name = if session_name.is_empty() { |
| 317 | + name |
| 318 | + } else { |
| 319 | + session_name |
| 320 | + }; |
| 321 | + |
| 322 | + // Re-discover sessions to find the one we just created |
| 323 | + let sessions = create_sessions(config)?; |
| 324 | + let session = sessions |
| 325 | + .find_session(session_name) |
| 326 | + .ok_or(TmsError::IoError) |
| 327 | + .attach_printable("Hook did not create a discoverable directory") |
| 328 | + .attach_printable(format!( |
| 329 | + "Expected to find a directory matching: {}", |
| 330 | + session_name |
| 331 | + ))?; |
| 332 | + |
| 333 | + // Open it using normal session flow |
| 334 | + session.switch_to(tmux, config)?; |
| 335 | + |
| 336 | + Ok(()) |
| 337 | +} |
0 commit comments