1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
//! Tag domain state: per-host tag tracking and bulk-tag-editor model.
use crate::app::host_state::GroupBy;
use crate::ssh_config::model::HostEntry;
/// A display tag with its source (user-defined or provider-synced).
#[derive(Debug, Clone, PartialEq)]
pub struct DisplayTag {
pub name: String,
pub is_user: bool,
}
/// Select up to 3 tags for display based on view mode and grouping.
/// Returns a Vec of up to 3 DisplayTags (user tags first, then provider tags).
///
/// In grouped views the tag matching the group criterion is suppressed
/// (it lives in the group header). Non-matching provider tags and the
/// provider name itself stay visible.
pub fn select_display_tags(
host: &HostEntry,
group_by: &GroupBy,
detail_mode: bool,
) -> Vec<DisplayTag> {
let group_name = match group_by {
GroupBy::Provider => host.provider.clone(),
GroupBy::Tag(t) => Some(t.clone()),
GroupBy::None => None,
};
let not_group = |t: &str| {
group_name
.as_ref()
.is_none_or(|g| !t.eq_ignore_ascii_case(g))
};
let user_tags = host
.tags
.iter()
.filter(|t| not_group(t))
.map(|t| DisplayTag {
name: t.to_string(),
is_user: true,
});
let provider_tags = host
.provider_tags
.iter()
.filter(|t| not_group(t))
.chain(host.provider.iter().filter(|p| not_group(p)))
.map(|t| DisplayTag {
name: t.to_string(),
is_user: false,
});
let limit = if detail_mode { 1 } else { 3 };
user_tags.chain(provider_tags).take(limit).collect()
}
/// Tag editor state.
#[derive(Default)]
pub struct TagState {
pub(in crate::app) input: Option<String>,
pub(in crate::app) cursor: usize,
pub(in crate::app) list: Vec<String>,
}
impl TagState {
/// Open the inline tag-edit input on the host detail screen with the
/// given seed text. Cursor lands at the end of the text so users can
/// type extra tags without re-positioning.
pub(crate) fn open_tag_input(&mut self, text: String) {
self.cursor = text.chars().count();
self.input = Some(text);
}
/// Close the inline tag-edit input. Called on both Enter (after the
/// submit hits disk) and Esc (cancel) so the two fields cannot drift
/// out of sync.
pub(crate) fn close_tag_input(&mut self) {
self.input = None;
self.cursor = 0;
}
pub fn input(&self) -> Option<&str> {
self.input.as_deref()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn list(&self) -> &[String] {
&self.list
}
pub fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_right(&mut self) {
if let Some(ref input) = self.input {
if self.cursor < input.chars().count() {
self.cursor += 1;
}
}
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
if let Some(ref input) = self.input {
self.cursor = input.chars().count();
}
}
/// Insert a char at the cursor position and advance the cursor.
/// No-op when the input is not active.
pub fn insert_char(&mut self, c: char) {
if let Some(ref mut input) = self.input {
let byte_pos = super::char_to_byte_pos(input, self.cursor);
input.insert(byte_pos, c);
self.cursor += 1;
}
}
/// Delete the char left of the cursor. No-op when cursor is 0 or input is inactive.
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
if let Some(ref mut input) = self.input {
let byte_pos = super::char_to_byte_pos(input, self.cursor);
let prev = super::char_to_byte_pos(input, self.cursor - 1);
input.drain(prev..byte_pos);
self.cursor -= 1;
}
}
}
/// User action per tag row in the bulk tag editor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BulkTagAction {
/// `[~]` Leave each host's state for this tag unchanged.
Leave,
/// `[x]` Ensure the tag is present on every selected host.
AddToAll,
/// `[ ]` Ensure the tag is absent from every selected host.
RemoveFromAll,
}
impl BulkTagAction {
/// 3-way cycle: `Leave` → `AddToAll` → `RemoveFromAll` → `Leave`.
pub fn cycle(self) -> Self {
match self {
BulkTagAction::Leave => BulkTagAction::AddToAll,
BulkTagAction::AddToAll => BulkTagAction::RemoveFromAll,
BulkTagAction::RemoveFromAll => BulkTagAction::Leave,
}
}
pub fn glyph(self) -> &'static str {
match self {
BulkTagAction::Leave => "[~]",
BulkTagAction::AddToAll => "[x]",
BulkTagAction::RemoveFromAll => "[ ]",
}
}
}
/// A single row in the bulk tag editor.
#[derive(Debug, Clone)]
pub struct BulkTagRow {
pub tag: String,
/// Number of selected hosts that had this tag at editor open time.
pub initial_count: usize,
pub action: BulkTagAction,
}
/// Snapshot state for the bulk tag editor overlay.
#[derive(Debug, Default)]
pub struct BulkTagEditorState {
pub rows: Vec<BulkTagRow>,
/// Aliases being edited, snapshot at open time so selection changes
/// during the flow do not affect the in-progress edit.
pub aliases: Vec<String>,
/// Aliases that live in an Include file and cannot be edited in place.
/// Surfaced in the header so the user sees the blast radius.
pub skipped_included: Vec<String>,
/// Draft name for a brand-new tag being typed by the user. `None` when
/// the input bar is inactive. Newly entered tags are appended to `rows`
/// with `action = AddToAll`.
pub new_tag_input: Option<String>,
pub new_tag_cursor: usize,
/// Snapshot of `rows[i].action` at editor open time. Used by `is_dirty`
/// to detect pending changes on Esc and prompt the user before
/// discarding. Captured by the opener (e.g. `App::open_bulk_tag_editor`)
/// after `rows` is populated.
///
/// Length-mismatch semantics: any extra row beyond the baseline length
/// (i.e. a newly added tag via `+`) counts as dirty if its action is
/// non-Leave. This matches the user's intuition that "I typed a new tag,
/// closing now should warn me".
pub initial_actions: Vec<BulkTagAction>,
}
impl BulkTagEditorState {
/// Returns true if any row's action differs from the open-time baseline,
/// or if rows have been added since open.
///
/// Single source of truth for the dirty check. The handler consults this
/// on Esc to decide between immediate exit and discard confirmation.
/// Every editable surface gets a dirty-check so Esc never drops unsaved
/// work.
///
/// **Invariant**: rows is append-only after `open_bulk_tag_editor`
/// captures the baseline. The `+ new tag` flow only appends to `rows`;
/// no code path removes rows during the editor session. If a future
/// change introduces row removal, the length-mismatch branch below will
/// silently treat the missing baseline rows as clean (because `zip`
/// stops at the shorter slice). At that point this method needs an
/// explicit shrink branch; the assertion below guards the assumption.
pub fn is_dirty(&self) -> bool {
debug_assert!(
self.rows.len() >= self.initial_actions.len(),
"rows must be append-only after baseline capture; \
shorter rows breaks the dirty-check"
);
if self.rows.len() != self.initial_actions.len() {
// Tags added since open. New rows count as dirty unless still Leave.
return self
.rows
.iter()
.skip(self.initial_actions.len())
.any(|r| r.action != BulkTagAction::Leave)
|| self
.rows
.iter()
.zip(self.initial_actions.iter())
.any(|(r, baseline)| r.action != *baseline);
}
self.rows
.iter()
.zip(self.initial_actions.iter())
.any(|(r, baseline)| r.action != *baseline)
}
}
/// Outcome of applying a bulk tag edit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BulkTagApplyResult {
/// Hosts whose tag list actually changed.
pub changed_hosts: usize,
/// Total (host, tag) additions.
pub added: usize,
/// Total (host, tag) removals.
pub removed: usize,
/// Hosts skipped because they live in an Include file.
pub skipped_included: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_tag_input_seeds_text_and_parks_cursor_at_end() {
let mut t = TagState::default();
t.open_tag_input("prod, web".to_string());
assert_eq!(t.input.as_deref(), Some("prod, web"));
assert_eq!(t.cursor, "prod, web".chars().count());
}
#[test]
fn open_tag_input_with_empty_text_lands_cursor_at_zero() {
let mut t = TagState::default();
t.open_tag_input(String::new());
assert_eq!(t.input.as_deref(), Some(""));
assert_eq!(t.cursor, 0);
}
#[test]
fn open_tag_input_counts_chars_not_bytes() {
// Cursor units are character positions; multi-byte text must not
// produce a byte-offset cursor (host_detail handler indexes by
// chars when converting to byte positions).
let mut t = TagState::default();
t.open_tag_input("café".to_string());
assert_eq!(t.cursor, 4);
}
#[test]
fn close_tag_input_clears_both_fields() {
let mut t = TagState::default();
t.open_tag_input("staging".to_string());
t.close_tag_input();
assert!(t.input.is_none());
assert_eq!(t.cursor, 0);
}
#[test]
fn close_tag_input_on_idle_state_is_noop() {
let mut t = TagState::default();
t.close_tag_input();
assert!(t.input.is_none());
assert_eq!(t.cursor, 0);
}
#[test]
fn close_tag_input_does_not_touch_picker_list() {
// The `list` field powers the tag picker overlay and lives
// independently of the inline tag-edit input.
let mut t = TagState {
list: vec!["prod".to_string(), "web".to_string()],
..Default::default()
};
t.open_tag_input("staging".to_string());
t.close_tag_input();
assert_eq!(t.list, vec!["prod".to_string(), "web".to_string()]);
}
}