Skip to content

Commit 3793207

Browse files
committed
feat: add directory creation from picker via customizable hook
Adds ability to create new directories directly from the picker when typing a non-existent name. Changes: * Show hint when no matches found in picker * Add convention-based hook at ~/.config/tms/create-hook * Hook can return custom directory name via stdout * Default hook creates directory with git init * Enhanced hook example clones GitHub URLs * Auto-creates default hook on first use Hook contract: * Receives: typed name and search paths as arguments * Returns: optional directory name via stdout (defaults to typed name) * Must: create discoverable directory, exit 0 on success Example: Type 'https://github.com/user/repo' -> clones repo -> opens session 'repo'
1 parent b8d89b0 commit 3793207

File tree

4 files changed

+255
-4
lines changed

4 files changed

+255
-4
lines changed

create-hook

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
# tms create hook
3+
#
4+
# Called when you type a non-existent name in the tms picker.
5+
# Example: create-hook "my-app" "/home/user/code" "/home/user/work"
6+
#
7+
# Parameters:
8+
# $1 = name you typed
9+
# $2, $3... = search directories from your config
10+
#
11+
# Output (optional):
12+
# Print directory name to stdout to override session name
13+
# If no output, session name defaults to $1
14+
#
15+
# Must: Create a directory that tms can discover, exit 0 on success
16+
17+
set -e
18+
19+
NAME="$1"
20+
shift # Remove NAME from arguments
21+
# Find first valid search directory
22+
FIRST_DIR=""
23+
for dir in "$@"; do
24+
if [[ -d "$dir" && -w "$dir" ]]; then
25+
FIRST_DIR="$dir"
26+
break
27+
fi
28+
done
29+
30+
if [[ -z "$FIRST_DIR" ]]; then
31+
echo "Error: No writable search directory found" >&2
32+
exit 1
33+
fi
34+
35+
# Detect Git URL (GitHub, GitLab, etc.) and clone it
36+
if [[ "$NAME" =~ ^https?://[^/]+/([^/]+)/([^/]+)(\.git)?$ ]]; then
37+
REPO_NAME="${BASH_REMATCH[2]%.git}"
38+
TARGET_DIR="$FIRST_DIR/$REPO_NAME"
39+
40+
# Only clone if directory doesn't exist
41+
if [[ ! -d "$TARGET_DIR" ]]; then
42+
echo "Cloning $REPO_NAME..." >&2
43+
git clone "$NAME" "$TARGET_DIR" 2>&1 | sed 's/^/ /' >&2
44+
echo "Done!" >&2
45+
else
46+
echo "Directory $REPO_NAME already exists, opening..." >&2
47+
fi
48+
49+
echo "$REPO_NAME" # Tell tms the actual directory name
50+
exit 0
51+
fi
52+
53+
# Default: create new directory with git init
54+
DIR="$FIRST_DIR/$NAME"
55+
mkdir -p "$DIR"
56+
cd "$DIR"
57+
git init -q
58+
59+
# No output = use original name from picker
60+
61+
# Example: customize based on name pattern
62+
# if [[ "$NAME" == *-rs ]]; then
63+
# cargo init --name "${NAME%-rs}"
64+
# echo "${NAME%-rs}" # Return the actual directory name
65+
# fi

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: 143 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,137 @@ fn get_session_list(
143149
(all_sessions, None)
144150
}
145151
}
152+
153+
/// Default create hook template embedded in binary
154+
const DEFAULT_HOOK: &str = include_str!("../create-hook");
155+
156+
/// Ensure the create hook exists at the conventional location
157+
fn ensure_hook_exists(hook_path: &PathBuf) -> Result<()> {
158+
if hook_path.exists() {
159+
return Ok(());
160+
}
161+
162+
// Create parent directory if needed
163+
if let Some(parent) = hook_path.parent() {
164+
std::fs::create_dir_all(parent)
165+
.change_context(TmsError::IoError)
166+
.attach_printable(format!("Failed to create directory: {}", parent.display()))?;
167+
}
168+
169+
// Write default hook
170+
std::fs::write(hook_path, DEFAULT_HOOK)
171+
.change_context(TmsError::IoError)
172+
.attach_printable(format!("Failed to write hook: {}", hook_path.display()))?;
173+
174+
// Make executable on Unix
175+
#[cfg(unix)]
176+
{
177+
use std::os::unix::fs::PermissionsExt;
178+
let mut perms = std::fs::metadata(hook_path)
179+
.change_context(TmsError::IoError)?
180+
.permissions();
181+
perms.set_mode(0o755);
182+
std::fs::set_permissions(hook_path, perms)
183+
.change_context(TmsError::IoError)
184+
.attach_printable("Failed to set hook permissions")?;
185+
}
186+
187+
eprintln!("✓ Created default hook at {}", hook_path.display());
188+
eprintln!(" Customize it: nvim {}", hook_path.display());
189+
190+
Ok(())
191+
}
192+
193+
/// Check if a file is executable
194+
#[cfg(unix)]
195+
fn is_executable(path: &PathBuf) -> bool {
196+
use std::os::unix::fs::PermissionsExt;
197+
std::fs::metadata(path)
198+
.map(|m| m.permissions().mode() & 0o111 != 0)
199+
.unwrap_or(false)
200+
}
201+
202+
#[cfg(not(unix))]
203+
fn is_executable(_path: &PathBuf) -> bool {
204+
true
205+
}
206+
207+
/// Handle creation of a new directory via the create hook
208+
fn create_new_directory(name: &str, config: &tms::configs::Config, tmux: &Tmux) -> Result<()> {
209+
// Convention: hook is always at ~/.config/tms/create-hook
210+
let hook_path = dirs::config_dir()
211+
.ok_or(TmsError::ConfigError)
212+
.attach_printable("Could not determine config directory")?
213+
.join("tms/create-hook");
214+
215+
// Ensure hook exists (create from template if needed)
216+
ensure_hook_exists(&hook_path)?;
217+
218+
// Check if executable
219+
if !is_executable(&hook_path) {
220+
return Err(TmsError::ConfigError)
221+
.attach_printable(format!("Hook is not executable: {}", hook_path.display()))
222+
.attach_printable(format!("Run: chmod +x {}", hook_path.display()));
223+
}
224+
225+
// Get search directories from config
226+
let search_dirs = config
227+
.search_dirs
228+
.as_ref()
229+
.ok_or(TmsError::ConfigError)
230+
.attach_printable("No search directories configured in config.toml")?;
231+
232+
let search_paths: Vec<String> = search_dirs
233+
.iter()
234+
.map(|d| d.path.to_string_lossy().to_string())
235+
.collect();
236+
237+
if search_paths.is_empty() {
238+
return Err(TmsError::ConfigError).attach_printable("search_dirs is empty in config.toml");
239+
}
240+
241+
// Execute hook: create-hook "name" "/path1" "/path2" ...
242+
// Inherit stderr so user sees progress messages, but capture stdout for directory name
243+
let output = Command::new(&hook_path)
244+
.arg(name)
245+
.args(&search_paths)
246+
.stderr(std::process::Stdio::inherit())
247+
.output()
248+
.change_context(TmsError::IoError)
249+
.attach_printable("Failed to execute create hook")?;
250+
251+
// Check exit status
252+
if !output.status.success() {
253+
return Err(TmsError::IoError)
254+
.attach_printable(format!(
255+
"Hook failed with exit code: {}",
256+
output.status.code().unwrap_or(-1)
257+
))
258+
.attach_printable("Check hook output for details");
259+
}
260+
261+
// Get session name from hook's stdout, or fall back to the typed name
262+
let hook_output = String::from_utf8_lossy(&output.stdout);
263+
let session_name = hook_output.trim();
264+
let session_name = if session_name.is_empty() {
265+
name
266+
} else {
267+
session_name
268+
};
269+
270+
// Re-discover sessions to find the one we just created
271+
let sessions = create_sessions(config)?;
272+
let session = sessions
273+
.find_session(&session_name)
274+
.ok_or(TmsError::IoError)
275+
.attach_printable("Hook did not create a discoverable directory")
276+
.attach_printable(format!(
277+
"Expected to find a directory matching: {}",
278+
session_name
279+
))?;
280+
281+
// Open it using normal session flow
282+
session.switch_to(tmux, config)?;
283+
284+
Ok(())
285+
}

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)