Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -706,10 +706,12 @@ fn clone_repo_command(args: &CloneRepoCommand, config: Config, tmux: &Tmux) -> R
return Ok(());
};

let (_, repo_name) = args
let repo_name = args
.repository
.rsplit_once('/')
.expect("Repository path contains '/'");
.or_else(|| args.repository.rsplit_once(':'))
.map(|(_, name)| name)
.expect("Repository path contains '/' or ':'");
let repo_name = repo_name.trim_end_matches(".git");
path.push(repo_name);

Expand Down
9 changes: 9 additions & 0 deletions src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ impl Default for Keymap {
},
PickerAction::Confirm,
),
(
Key {
code: KeyCode::Enter,
modifiers: KeyModifiers::CONTROL,
},
PickerAction::ForceCreate,
),
(
Key {
code: KeyCode::Delete,
Expand Down Expand Up @@ -341,6 +348,8 @@ pub enum PickerAction {
Cancel,
#[serde(rename = "confirm")]
Confirm,
#[serde(rename = "force_create")]
ForceCreate,
#[serde(rename = "backspace")]
Backspace,
#[serde(rename = "delete")]
Expand Down
209 changes: 205 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use std::{collections::HashSet, env};
use std::{collections::HashSet, env, path::PathBuf, process::Command};

use clap::{CommandFactory, Parser};
use clap_complete::CompleteEnv;
use error_stack::Report;
use error_stack::{Report, ResultExt};

use tms::{
cli::{Cli, SubCommandGiven},
configs::SessionSortOrderConfig,
error::{Result, Suggestion},
error::{Result, Suggestion, TmsError},
session::{create_sessions, SessionContainer},
tmux::Tmux,
};
Expand Down Expand Up @@ -68,6 +68,12 @@ fn main() -> Result<()> {
return Ok(());
};

// Check if user wants to create a new directory
if let Some(name) = selected_str.strip_prefix("__TMS_CREATE_NEW__:") {
create_new_directory(name, &config, &tmux)?;
return Ok(());
}

if let Some(session) = sessions.find_session(&selected_str) {
session.switch_to(&tmux, &config)?;
}
Expand All @@ -91,7 +97,7 @@ fn get_session_list(
) {
// Get active sessions from tmux with timestamps, excluding the currently attached one
let active_sessions_raw =
tmux.list_sessions("'#{?session_attached,,#{session_name},#{session_last_attached}}'");
tmux.list_sessions("'#{?session_attached,,#{session_name}#,#{session_last_attached}}'");

// Parse into (name, timestamp) pairs
let active_sessions: Vec<(&str, i64)> = active_sessions_raw
Expand Down Expand Up @@ -143,3 +149,198 @@ fn get_session_list(
(all_sessions, None)
}
}

/// Default create hook template embedded in binary
const DEFAULT_HOOK: &str = r#"#!/usr/bin/env bash
# tms create hook
#
# Called when you type a non-existent name in the tms picker.
# Example: create-hook "my-app" "/home/user/code" "/home/user/work"
#
# Parameters:
# $1 = name you typed
# $2, $3... = search directories from your config
#
# Output (optional):
# Print directory name to stdout to override session name
# If no output, session name defaults to $1
#
# Must: Create a directory that tms can discover, exit 0 on success

set -e

NAME="$1"
FIRST_DIR="$2"

# Detect Git URL (GitHub, GitLab, etc.) and clone it
# HTTPS: https://github.com/user/repo.git
# SSH: git@github.com:user/repo.git
if [[ "$NAME" =~ ^https?://[^/]+/([^/]+)/([^/]+)(\.git)?$ ]]; then
REPO_NAME="${BASH_REMATCH[2]%.git}"
elif [[ "$NAME" =~ ^git@[^:]+:([^/]+)/([^/]+)(\.git)?$ ]]; then
REPO_NAME="${BASH_REMATCH[2]%.git}"
else
REPO_NAME=""
fi

if [[ -n "$REPO_NAME" ]]; then
TARGET_DIR="$FIRST_DIR/$REPO_NAME"

# Only clone if directory doesn't exist
if [[ ! -d "$TARGET_DIR" ]]; then
echo "Cloning $REPO_NAME..." >&2
git clone "$NAME" "$TARGET_DIR" 2>&1 | sed 's/^/ /' >&2
echo "Done!" >&2
else
echo "Directory $REPO_NAME already exists, opening..." >&2
fi

echo "$REPO_NAME" # Tell tms the actual directory name
exit 0
fi

# Default: create new directory with git init
DIR="$FIRST_DIR/$NAME"
mkdir -p "$DIR"
cd "$DIR"
git init -q

# No output = use original name from picker

# Example: customize based on name pattern
# if [[ "$NAME" == *-rs ]]; then
# cargo init --name "${NAME%-rs}"
# echo "${NAME%-rs}" # Return the actual directory name
# fi
"#;

/// Ensure the create hook exists at the conventional location
fn ensure_hook_exists(hook_path: &PathBuf) -> Result<()> {
if hook_path.exists() {
return Ok(());
}

// Create parent directory if needed
if let Some(parent) = hook_path.parent() {
std::fs::create_dir_all(parent)
.change_context(TmsError::IoError)
.attach_printable(format!("Failed to create directory: {}", parent.display()))?;
}

// Write default hook
std::fs::write(hook_path, DEFAULT_HOOK)
.change_context(TmsError::IoError)
.attach_printable(format!("Failed to write hook: {}", hook_path.display()))?;

// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(hook_path)
.change_context(TmsError::IoError)?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(hook_path, perms)
.change_context(TmsError::IoError)
.attach_printable("Failed to set hook permissions")?;
}

eprintln!("✓ Created default hook at {}", hook_path.display());
eprintln!(" Customize it: nvim {}", hook_path.display());

Ok(())
}

/// Check if a file is executable
#[cfg(unix)]
fn is_executable(path: &PathBuf) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable(_path: &PathBuf) -> bool {
true
}

/// Handle creation of a new directory via the create hook
fn create_new_directory(name: &str, config: &tms::configs::Config, tmux: &Tmux) -> Result<()> {
// Convention: hook is always at ~/.config/tms/create-hook
let hook_path = dirs::config_dir()
.ok_or(TmsError::ConfigError)
.attach_printable("Could not determine config directory")?
.join("tms/create-hook");

// Ensure hook exists (create from template if needed)
ensure_hook_exists(&hook_path)?;

// Check if executable
if !is_executable(&hook_path) {
return Err(TmsError::ConfigError)
.attach_printable(format!("Hook is not executable: {}", hook_path.display()))
.attach_printable(format!("Run: chmod +x {}", hook_path.display()));
}

// Get search directories from config
let search_dirs = config
.search_dirs
.as_ref()
.ok_or(TmsError::ConfigError)
.attach_printable("No search directories configured in config.toml")?;

let search_paths: Vec<String> = search_dirs
.iter()
.map(|d| d.path.to_string_lossy().to_string())
.collect();

if search_paths.is_empty() {
return Err(TmsError::ConfigError).attach_printable("search_dirs is empty in config.toml");
}

// Execute hook: create-hook "name" "/path1" "/path2" ...
// Inherit stderr so user sees progress messages, but capture stdout for directory name
let output = Command::new(&hook_path)
.arg(name)
.args(&search_paths)
.stderr(std::process::Stdio::inherit())
.output()
.change_context(TmsError::IoError)
.attach_printable("Failed to execute create hook")?;

// Check exit status
if !output.status.success() {
return Err(TmsError::IoError)
.attach_printable(format!(
"Hook failed with exit code: {}",
output.status.code().unwrap_or(-1)
))
.attach_printable("Check hook output for details");
}

// Get session name from hook's stdout, or fall back to the typed name
let hook_output = String::from_utf8_lossy(&output.stdout);
let session_name = hook_output.trim();
let session_name = if session_name.is_empty() {
name
} else {
session_name
};

// Re-discover sessions to find the one we just created
let sessions = create_sessions(config)?;
let session = sessions
.find_session(session_name)
.ok_or(TmsError::IoError)
.attach_printable("Hook did not create a discoverable directory")
.attach_printable(format!(
"Expected to find a directory matching: {}",
session_name
))?;

// Open it using normal session flow
session.switch_to(tmux, config)?;

Ok(())
}
39 changes: 38 additions & 1 deletion src/picker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ impl<'a> Picker<'a> {
Some(PickerAction::Confirm) => {
if let Some(selected) = self.get_selected() {
return Ok(Some(selected.to_owned()));
} else if !self.filter.is_empty() {
// No matches but user has typed something - offer to create new repo
return Ok(Some(format!("__TMS_CREATE_NEW__:{}", self.filter)));
}
}
Some(PickerAction::ForceCreate) => {
// Force directory creation even if matches exist
if !self.filter.is_empty() {
return Ok(Some(format!("__TMS_CREATE_NEW__:{}", self.filter)));
}
}
Some(PickerAction::Backspace) => self.remove_filter(),
Expand Down Expand Up @@ -272,7 +281,35 @@ impl<'a> Picker<'a> {

let prompt = Span::styled("> ", Style::default().fg(colors.prompt_color()));
let input_text = Span::raw(&self.filter);
let input_line = Line::from(vec![prompt, input_text]);
let mut spans = vec![prompt, input_text];

// Show hint for directory creation based on match state
if !self.filter.is_empty() {
let hint_text = if snapshot.matched_item_count() == 0 {
// No matches - Enter creates
format!(" [↵: create {}]", self.filter)
} else if let Some(selected_name) = self.get_selected() {
// Has matches - show both options with truncated names
let create_name = if self.filter.len() > 15 {
format!("{}…", &self.filter[..14])
} else {
self.filter.clone()
};
let select_name = if selected_name.len() > 15 {
format!("{}…", &selected_name[..14])
} else {
selected_name.to_string()
};
format!(" [^↵: create {}, ↵: open {}]", create_name, select_name)
} else {
// Has matches but none selected
" [^↵: create]".to_string()
};
let hint = Span::styled(hint_text, Style::default().fg(colors.info_color()));
spans.push(hint);
}

let input_line = Line::from(spans);
let input = Paragraph::new(vec![input_line]);
f.render_widget(input, layout[input_index]);
f.set_cursor_position(layout::Position {
Expand Down