diff --git a/README.md b/README.md index 66b3d1a1..a9ad296b 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ A first-session cheatsheet. Press `?` inside tuicr for the full reference. | `v` / `V` | Visual mode (range comment) | | `r` | Toggle file reviewed | | `R` | Toggle hunk reviewed | +| `t` (file list) | Toggle tree / flat file-list mode (remembered next launch) | | `e` | Open focused file in `$EDITOR` | | `y` | Copy review to clipboard | | `:edit` | Open focused file in `$EDITOR` | @@ -257,6 +258,8 @@ A first-session cheatsheet. Press `?` inside tuicr for the full reference. | `Tab` in `:` prompt | Complete or cycle commands | | `?` | Toggle full help | +Drag the separator between the file list and diff to resize the left panel; the width is remembered for the next launch. + Full reference in [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). ## Sponsors diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 9818cf4a..9252d33c 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -44,9 +44,12 @@ Press `?` to open help. |-----|--------| | `Space` | Toggle expand directory | | `Enter` | Expand directory / jump to file in diff | +| `t` | Toggle tree / flat file-list mode (remembered next launch) | | `o` | Expand all directories | | `O` | Collapse all directories | +Drag the vertical separator between the file list and diff to resize the left panel. The width is saved for the next launch. + ## Panel focus | Key | Action | diff --git a/src/app/init.rs b/src/app/init.rs index 84ba102c..39e8f095 100644 --- a/src/app/init.rs +++ b/src/app/init.rs @@ -519,6 +519,9 @@ impl App { pending_confirm: None, supports_keyboard_enhancement: false, show_file_list: true, + file_list_width: 20, + file_list_resize_active: false, + file_list_flat: false, is_pristine_mode: false, is_single_file_view: false, primed_walk_next: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index 2164c9be..7a404b88 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1182,6 +1182,13 @@ pub struct App { pub pending_confirm: Option, pub supports_keyboard_enhancement: bool, pub show_file_list: bool, + /// Horizontal file-list width as a percentage of the main content area. + pub file_list_width: u16, + /// True while the mouse is dragging the separator between file list/diff. + pub file_list_resize_active: bool, + /// When true, the file list shows every file as a single flat row rather + /// than rendering directory nodes and indentation. + pub file_list_flat: bool, /// `true` when the session was opened via `--all-files`. Drives the /// `PRISTINE · N files` chip in the status bar and prevents that chip /// from showing in the regular `--file ` directory mode. diff --git a/src/app/tests/tree_tests.rs b/src/app/tests/tree_tests.rs index 6e9c6a41..29e32b00 100644 --- a/src/app/tests/tree_tests.rs +++ b/src/app/tests/tree_tests.rs @@ -17,6 +17,7 @@ fn make_file(path: &str) -> DiffFile { struct TreeTestHarness { diff_files: Vec, expanded_dirs: HashSet, + flat: bool, } impl TreeTestHarness { @@ -24,6 +25,7 @@ impl TreeTestHarness { Self { diff_files: paths.iter().map(|p| make_file(p)).collect(), expanded_dirs: HashSet::new(), + flat: false, } } @@ -56,6 +58,14 @@ impl TreeTestHarness { fn build_visible_items(&self) -> Vec { use std::path::Path; + if self.flat { + return self + .diff_files + .iter() + .enumerate() + .map(|(file_idx, _)| FileTreeItem::File { file_idx, depth: 0 }) + .collect(); + } let mut items = Vec::new(); let mut seen_dirs: HashSet = HashSet::new(); @@ -186,3 +196,19 @@ fn test_sibling_dirs_independent() { assert_eq!(h.visible_file_count(), 1); // only tests/test.rs } + +#[test] +fn test_flat_mode_shows_every_file_without_directories() { + let mut h = TreeTestHarness::new(&["src/ui/app.rs", "tests/test.rs", "README.md"]); + h.flat = true; + + let items = h.build_visible_items(); + assert_eq!(items.len(), 3); + assert_eq!(h.visible_file_count(), 3); + assert_eq!(h.visible_dir_count(), 0); + assert!( + items + .iter() + .all(|item| matches!(item, FileTreeItem::File { depth: 0, .. })) + ); +} diff --git a/src/app/tree.rs b/src/app/tree.rs index 609b1279..590d1150 100644 --- a/src/app/tree.rs +++ b/src/app/tree.rs @@ -1,6 +1,62 @@ use super::*; impl App { + /// Whether a mouse position is on the separator between the file list and + /// diff panels. The one-column tolerance makes the handle usable even + /// when the terminal reports a border cell inconsistently. + pub fn is_file_list_resize_handle(&self, x: u16, y: u16) -> bool { + let Some(file_list) = self.file_list_area else { + return false; + }; + let Some(diff) = self.diff_area else { + return false; + }; + y >= diff.y + && y < diff.y.saturating_add(diff.height) + && x.abs_diff(diff.x) <= 1 + && diff.x > file_list.x + } + + /// Update the file-list width from a mouse x-coordinate. Width is bounded + /// so neither panel can become unusably narrow. + pub fn resize_file_list_to(&mut self, x: u16) { + let Some(file_list) = self.file_list_area else { + return; + }; + let Some(diff) = self.diff_area else { + return; + }; + let total = u32::from( + diff.x + .saturating_add(diff.width) + .saturating_sub(file_list.x), + ); + if total == 0 { + return; + } + let relative = u32::from(x.saturating_sub(file_list.x)); + let percent = ((relative * 100 + total / 2) / total).clamp(15, 50) as u16; + if percent != self.file_list_width { + self.file_list_width = percent; + self.file_list_resize_active = true; + } + } + + /// Finish a resize gesture and persist the selected width for the next + /// launch. Errors are non-fatal because the current session keeps the + /// in-memory width. + pub fn finish_file_list_resize(&mut self) { + if !self.file_list_resize_active { + return; + } + self.file_list_resize_active = false; + if let Err(error) = crate::config::save_file_list_width(self.file_list_width) { + self.set_warning(format!("Failed to save file-list width: {error}")); + } else { + self.set_message(format!("File list width: {}%", self.file_list_width)); + } + } + pub fn file_list_down(&mut self, n: usize) { let visible_items = self.build_visible_items(); let max_idx = visible_items.len().saturating_sub(1); @@ -107,6 +163,24 @@ impl App { self.set_message(format!("File list: {status}")); } + /// Toggle between the hierarchical file tree and a flat list of all + /// changed files. Keep the currently focused file selected when possible. + pub fn toggle_file_list_mode(&mut self) { + let current_file_idx = self.diff_state.current_file_idx; + self.file_list_flat = !self.file_list_flat; + self.ensure_valid_tree_selection(); + if let Some(tree_idx) = self.file_idx_to_tree_idx(current_file_idx) { + self.file_list_state.select(tree_idx); + } + *self.file_list_state.list_state.offset_mut() = 0; + let mode = if self.file_list_flat { "flat" } else { "tree" }; + if let Err(error) = crate::config::save_file_list_flat(self.file_list_flat) { + self.set_warning(format!("Failed to save file-list mode: {error}")); + } else { + self.set_message(format!("File list mode: {mode}")); + } + } + /// Toggle single-file view. When on, the diff panel renders only the /// currently focused file instead of the full continuous-scroll /// concatenation. Annotations, navigation, and export work the same @@ -271,6 +345,15 @@ impl App { pub fn build_visible_items(&self) -> Vec { use std::path::Path; + if self.file_list_flat { + return self + .diff_files + .iter() + .enumerate() + .map(|(file_idx, _)| FileTreeItem::File { file_idx, depth: 0 }) + .collect(); + } + let mut items = Vec::new(); let mut seen_dirs: HashSet = HashSet::new(); diff --git a/src/config/mod.rs b/src/config/mod.rs index c20e31e5..bbe77299 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -222,6 +222,58 @@ pub fn themes_dir() -> Result { Ok(config_dir()?.join("themes")) } +const UI_STATE_FILE: &str = "ui_state.toml"; + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct UiState { + pub file_list_width: Option, + pub file_list_flat: Option, +} + +/// Load UI-only state that is changed interactively rather than authored in +/// `config.toml`. +pub fn load_ui_state() -> UiState { + let Ok(dir) = config_dir() else { + return UiState::default(); + }; + let Ok(content) = fs::read_to_string(dir.join(UI_STATE_FILE)) else { + return UiState::default(); + }; + toml::from_str(&content).unwrap_or_default() +} + +/// Persist UI state. This sidecar keeps the user's +/// hand-written `config.toml` comments and formatting untouched. +fn save_ui_state(state: &UiState) -> Result<()> { + let dir = config_dir()?; + fs::create_dir_all(&dir)?; + fs::write(dir.join(UI_STATE_FILE), toml::to_string(state)?)?; + Ok(()) +} + +pub fn load_file_list_width() -> Option { + load_ui_state() + .file_list_width + .filter(|width| (15..=50).contains(width)) +} + +pub fn load_file_list_flat() -> Option { + load_ui_state().file_list_flat +} + +pub fn save_file_list_width(width: u16) -> Result<()> { + let mut state = load_ui_state(); + state.file_list_width = Some(width); + save_ui_state(&state) +} + +pub fn save_file_list_flat(flat: bool) -> Result<()> { + let mut state = load_ui_state(); + state.file_list_flat = Some(flat); + save_ui_state(&state) +} + fn config_path_env_parts() -> (Option, Option, Option) { ( std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), @@ -748,6 +800,18 @@ mod tests { assert!(outcome.warnings.is_empty()); } + #[test] + fn should_round_trip_file_list_ui_state() { + let state = UiState { + file_list_width: Some(35), + file_list_flat: Some(true), + }; + + let serialized = toml::to_string(&state).expect("state should serialize"); + let restored: UiState = toml::from_str(&serialized).expect("state should deserialize"); + assert_eq!(restored, state); + } + #[test] fn should_load_theme_from_valid_toml() { let outcome = parse_config("theme = \"light\"\n"); diff --git a/src/handler.rs b/src/handler.rs index e65fec98..8197a434 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -196,6 +196,18 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) { } } } + MouseEventKind::Down(MouseButton::Left) + if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) + && app.is_file_list_resize_handle(pos.x, pos.y) => + { + app.focused_panel = FocusedPanel::FileList; + if app.input_mode == InputMode::VisualSelect { + app.exit_visual_mode(); + } + app.visual_selection = None; + app.file_list_resize_active = true; + app.resize_file_list_to(pos.x); + } MouseEventKind::Down(MouseButton::Left) if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) => { @@ -229,6 +241,9 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) { handle_commit_select_action(app, Action::ToggleCommitSelect); } } + MouseEventKind::Drag(MouseButton::Left) if app.file_list_resize_active => { + app.resize_file_list_to(pos.x); + } MouseEventKind::Drag(MouseButton::Left) if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) => { @@ -261,6 +276,9 @@ pub fn handle_mouse_event(app: &mut App, event: MouseEvent) { app.move_cursor_to_annotation(head.annotation_idx); } } + MouseEventKind::Up(MouseButton::Left) if app.file_list_resize_active => { + app.finish_file_list_resize(); + } MouseEventKind::Up(MouseButton::Left) if matches!(app.input_mode, InputMode::Normal | InputMode::VisualSelect) => { @@ -1352,6 +1370,7 @@ pub fn handle_file_list_action(app: &mut App, action: Action) { } } } + Action::ToggleFileListMode => app.toggle_file_list_mode(), Action::ToggleReviewed => { if let Some(FileTreeItem::File { file_idx, .. }) = app.get_selected_tree_item() { app.toggle_reviewed_for_file_idx(file_idx, false); diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index ff575f8c..57f2a1a5 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -131,6 +131,8 @@ pub enum Action { SubmitPickerConfirm, ToggleExpand, + /// Toggle the left file list between tree and flat modes (`t`). + ToggleFileListMode, ExpandAll, CollapseAll, SelectFileFull, @@ -221,6 +223,7 @@ fn map_normal_mode(key: KeyEvent, leader_key: char) -> Action { (KeyCode::Char('q'), KeyModifiers::NONE) => Action::Quit, (KeyCode::Char(' '), KeyModifiers::NONE) => Action::ToggleExpand, + (KeyCode::Char('t'), KeyModifiers::NONE) => Action::ToggleFileListMode, (KeyCode::Char('o'), KeyModifiers::NONE) => Action::ExpandAll, (KeyCode::Char('O'), _) => Action::CollapseAll, @@ -480,6 +483,14 @@ mod tests { ); } + #[test] + fn should_map_t_to_toggle_file_list_mode() { + assert_eq!( + map_normal_mode(key(KeyCode::Char('t')), DEFAULT_LEADER_KEY), + Action::ToggleFileListMode + ); + } + #[test] fn should_map_uppercase_g_to_go_to_bottom_in_normal_mode() { let action = map_normal_mode(key_shift('G'), DEFAULT_LEADER_KEY); diff --git a/src/main.rs b/src/main.rs index 857a4c9b..752e30bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -315,6 +315,13 @@ fn main() -> anyhow::Result<()> { } } + if let Some(width) = config::load_file_list_width() { + app.file_list_width = width; + } + if let Some(flat) = config::load_file_list_flat() { + app.file_list_flat = flat; + } + // On narrow terminals, start with only the diff panel visible. if let Ok((width, _)) = crossterm::terminal::size() && width < MIN_WIDTH_FOR_FILE_LIST diff --git a/src/ui/app_layout.rs b/src/ui/app_layout.rs index 1704dbf1..774c5505 100644 --- a/src/ui/app_layout.rs +++ b/src/ui/app_layout.rs @@ -104,8 +104,8 @@ fn render_main_content(frame: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(20), // File list - Constraint::Percentage(80), // Diff view + Constraint::Percentage(app.file_list_width), // File list + Constraint::Percentage(100u16.saturating_sub(app.file_list_width)), // Diff view ]) .split(content_area); diff --git a/src/ui/diff_view.rs b/src/ui/diff_view.rs index cf9c8cca..8bd9a41f 100644 --- a/src/ui/diff_view.rs +++ b/src/ui/diff_view.rs @@ -1038,18 +1038,35 @@ fn visual_rows_for_line(row_heights: &[usize], idx: usize) -> usize { /// Apply horizontal scroll to a line while preserving the first span (cursor indicator) pub(super) fn apply_horizontal_scroll(line: Line, scroll_x: usize) -> Line { + apply_horizontal_scroll_after_prefix(line, scroll_x, 1) +} + +/// Apply horizontal scroll to a line without retaining a fixed prefix. +/// +/// File-tree rows begin with indentation rather than the diff's cursor +/// indicator, so retaining their first span leaves an ever-present blank area +/// at the left edge while the rest of the row scrolls. +pub(super) fn apply_horizontal_scroll_without_prefix(line: Line, scroll_x: usize) -> Line { + apply_horizontal_scroll_after_prefix(line, scroll_x, 0) +} + +fn apply_horizontal_scroll_after_prefix( + line: Line, + scroll_x: usize, + prefix_span_count: usize, +) -> Line { if scroll_x == 0 || line.spans.is_empty() { return line; } let mut spans: Vec = line.spans.into_iter().collect(); - // Preserve the first span (indicator) - let indicator = spans.remove(0); + let protected_count = prefix_span_count.min(spans.len()); + let protected = spans.drain(..protected_count); // Skip scroll_x characters from the remaining spans let mut chars_to_skip = scroll_x; - let mut new_spans = vec![indicator]; + let mut new_spans: Vec = protected.collect(); for span in spans { let content = span.content.to_string(); diff --git a/src/ui/file_list.rs b/src/ui/file_list.rs index 6a605ed6..4fe4a1da 100644 --- a/src/ui/file_list.rs +++ b/src/ui/file_list.rs @@ -8,7 +8,7 @@ use std::path::Path; use unicode_width::UnicodeWidthStr; use crate::app::{App, FileTreeItem, FocusedPanel}; -use crate::ui::diff_view::apply_horizontal_scroll; +use crate::ui::diff_view::apply_horizontal_scroll_without_prefix; use crate::ui::styles; const EXPANDED_GLYPH: &str = "\u{25bc}"; // ▼ @@ -16,11 +16,60 @@ const COLLAPSED_GLYPH: &str = "\u{25b6}"; // ▶ const REVIEWED_BOX: &str = "\u{25a3}"; // ▣ const UNREVIEWED_BOX: &str = "\u{25a2}"; // ▢ +/// Compact text badges used only by flat mode. Keeping these ASCII-only makes +/// file-type hints render consistently in every terminal font. +fn file_type_icon(path: &Path) -> &'static str { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if filename.eq_ignore_ascii_case("dockerfile") { + return "[DOCKER]"; + } + if filename.eq_ignore_ascii_case("makefile") { + return "[MAKE]"; + } + + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "[JS]", + Some("ts") | Some("tsx") => "[TS]", + Some("vue") => "[VUE]", + Some("java") => "[JAVA]", + Some("sql") => "[SQL]", + Some("md") | Some("markdown") => "[MD]", + Some("py") => "[PY]", + Some("rs") => "[RS]", + Some("go") => "[GO]", + Some("rb") => "[RB]", + Some("php") => "[PHP]", + Some("html") | Some("htm") => "[HTML]", + Some("css") | Some("scss") | Some("sass") | Some("less") => "[CSS]", + Some("json") => "[JSON]", + Some("yaml") | Some("yml") => "[YAML]", + Some("sh") | Some("bash") | Some("zsh") | Some("fish") => "[SH]", + Some("c") | Some("h") => "[C]", + Some("cc") | Some("cpp") | Some("cxx") | Some("hpp") => "[C++]", + Some("cs") => "[C#]", + Some("kt") | Some("kts") => "[KT]", + Some("swift") => "[SWIFT]", + Some("xml") => "[XML]", + Some("toml") => "[TOML]", + Some("lock") => "[LOCK]", + _ => "[FILE]", + } +} + pub(super) fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) { let focused = app.focused_panel == FocusedPanel::FileList; + let mode = if app.file_list_flat { "flat" } else { "tree" }; let title = format!( - " Files \u{00b7} {}/{} ", + " Files \u{00b7} {}/{} \u{00b7} {mode} ", app.reviewed_count(), app.file_count() ); @@ -46,12 +95,18 @@ pub(super) fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) { } FileTreeItem::File { file_idx, depth } => { let file = &app.diff_files[*file_idx]; - let filename = file - .display_path() + let path = file.display_path(); + let name = path .file_name() .and_then(|n| n.to_str()) - .unwrap_or("?"); - depth * 2 + 4 + filename.width() + .unwrap_or("?") + .to_string(); + let icon_width = if app.file_list_flat { + file_type_icon(path).width() + 1 + } else { + 0 + }; + depth * 2 + 4 + icon_width + name.width() } }) .max() @@ -132,12 +187,23 @@ pub(super) fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) { Span::raw(format!(" {}", path.display())), ]) } else { - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); let indent = " ".repeat(*depth); let mut spans = vec![ Span::raw(indent), Span::styled(format!("{checkbox} "), checkbox_style), ]; + if app.file_list_flat { + let badge = file_type_icon(path); + spans.push(Span::styled( + format!("{badge} "), + styles::file_type_badge_style(badge), + )); + } // Pristine mode reviews unchanged code; the M/A/D // badge would lie. Suppress it and leave the row as // checkbox + filename. @@ -154,7 +220,7 @@ pub(super) fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) { } }; - ListItem::new(apply_horizontal_scroll(line, scroll_x)) + ListItem::new(apply_horizontal_scroll_without_prefix(line, scroll_x)) }) .collect(); @@ -168,3 +234,51 @@ pub(super) fn render_file_list(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_stateful_widget(list, area, &mut app.file_list_state.list_state); } + +#[cfg(test)] +mod tests { + use ratatui::text::{Line, Span}; + use std::path::Path; + + use super::{apply_horizontal_scroll_without_prefix, file_type_icon}; + + #[test] + fn horizontal_scroll_removes_file_tree_indentation() { + let line = Line::from(vec![ + Span::raw(" "), + Span::raw("▢ M "), + Span::raw("nested.rs"), + ]); + + let scrolled = apply_horizontal_scroll_without_prefix(line, 4); + let content: String = scrolled + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + + assert_eq!(content, "▢ M nested.rs"); + } + + #[test] + fn file_type_icon_covers_common_review_languages() { + assert_eq!(file_type_icon(Path::new("app.js")), "[JS]"); + assert_eq!(file_type_icon(Path::new("app.vue")), "[VUE]"); + assert_eq!(file_type_icon(Path::new("app.java")), "[JAVA]"); + assert_eq!(file_type_icon(Path::new("query.sql")), "[SQL]"); + assert_eq!(file_type_icon(Path::new("README.md")), "[MD]"); + assert_eq!(file_type_icon(Path::new("unknown")), "[FILE]"); + } + + #[test] + fn file_type_icon_matches_extensions_case_insensitively() { + assert_eq!( + file_type_icon(Path::new("APP.JS")), + file_type_icon(Path::new("app.js")) + ); + assert_eq!( + file_type_icon(Path::new("Dockerfile")), + file_type_icon(Path::new("dockerfile")) + ); + } +} diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 602b0375..e8e1e13d 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -334,6 +334,13 @@ pub fn render_help(frame: &mut Frame, app: &mut App) { ), Span::raw("Expand dir / Jump to file"), ]), + Line::from(vec![ + Span::styled( + " t ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw("Toggle tree / flat file-list mode"), + ]), Line::from(vec![ Span::styled( " o ", diff --git a/src/ui/styles.rs b/src/ui/styles.rs index f8cc8e32..b79fb8ce 100644 --- a/src/ui/styles.rs +++ b/src/ui/styles.rs @@ -86,6 +86,34 @@ pub fn file_status_style(theme: &Theme, status: char) -> Style { Style::default().fg(color) } +/// Color-coded text badges in the flat file list. These use the terminal's +/// standard palette so they remain readable across custom tuicr themes. +pub fn file_type_badge_style(badge: &str) -> Style { + let color = match badge { + "[JS]" => Color::Yellow, + "[TS]" => Color::Blue, + "[VUE]" => Color::Green, + "[JAVA]" => Color::Red, + "[SQL]" => Color::LightBlue, + "[MD]" => Color::Cyan, + "[PY]" => Color::LightYellow, + "[RS]" => Color::LightRed, + "[GO]" => Color::LightCyan, + "[RB]" | "[PHP]" => Color::Magenta, + "[HTML]" => Color::LightRed, + "[CSS]" => Color::LightMagenta, + "[JSON]" | "[YAML]" | "[TOML]" => Color::LightYellow, + "[SH]" => Color::LightGreen, + "[C]" | "[C++]" | "[C#]" => Color::LightBlue, + "[KT]" | "[SWIFT]" => Color::LightMagenta, + "[XML]" => Color::LightCyan, + "[DOCKER]" => Color::Blue, + "[MAKE]" | "[LOCK]" | "[FILE]" => Color::DarkGray, + _ => Color::Gray, + }; + Style::default().fg(color).add_modifier(Modifier::BOLD) +} + pub fn current_line_indicator_style(theme: &Theme) -> Style { Style::default().fg(theme.border_focused) }