feat(maestro): rename the highlighted session from the TUI sidebar
`r` opens a prompt pre-filled with the session's own name — empty when it has none, so Enter cannot pin the command as the name. Enter saves, Escape cancels, and an emptied field clears the name. Refs #125 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
345650172b
commit
bdeec6eff5
@@ -48,6 +48,9 @@ pub enum Action {
|
|||||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||||
FastForwardSpawn,
|
FastForwardSpawn,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||||
|
/// its current name.
|
||||||
|
RenameHighlighted,
|
||||||
/// Open the retained-worktrees view (#85).
|
/// Open the retained-worktrees view (#85).
|
||||||
ShowWorktrees,
|
ShowWorktrees,
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ impl Action {
|
|||||||
Action::NewDraftPane => "Create a session".into(),
|
Action::NewDraftPane => "Create a session".into(),
|
||||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
Action::KillHighlighted => "Kill selected session".into(),
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
Action::ShowWorktrees => "Show retained worktrees".into(),
|
Action::ShowWorktrees => "Show retained worktrees".into(),
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
@@ -118,6 +122,7 @@ impl Action {
|
|||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
@@ -223,10 +228,24 @@ mod tests {
|
|||||||
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_action_round_trips() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("RenameHighlighted").unwrap(),
|
||||||
|
Action::RenameHighlighted
|
||||||
|
);
|
||||||
|
assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted");
|
||||||
|
assert_eq!(
|
||||||
|
Action::RenameHighlighted.help_label(),
|
||||||
|
"Rename selected session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::NewDraftPane,
|
Action::NewDraftPane,
|
||||||
Action::ScrollPanePageDown,
|
Action::ScrollPanePageDown,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
|||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||||
|
use crate::text_input::TextInput;
|
||||||
use crate::worktrees::WorktreesView;
|
use crate::worktrees::WorktreesView;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ pub struct App {
|
|||||||
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
||||||
/// rather than in `AppState` so the overlay kind stays payload-free.
|
/// rather than in `AppState` so the overlay kind stays payload-free.
|
||||||
worktrees: WorktreesView,
|
worktrees: WorktreesView,
|
||||||
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||||
|
/// same reason the worktree entries are.
|
||||||
|
rename_input: TextInput,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -123,6 +127,12 @@ pub enum Effect {
|
|||||||
/// Reclaim one retained worktree (#85) — removes its directory; the
|
/// Reclaim one retained worktree (#85) — removes its directory; the
|
||||||
/// branch is never deleted.
|
/// branch is never deleted.
|
||||||
ReclaimWorktree(Uuid),
|
ReclaimWorktree(Uuid),
|
||||||
|
/// Rename a session (#125). An empty name clears the custom one, so the
|
||||||
|
/// card falls back to the command.
|
||||||
|
Rename {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -148,6 +158,7 @@ impl App {
|
|||||||
daemon,
|
daemon,
|
||||||
http,
|
http,
|
||||||
worktrees: WorktreesView::default(),
|
worktrees: WorktreesView::default(),
|
||||||
|
rename_input: TextInput::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -284,6 +295,7 @@ impl App {
|
|||||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||||
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
||||||
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
||||||
|
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||||
Effect::Forward { id, bytes } => {
|
Effect::Forward { id, bytes } => {
|
||||||
if let Some(handle) = self.emulators.get(id) {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
@@ -306,6 +318,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
},
|
},
|
||||||
|
Some(OverlayKind::Rename(id)) => match key {
|
||||||
|
InputKey::Enter => {
|
||||||
|
let name = self.rename_input.value().to_string();
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, vec![Effect::Rename { id, name }])
|
||||||
|
}
|
||||||
|
InputKey::Esc => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
self.rename_input.handle_key(other);
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
},
|
||||||
Some(OverlayKind::Worktrees) => match key {
|
Some(OverlayKind::Worktrees) => match key {
|
||||||
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
||||||
self.worktrees.move_highlight(1);
|
self.worktrees.move_highlight(1);
|
||||||
@@ -430,6 +457,23 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::RenameHighlighted => {
|
||||||
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
|
// Pre-fill with the custom name only: a session that has
|
||||||
|
// none starts empty rather than with its command already
|
||||||
|
// typed in, which Enter would then pin as the name.
|
||||||
|
self.rename_input = TextInput::with_value(
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == sid)
|
||||||
|
.and_then(crate::sidebar::custom_name_for)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
self.state.show_overlay(OverlayKind::Rename(sid));
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::ShowWorktrees => {
|
Action::ShowWorktrees => {
|
||||||
self.state.show_overlay(OverlayKind::Worktrees);
|
self.state.show_overlay(OverlayKind::Worktrees);
|
||||||
effects.push(Effect::LoadWorktrees);
|
effects.push(Effect::LoadWorktrees);
|
||||||
@@ -928,6 +972,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a session (#125). The catalog updates from the daemon's
|
||||||
|
/// `SessionUpdated` broadcast, so nothing is patched locally here.
|
||||||
|
async fn submit_rename(&mut self, sid: Uuid, name: &str) {
|
||||||
|
if let Err(e) = self.daemon.rename_session(sid, name).await {
|
||||||
|
self.state.toast(format!("rename failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
||||||
/// same off-loop pattern as the draft loaders.
|
/// same off-loop pattern as the draft loaders.
|
||||||
fn spawn_load_worktrees(&self) {
|
fn spawn_load_worktrees(&self) {
|
||||||
@@ -1008,6 +1060,7 @@ impl App {
|
|||||||
draft,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
worktrees: Some(&self.worktrees),
|
worktrees: Some(&self.worktrees),
|
||||||
|
rename_input: &self.rename_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_region = RegionMap::default();
|
let mut new_region = RegionMap::default();
|
||||||
@@ -2035,6 +2088,117 @@ mod tests {
|
|||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- rename (#125) ------------------------------------------------------
|
||||||
|
|
||||||
|
fn seed_session(app: &mut App, sid: Uuid, name: Option<&str>) {
|
||||||
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
|
let metadata = match name {
|
||||||
|
Some(n) => {
|
||||||
|
HashMap::from([("name".to_string(), serde_json::Value::String(n.to_string()))])
|
||||||
|
}
|
||||||
|
None => Default::default(),
|
||||||
|
};
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: sid,
|
||||||
|
command: "bash".into(),
|
||||||
|
cwd: std::path::PathBuf::from("/"),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata,
|
||||||
|
worktree: None,
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_highlighted_opens_a_prompt_prefilled_with_the_current_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("amber-falcon"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Rename(s)) if *s == sid));
|
||||||
|
assert_eq!(app.rename_input.value(), "amber-falcon");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session with no name of its own opens an empty field: pre-filling
|
||||||
|
/// with the command would let Enter pin the command as the name.
|
||||||
|
#[test]
|
||||||
|
fn rename_prompt_starts_empty_for_an_unnamed_session() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_session(&mut app, Uuid::from_u128(7), None);
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
assert_eq!(app.rename_input.value(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_then_enter_submits_the_edited_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("amber"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
for c in "-fox".chars() {
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
||||||
|
c,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(
|
||||||
|
effects,
|
||||||
|
vec![Effect::Rename {
|
||||||
|
id: sid,
|
||||||
|
name: "amber-fox".into()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_cancels_the_rename_without_an_effect() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_session(&mut app, Uuid::from_u128(7), Some("amber"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clearing the field and submitting is how the name is dropped, so the
|
||||||
|
/// card falls back to the command.
|
||||||
|
#[test]
|
||||||
|
fn an_emptied_field_submits_a_blank_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("ab"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
app.handle_overlay_input(&InputKey::Backspace);
|
||||||
|
app.handle_overlay_input(&InputKey::Backspace);
|
||||||
|
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(
|
||||||
|
effects,
|
||||||
|
vec![Effect::Rename {
|
||||||
|
id: sid,
|
||||||
|
name: String::new()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -- retained worktrees (#85) -------------------------------------------
|
// -- retained worktrees (#85) -------------------------------------------
|
||||||
|
|
||||||
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
|
("r", "RenameHighlighted"),
|
||||||
("w", "ShowWorktrees"),
|
("w", "ShowWorktrees"),
|
||||||
// Focus
|
// Focus
|
||||||
("ctrl-w h", "FocusSidebar"),
|
("ctrl-w h", "FocusSidebar"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay,
|
||||||
|
//! transient toast.
|
||||||
//!
|
//!
|
||||||
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||||
//! panes ([`crate::draft::DraftPane`]).
|
//! panes ([`crate::draft::DraftPane`]).
|
||||||
@@ -7,10 +8,13 @@
|
|||||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::text_input::TextInput;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -39,6 +43,50 @@ impl KillConfirm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename prompt for the highlighted session (#125).
|
||||||
|
///
|
||||||
|
/// Borrows its text buffer rather than owning it: the buffer lives on `App`
|
||||||
|
/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`]
|
||||||
|
/// stays a plain comparable enum.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RenamePrompt<'a> {
|
||||||
|
/// What the card shows today — the session's name, or the command it
|
||||||
|
/// falls back to when it has none.
|
||||||
|
pub current: &'a str,
|
||||||
|
pub input: &'a TextInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenamePrompt<'_> {
|
||||||
|
/// Draws the prompt and returns where the terminal cursor belongs.
|
||||||
|
pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> {
|
||||||
|
let area = centered(parent, 64, 7);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::default().title(" rename ").borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
if inner.height < 4 || inner.width == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let row = |offset: u16| Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y + offset,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(format!("Rename {}", self.current))),
|
||||||
|
row(0),
|
||||||
|
);
|
||||||
|
let field = row(1);
|
||||||
|
let col = self.input.render(field, f.buffer_mut(), Style::default());
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from("[enter] save [esc] cancel")),
|
||||||
|
row(3),
|
||||||
|
);
|
||||||
|
Some((col, field.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HelpOverlay {
|
pub struct HelpOverlay {
|
||||||
pub lines: Vec<String>,
|
pub lines: Vec<String>,
|
||||||
@@ -151,6 +199,22 @@ mod tests {
|
|||||||
assert!(rendered.contains("refused"), "{rendered}");
|
assert!(rendered.contains("refused"), "{rendered}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_prompt_shows_the_current_name_and_the_edit_buffer() {
|
||||||
|
let input = TextInput::with_value("crimson-otter");
|
||||||
|
let prompt = RenamePrompt {
|
||||||
|
current: "amber-falcon",
|
||||||
|
input: &input,
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(70, 12, |f| {
|
||||||
|
prompt.render(f, f.area());
|
||||||
|
});
|
||||||
|
assert!(rendered.contains("Rename amber-falcon"), "{rendered}");
|
||||||
|
assert!(rendered.contains("crimson-otter"), "{rendered}");
|
||||||
|
assert!(rendered.contains("[enter] save"), "{rendered}");
|
||||||
|
assert!(rendered.contains("[esc] cancel"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_overlay_lists_lines() {
|
fn help_overlay_lists_lines() {
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
|||||||
|
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
|
||||||
use crate::palette;
|
use crate::palette;
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
@@ -84,6 +84,8 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Retained-worktree entries for the worktrees view (#85).
|
/// Retained-worktree entries for the worktrees view (#85).
|
||||||
pub worktrees: Option<&'a WorktreesView>,
|
pub worktrees: Option<&'a WorktreesView>,
|
||||||
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||||
@@ -170,6 +172,23 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
kc.render(f, area);
|
kc.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
|
Some(OverlayKind::Rename(id)) => {
|
||||||
|
let current = inputs
|
||||||
|
.sidebar
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.id == id)
|
||||||
|
.map(crate::sidebar::name_for)
|
||||||
|
.unwrap_or_else(|| id.to_string());
|
||||||
|
let prompt = RenamePrompt {
|
||||||
|
current: ¤t,
|
||||||
|
input: inputs.rename_input,
|
||||||
|
};
|
||||||
|
if let Some((x, y)) = prompt.render(f, area) {
|
||||||
|
f.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -354,6 +373,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
worktrees: None,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -407,6 +427,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
worktrees: None,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
@@ -446,6 +467,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
worktrees: None,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
|||||||
@@ -382,10 +382,15 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
|||||||
|
|
||||||
/// `metadata.name` if present, else the command line.
|
/// `metadata.name` if present, else the command line.
|
||||||
pub(crate) fn name_for(s: &Session) -> String {
|
pub(crate) fn name_for(s: &Session) -> String {
|
||||||
if let Some(serde_json::Value::String(name)) = s.metadata.get("name") {
|
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
||||||
name.clone()
|
}
|
||||||
} else {
|
|
||||||
s.command.clone()
|
/// The session's own name, without the command fallback (#125): `None` for a
|
||||||
|
/// session that was never given one.
|
||||||
|
pub(crate) fn custom_name_for(s: &Session) -> Option<String> {
|
||||||
|
match s.metadata.get("name") {
|
||||||
|
Some(serde_json::Value::String(name)) => Some(name.clone()),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ impl Tab {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OverlayKind {
|
pub enum OverlayKind {
|
||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
|
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
||||||
|
/// like the worktrees view's entries, so this stays comparable.
|
||||||
|
Rename(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
/// The retained-worktrees view (#85); its entries live on `App` because
|
/// The retained-worktrees view (#85); its entries live on `App` because
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
|||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "NewDraftPane"
|
"s" = "NewDraftPane"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
|
"r" = "RenameHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
"ctrl-w h" = "FocusSidebar"
|
"ctrl-w h" = "FocusSidebar"
|
||||||
"enter" = "AttachHighlighted"
|
"enter" = "AttachHighlighted"
|
||||||
|
|||||||
Reference in New Issue
Block a user