Skip to content

Commit ba8553e

Browse files
committed
feat: add directory creation from picker via customizable hook
Create directories directly from the picker. Hook template embedded in binary, written to ~/.config/tms/create-hook on first use. Features: * Dynamic hints: [^↵: create foo, ↵: open bar] * Ctrl+Enter to force creation when matches exist * GitHub/GitLab URL cloning with progress messages * Hook stdout for custom directory names * Auto-creates hook template on first use
1 parent b8d89b0 commit ba8553e

File tree

3 files changed

+242
-4
lines changed

3 files changed

+242
-4
lines changed

src/keymap.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,13 @@ impl Default for Keymap {
214214
},
215215
PickerAction::Confirm,
216216
),
217+
(
218+
Key {
219+
code: KeyCode::Enter,
220+
modifiers: KeyModifiers::CONTROL,
221+
},
222+
PickerAction::ForceCreate,
223+
),
217224
(
218225
Key {
219226
code: KeyCode::Delete,
@@ -341,6 +348,8 @@ pub enum PickerAction {
341348
Cancel,
342349
#[serde(rename = "confirm")]
343350
Confirm,
351+
#[serde(rename = "force_create")]
352+
ForceCreate,
344353
#[serde(rename = "backspace")]
345354
Backspace,
346355
#[serde(rename = "delete")]

src/main.rs

Lines changed: 195 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
use std::{collections::HashSet, env};
1+
use std::{collections::HashSet, env, path::PathBuf, process::Command};
22

33
use clap::{CommandFactory, Parser};
44
use clap_complete::CompleteEnv;
5-
use error_stack::Report;
5+
use error_stack::{Report, ResultExt};
66

77
use tms::{
88
cli::{Cli, SubCommandGiven},
99
configs::SessionSortOrderConfig,
10-
error::{Result, Suggestion},
10+
error::{Result, Suggestion, TmsError},
1111
session::{create_sessions, SessionContainer},
1212
tmux::Tmux,
1313
};
@@ -68,6 +68,12 @@ fn main() -> Result<()> {
6868
return Ok(());
6969
};
7070

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+
7177
if let Some(session) = sessions.find_session(&selected_str) {
7278
session.switch_to(&tmux, &config)?;
7379
}
@@ -143,3 +149,189 @@ fn get_session_list(
143149
(all_sessions, None)
144150
}
145151
}
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+
}

src/picker/mod.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ impl<'a> Picker<'a> {
130130
Some(PickerAction::Confirm) => {
131131
if let Some(selected) = self.get_selected() {
132132
return Ok(Some(selected.to_owned()));
133+
} else if !self.filter.is_empty() {
134+
// No matches but user has typed something - offer to create new repo
135+
return Ok(Some(format!("__TMS_CREATE_NEW__:{}", self.filter)));
136+
}
137+
}
138+
Some(PickerAction::ForceCreate) => {
139+
// Force directory creation even if matches exist
140+
if !self.filter.is_empty() {
141+
return Ok(Some(format!("__TMS_CREATE_NEW__:{}", self.filter)));
133142
}
134143
}
135144
Some(PickerAction::Backspace) => self.remove_filter(),
@@ -272,7 +281,35 @@ impl<'a> Picker<'a> {
272281

273282
let prompt = Span::styled("> ", Style::default().fg(colors.prompt_color()));
274283
let input_text = Span::raw(&self.filter);
275-
let input_line = Line::from(vec![prompt, input_text]);
284+
let mut spans = vec![prompt, input_text];
285+
286+
// Show hint for directory creation based on match state
287+
if !self.filter.is_empty() {
288+
let hint_text = if snapshot.matched_item_count() == 0 {
289+
// No matches - Enter creates
290+
format!(" [↵: create {}]", self.filter)
291+
} else if let Some(selected_name) = self.get_selected() {
292+
// Has matches - show both options with truncated names
293+
let create_name = if self.filter.len() > 15 {
294+
format!("{}…", &self.filter[..14])
295+
} else {
296+
self.filter.clone()
297+
};
298+
let select_name = if selected_name.len() > 15 {
299+
format!("{}…", &selected_name[..14])
300+
} else {
301+
selected_name.to_string()
302+
};
303+
format!(" [^↵: create {}, ↵: open {}]", create_name, select_name)
304+
} else {
305+
// Has matches but none selected
306+
" [^↵: create]".to_string()
307+
};
308+
let hint = Span::styled(hint_text, Style::default().fg(colors.info_color()));
309+
spans.push(hint);
310+
}
311+
312+
let input_line = Line::from(spans);
276313
let input = Paragraph::new(vec![input_line]);
277314
f.render_widget(input, layout[input_index]);
278315
f.set_cursor_position(layout::Position {

0 commit comments

Comments
 (0)