Skip to content

Commit 87d06de

Browse files
committed
feat(ui): Add keybinds side panel + richer Users/Groups details; Shift+K toggle
- Keybindings - New right-side keybinds panel with grouped, aligned columns; bold titles, italic dynamic values; visible separator. - Removed inline header hints; header background now matches app bg. - Expose `Keymap::all_bindings` and `Keymap::format_key`; `format_action` made public. - Toggle panel with Shift+K (supports variants); restore vim `k` to MoveUp; remove Ctrl+Tab pane toggle. - Add help modal and update content. - Users details - Show primary group name; home existence and octal perms; shell validity (in /etc/shells) and interactivity. - Password status via shadow (locked/no_password/expired) with last-change/expiry days. - Sudo membership using configurable group `UGM_SUDO_GROUP` (default wheel). - SSH authorized_keys count; process count; increased pane height. - Groups details - System/user classification; counts: primary vs secondary members. - Alphabetical top-N members preview with “(+N more)”. - Shell distribution (interactive vs noninteractive); UID class counts. - Shadow status counts; orphan secondary members detection. - Sudo-capable flag via configurable sudo group; `/etc/group` mtime proxy. - Pane height and layout adjustments. - QA/maintenance - Tests updated (including toggle mapping/state); clippy clean (vec init fix). - Minor layout/style cleanups. Env: - `UGM_SUDO_GROUP` selects the sudo group name (defaults to `wheel`).
1 parent caee4ff commit 87d06de

File tree

10 files changed

+1504
-254
lines changed

10 files changed

+1504
-254
lines changed

src/app/keymap.rs

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
66
pub enum KeyAction {
77
Quit,
88
OpenFilterMenu,
9+
OpenHelp,
910
StartSearch,
1011
NewUser,
12+
DeleteSelection,
1113
SwitchTab,
1214
ToggleUsersFocus,
15+
ToggleGroupsFocus,
16+
ToggleKeybindsPane,
1317
EnterAction,
1418
MoveUp,
1519
MoveDown,
@@ -37,21 +41,32 @@ impl Keymap {
3741
bindings.insert((M::NONE, Char('f')), KeyAction::OpenFilterMenu);
3842
bindings.insert((M::NONE, Char('/')), KeyAction::StartSearch);
3943
bindings.insert((M::NONE, Char('n')), KeyAction::NewUser);
44+
bindings.insert((M::NONE, Char('?')), KeyAction::OpenHelp);
45+
bindings.insert((M::NONE, KeyCode::Delete), KeyAction::DeleteSelection);
4046
bindings.insert((M::NONE, Tab), KeyAction::SwitchTab);
4147
// Shift+Tab is BackTab in crossterm
4248
bindings.insert((M::NONE, BackTab), KeyAction::ToggleUsersFocus);
49+
// Some terminals report BackTab with SHIFT modifier, and some send Tab+SHIFT
50+
bindings.insert((M::SHIFT, BackTab), KeyAction::ToggleUsersFocus);
51+
bindings.insert((M::SHIFT, Tab), KeyAction::ToggleUsersFocus);
52+
// Ctrl+Tab no longer toggles panes in Groups
4353

4454
bindings.insert((M::NONE, Enter), KeyAction::EnterAction);
4555
// Navigation
4656
bindings.insert((M::NONE, Up), KeyAction::MoveUp);
4757
bindings.insert((M::NONE, Down), KeyAction::MoveDown);
4858
bindings.insert((M::NONE, Left), KeyAction::MoveLeftPage);
4959
bindings.insert((M::NONE, Right), KeyAction::MoveRightPage);
50-
// Vim keys
60+
// Vim-like keys
5161
bindings.insert((M::NONE, Char('k')), KeyAction::MoveUp);
5262
bindings.insert((M::NONE, Char('j')), KeyAction::MoveDown);
5363
bindings.insert((M::NONE, Char('h')), KeyAction::MoveLeftPage);
5464
bindings.insert((M::NONE, Char('l')), KeyAction::MoveRightPage);
65+
// Toggle keybindings pane (support Shift+K variants across terminals)
66+
bindings.insert((M::SHIFT, Char('k')), KeyAction::ToggleKeybindsPane);
67+
bindings.insert((M::SHIFT, Char('K')), KeyAction::ToggleKeybindsPane);
68+
bindings.insert((M::NONE, Char('K')), KeyAction::ToggleKeybindsPane);
69+
5570
// Page keys
5671
bindings.insert((M::NONE, PageUp), KeyAction::PageUp);
5772
bindings.insert((M::NONE, PageDown), KeyAction::PageDown);
@@ -106,8 +121,9 @@ impl Keymap {
106121
let mut buf = String::new();
107122
buf.push_str("# usrgrp-manager keybindings\n");
108123
buf.push_str("# Format: <Action> = <KeySpec>\n");
109-
buf.push_str("# KeySpec examples: q, Ctrl+q, Enter, Esc, Tab, BackTab, Up, Down, Left, Right, PageUp, PageDown, /, n, f, j, k, h, l\n");
110-
buf.push_str("# Actions: Quit, OpenFilterMenu, StartSearch, NewUser, SwitchTab, ToggleUsersFocus, EnterAction, MoveUp, MoveDown, MoveLeftPage, MoveRightPage, PageUp, PageDown, Ignore\n\n");
124+
buf.push_str("# KeySpec examples: q, Ctrl+q, Enter, Esc, Tab, BackTab, Up, Down, Left, Right, PageUp, PageDown, Delete, /, n, f, j, k, h, l\n");
125+
buf.push_str("# Actions: Quit, OpenFilterMenu, StartSearch, NewUser, DeleteSelection, SwitchTab, ToggleUsersFocus, ToggleGroupsFocus, ToggleKeybindsPane, EnterAction, MoveUp, MoveDown, MoveLeftPage, MoveRightPage, PageUp, PageDown, Ignore\n\n");
126+
buf.push_str("# Additional: OpenHelp (mapped to '?')\n\n");
111127

112128
// Emit a stable, readable subset of current bindings
113129
let dump = [
@@ -118,6 +134,7 @@ impl Keymap {
118134
("n", KeyAction::NewUser),
119135
("Tab", KeyAction::SwitchTab),
120136
("BackTab", KeyAction::ToggleUsersFocus),
137+
("?", KeyAction::OpenHelp),
121138
("Enter", KeyAction::EnterAction),
122139
("Up", KeyAction::MoveUp),
123140
("Down", KeyAction::MoveDown),
@@ -129,6 +146,7 @@ impl Keymap {
129146
("l", KeyAction::MoveRightPage),
130147
("PageUp", KeyAction::PageUp),
131148
("PageDown", KeyAction::PageDown),
149+
("Delete", KeyAction::DeleteSelection),
132150
];
133151
for (k, a) in dump {
134152
let _ = writeln!(&mut buf, "{} = {}", format_action(a), k);
@@ -142,6 +160,40 @@ impl Keymap {
142160
let code = key.code;
143161
self.bindings.get(&(mm, code)).copied()
144162
}
163+
164+
/// Return a snapshot of all bindings as ((modifiers, code), action) pairs.
165+
pub fn all_bindings(&self) -> Vec<((KeyModifiers, KeyCode), KeyAction)> {
166+
self.bindings.iter().map(|(k, v)| (*k, *v)).collect()
167+
}
168+
169+
/// Format a key (modifiers + code) into a human-readable spec like "Ctrl+q", "BackTab".
170+
pub fn format_key(mods: KeyModifiers, code: KeyCode) -> String {
171+
use KeyCode::*;
172+
let base = match code {
173+
Enter => "Enter".to_string(),
174+
Delete => "Delete".to_string(),
175+
Esc => "Esc".to_string(),
176+
Tab => "Tab".to_string(),
177+
BackTab => "BackTab".to_string(),
178+
Up => "Up".to_string(),
179+
Down => "Down".to_string(),
180+
Left => "Left".to_string(),
181+
Right => "Right".to_string(),
182+
PageUp => "PageUp".to_string(),
183+
PageDown => "PageDown".to_string(),
184+
Char('/') => "/".to_string(),
185+
Char(c) => c.to_string(),
186+
_ => format!("{:?}", code),
187+
};
188+
if mods.contains(KeyModifiers::CONTROL) {
189+
format!("Ctrl+{}", base)
190+
} else if mods.is_empty() {
191+
base
192+
} else {
193+
// Future: format other modifiers when supported
194+
base
195+
}
196+
}
145197
}
146198

147199
impl Default for Keymap {
@@ -162,6 +214,7 @@ fn parse_key(spec: &str) -> Option<(KeyModifiers, KeyCode)> {
162214
// Future: Alt+ / Shift+
163215
let code = match rest {
164216
"Enter" => Enter,
217+
"Delete" => Delete,
165218
"/" => Char('/'),
166219
"Esc" | "Escape" => Esc,
167220
"Tab" => Tab,
@@ -188,10 +241,14 @@ fn parse_action(s: &str) -> Option<KeyAction> {
188241
match s.trim() {
189242
"Quit" => Some(KeyAction::Quit),
190243
"OpenFilterMenu" => Some(KeyAction::OpenFilterMenu),
244+
"OpenHelp" => Some(KeyAction::OpenHelp),
191245
"StartSearch" => Some(KeyAction::StartSearch),
192246
"NewUser" => Some(KeyAction::NewUser),
247+
"DeleteSelection" => Some(KeyAction::DeleteSelection),
193248
"SwitchTab" => Some(KeyAction::SwitchTab),
194249
"ToggleUsersFocus" => Some(KeyAction::ToggleUsersFocus),
250+
"ToggleGroupsFocus" => Some(KeyAction::ToggleGroupsFocus),
251+
"ToggleKeybindsPane" => Some(KeyAction::ToggleKeybindsPane),
195252
"EnterAction" => Some(KeyAction::EnterAction),
196253
"MoveUp" => Some(KeyAction::MoveUp),
197254
"MoveDown" => Some(KeyAction::MoveDown),
@@ -204,14 +261,18 @@ fn parse_action(s: &str) -> Option<KeyAction> {
204261
}
205262
}
206263

207-
fn format_action(a: KeyAction) -> &'static str {
264+
pub fn format_action(a: KeyAction) -> &'static str {
208265
match a {
209266
KeyAction::Quit => "Quit",
210267
KeyAction::OpenFilterMenu => "OpenFilterMenu",
268+
KeyAction::OpenHelp => "OpenHelp",
211269
KeyAction::StartSearch => "StartSearch",
212270
KeyAction::NewUser => "NewUser",
271+
KeyAction::DeleteSelection => "DeleteSelection",
213272
KeyAction::SwitchTab => "SwitchTab",
214273
KeyAction::ToggleUsersFocus => "ToggleUsersFocus",
274+
KeyAction::ToggleGroupsFocus => "ToggleGroupsFocus",
275+
KeyAction::ToggleKeybindsPane => "ToggleKeybindsPane",
215276
KeyAction::EnterAction => "EnterAction",
216277
KeyAction::MoveUp => "MoveUp",
217278
KeyAction::MoveDown => "MoveDown",

src/app/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,9 @@ pub enum ModalState {
264264
Info {
265265
message: String,
266266
},
267+
Help {
268+
scroll: u16,
269+
},
267270
SudoPrompt {
268271
next: PendingAction,
269272
password: String,
@@ -278,6 +281,11 @@ pub enum ModalState {
278281
},
279282
GroupDeleteConfirm {
280283
selected: usize,
284+
target_gid: Option<u32>,
285+
},
286+
ConfirmRemoveUserFromGroup {
287+
selected: usize,
288+
group_name: String,
281289
},
282290
GroupModifyMenu {
283291
selected: usize,
@@ -339,6 +347,17 @@ pub enum GroupsFilter {
339347
OnlySystemGids, // gid < 1000
340348
}
341349

350+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351+
pub enum GroupsFocus {
352+
GroupsList,
353+
Members,
354+
}
355+
356+
#[derive(Clone, Debug)]
357+
pub enum ActionsContext {
358+
GroupMemberRemoval { group_name: String },
359+
}
360+
342361
/// Actions that require privileged changes, executed via `sys::SystemAdapter`.
343362
#[derive(Clone, Debug)]
344363
pub enum PendingAction {
@@ -418,6 +437,7 @@ pub struct AppState {
418437
pub active_tab: ActiveTab,
419438
pub selected_user_index: usize,
420439
pub selected_group_index: usize,
440+
pub selected_group_member_index: usize,
421441
pub rows_per_page: usize,
422442
pub _table_state: TableState,
423443
pub input_mode: InputMode,
@@ -426,10 +446,13 @@ pub struct AppState {
426446
pub keymap: keymap::Keymap,
427447
pub modal: Option<ModalState>,
428448
pub users_focus: UsersFocus,
449+
pub groups_focus: GroupsFocus,
429450
pub sudo_password: Option<String>,
430451
pub users_filter: Option<UsersFilter>,
431452
pub groups_filter: Option<GroupsFilter>,
432453
pub users_filter_chips: UsersFilterChips,
454+
pub actions_context: Option<ActionsContext>,
455+
pub show_keybinds: bool,
433456
}
434457

435458
impl AppState {
@@ -449,6 +472,7 @@ impl AppState {
449472
active_tab: ActiveTab::Users,
450473
selected_user_index: 0,
451474
selected_group_index: 0,
475+
selected_group_member_index: 0,
452476
rows_per_page: 10,
453477
_table_state: TableState::default(),
454478
input_mode: InputMode::Normal,
@@ -463,10 +487,13 @@ impl AppState {
463487
),
464488
modal: None,
465489
users_focus: UsersFocus::UsersList,
490+
groups_focus: GroupsFocus::GroupsList,
466491
sudo_password: None,
467492
users_filter: None,
468493
groups_filter: None,
469494
users_filter_chips: UsersFilterChips::default(),
495+
actions_context: None,
496+
show_keybinds: true,
470497
};
471498

472499
// Load and apply filter configuration from filter.conf (creates default if missing/empty)
@@ -538,3 +565,8 @@ impl Default for AppState {
538565

539566
/// Re-export the application event loop entry function.
540567
pub use update::run_app as run;
568+
569+
/// Resolve the sudo group name from environment, defaulting to "wheel".
570+
pub fn sudo_group_name() -> String {
571+
std::env::var("UGM_SUDO_GROUP").unwrap_or_else(|_| "wheel".to_string())
572+
}

0 commit comments

Comments
 (0)