From 0da8a3f193895ac3cf865b62611604bd5f90d029 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 13 Oct 2025 00:25:30 +0200 Subject: [PATCH] feat(ui): add file icon resolver with Nerd/ASCII sets, env override, and breadcrumb display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce `IconMode` in core config (default Auto) and bump schema version to 1.4.0. - Add `FileIconSet`, `IconDetection`, and `FileIconResolver` to resolve per‑file icons with configurable fallbacks and environment variable `OWLEN_TUI_ICONS`. - Export resolver types from `owlen-tui::state::file_icons`. - Extend `ChatApp` with `file_icons` field, initialize it from config, and expose via `file_icons()` accessor. - Append system status line showing selected icon set and detection source. - Implement breadcrumb construction (`repo > path > file`) and display in code pane headers. - Render icons in file tree, handle unsaved file markers, hidden files, and Git decorations with proper styling. - Add helper `collect_unsaved_relative_paths` and tree line computation for visual guides. - Provide `Workspace::panes()` iterator for unsaved tracking. - Update UI imports and tests to cover new breadcrumb feature. --- crates/owlen-core/src/config.rs | 22 +- crates/owlen-tui/src/chat_app.rs | 22 +- crates/owlen-tui/src/config.rs | 4 +- crates/owlen-tui/src/state/file_icons.rs | 320 +++++++++++++++++++++++ crates/owlen-tui/src/state/mod.rs | 2 + crates/owlen-tui/src/state/workspace.rs | 4 + crates/owlen-tui/src/ui.rs | 270 ++++++++++++++++--- 7 files changed, 604 insertions(+), 40 deletions(-) create mode 100644 crates/owlen-tui/src/state/file_icons.rs diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 8581c2d..daed5dd 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -14,7 +14,7 @@ use std::time::Duration; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; /// Current schema version written to `config.toml`. -pub const CONFIG_SCHEMA_VERSION: &str = "1.3.0"; +pub const CONFIG_SCHEMA_VERSION: &str = "1.4.0"; /// Core configuration shared by all OWLEN clients #[derive(Debug, Clone, Serialize, Deserialize)] @@ -724,6 +724,21 @@ pub struct UiSettings { pub syntax_highlighting: bool, #[serde(default = "UiSettings::default_show_timestamps")] pub show_timestamps: bool, + #[serde(default = "UiSettings::default_icon_mode")] + pub icon_mode: IconMode, +} + +/// Preference for which symbol set to render in the terminal UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum IconMode { + /// Automatically detect support for Nerd Font glyphs. + #[default] + Auto, + /// Use only ASCII-safe symbols. + Ascii, + /// Force Nerd Font glyphs regardless of detection heuristics. + Nerd, } impl UiSettings { @@ -771,6 +786,10 @@ impl UiSettings { true } + const fn default_icon_mode() -> IconMode { + IconMode::Auto + } + fn deserialize_role_label_mode<'de, D>( deserializer: D, ) -> std::result::Result @@ -838,6 +857,7 @@ impl Default for UiSettings { show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), syntax_highlighting: Self::default_syntax_highlighting(), show_timestamps: Self::default_show_timestamps(), + icon_mode: Self::default_icon_mode(), } } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 331ee8d..0677911 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -28,10 +28,10 @@ use crate::config; use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; use crate::state::{ - CodeWorkspace, CommandPalette, FileFilterMode, FileNode, FileTreeState, ModelPaletteEntry, - PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, - SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task, - spawn_symbol_search_task, + CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState, + ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, + RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, + spawn_repo_search_task, spawn_symbol_search_task, }; use crate::ui::format_tool_output; // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly @@ -224,6 +224,7 @@ pub struct ChatApp { resize_snap_index: usize, // Cycles through 25/50/75 snaps last_snap_direction: Option, file_tree: FileTreeState, // Workspace file tree state + file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback file_panel_collapsed: bool, // Whether the file panel is collapsed file_panel_width: u16, // Cached file panel width saved_sessions: Vec, // Cached list of saved sessions @@ -384,6 +385,7 @@ impl ChatApp { let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; let syntax_highlighting = config_guard.ui.syntax_highlighting; let show_timestamps = config_guard.ui.show_timestamps; + let icon_mode = config_guard.ui.icon_mode; drop(config_guard); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); @@ -392,6 +394,7 @@ impl ChatApp { let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let file_tree = FileTreeState::new(workspace_root); + let file_icons = FileIconResolver::from_mode(icon_mode); let mut app = Self { controller, @@ -454,6 +457,7 @@ impl ChatApp { resize_snap_index: 0, last_snap_direction: None, file_tree, + file_icons, file_panel_collapsed: true, file_panel_width: 32, saved_sessions: Vec::new(), @@ -479,6 +483,12 @@ impl ChatApp { show_message_timestamps: show_timestamps, }; + app.append_system_status(&format!( + "Icons: {} ({})", + app.file_icons.status_label(), + app.file_icons.detection_label() + )); + app.update_command_palette_catalog(); if let Err(err) = app.restore_workspace_layout().await { @@ -973,6 +983,10 @@ impl ChatApp { &mut self.file_tree } + pub fn file_icons(&self) -> &FileIconResolver { + &self.file_icons + } + pub fn workspace(&self) -> &CodeWorkspace { &self.code_workspace } diff --git a/crates/owlen-tui/src/config.rs b/crates/owlen-tui/src/config.rs index 1253341..4b0d78f 100644 --- a/crates/owlen-tui/src/config.rs +++ b/crates/owlen-tui/src/config.rs @@ -1,6 +1,6 @@ pub use owlen_core::config::{ - Config, DEFAULT_CONFIG_PATH, GeneralSettings, InputSettings, StorageSettings, UiSettings, - default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, + Config, DEFAULT_CONFIG_PATH, GeneralSettings, IconMode, InputSettings, StorageSettings, + UiSettings, default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, }; /// Attempt to load configuration from default location diff --git a/crates/owlen-tui/src/state/file_icons.rs b/crates/owlen-tui/src/state/file_icons.rs new file mode 100644 index 0000000..c0bd4ad --- /dev/null +++ b/crates/owlen-tui/src/state/file_icons.rs @@ -0,0 +1,320 @@ +use std::env; +use std::path::Path; + +use owlen_core::config::IconMode; +use unicode_width::UnicodeWidthChar; + +use super::FileNode; + +const ENV_ICON_OVERRIDE: &str = "OWLEN_TUI_ICONS"; + +/// Concrete icon sets that can be rendered in the terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileIconSet { + Nerd, + Ascii, +} + +impl FileIconSet { + pub fn label(self) -> &'static str { + match self { + FileIconSet::Nerd => "Nerd", + FileIconSet::Ascii => "ASCII", + } + } +} + +/// How the icon mode was decided. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconDetection { + /// Explicit configuration (config file or CLI flag) forced the mode. + Configured, + /// The runtime environment variable override selected the mode. + Environment, + /// Automatic heuristics guessed the appropriate mode. + Heuristic, +} + +/// Resolves per-file icons with configurable fallbacks. +#[derive(Debug, Clone)] +pub struct FileIconResolver { + set: FileIconSet, + detection: IconDetection, +} + +impl FileIconResolver { + /// Construct a resolver from the configured icon preference. + pub fn from_mode(pref: IconMode) -> Self { + let (set, detection) = match pref { + IconMode::Ascii => (FileIconSet::Ascii, IconDetection::Configured), + IconMode::Nerd => (FileIconSet::Nerd, IconDetection::Configured), + IconMode::Auto => detect_icon_set(), + }; + Self { set, detection } + } + + /// Effective icon set that will be rendered. + pub fn set(&self) -> FileIconSet { + self.set + } + + /// How the icon set was chosen. + pub fn detection(&self) -> IconDetection { + self.detection + } + + /// Human readable label for status lines. + pub fn status_label(&self) -> &'static str { + self.set.label() + } + + /// Short label indicating where the decision originated. + pub fn detection_label(&self) -> &'static str { + match self.detection { + IconDetection::Configured => "config", + IconDetection::Environment => "env", + IconDetection::Heuristic => "auto", + } + } + + /// Select the glyph to render for the given node. + pub fn icon_for(&self, node: &FileNode) -> &'static str { + match self.set { + FileIconSet::Nerd => nerd_icon_for(node), + FileIconSet::Ascii => ascii_icon_for(node), + } + } +} + +fn detect_icon_set() -> (FileIconSet, IconDetection) { + if let Some(set) = env_icon_override() { + return (set, IconDetection::Environment); + } + + if !locale_supports_unicode() || is_basic_terminal() { + return (FileIconSet::Ascii, IconDetection::Heuristic); + } + + if nerd_glyph_has_compact_width() { + (FileIconSet::Nerd, IconDetection::Heuristic) + } else { + (FileIconSet::Ascii, IconDetection::Heuristic) + } +} + +fn env_icon_override() -> Option { + let value = env::var(ENV_ICON_OVERRIDE).ok()?; + match value.trim().to_ascii_lowercase().as_str() { + "nerd" | "nerdfont" | "nf" | "fancy" => Some(FileIconSet::Nerd), + "ascii" | "plain" | "simple" => Some(FileIconSet::Ascii), + _ => None, + } +} + +fn locale_supports_unicode() -> bool { + let vars = ["LC_ALL", "LC_CTYPE", "LANG"]; + vars.iter() + .filter_map(|name| env::var(name).ok()) + .map(|value| value.to_ascii_lowercase()) + .any(|value| value.contains("utf-8") || value.contains("utf8")) +} + +fn is_basic_terminal() -> bool { + matches!(env::var("TERM").ok().as_deref(), Some("linux" | "vt100")) +} + +fn nerd_glyph_has_compact_width() -> bool { + // Sample glyphs chosen from the Nerd Font private use area. + const SAMPLE_ICONS: [&str; 3] = ["󰈙", "󰉋", ""]; + SAMPLE_ICONS.iter().all(|icon| { + icon.chars() + .all(|ch| UnicodeWidthChar::width(ch).unwrap_or(1) == 1) + }) +} + +fn nerd_icon_for(node: &FileNode) -> &'static str { + if node.depth == 0 { + return "󰉖"; + } + if node.is_dir { + return if node.is_expanded { "󰝰" } else { "󰉋" }; + } + + let name = node.name.as_str(); + if let Some(icon) = nerd_icon_by_special_name(name) { + return icon; + } + + let ext = Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + match ext.as_str() { + "rs" => "", + "toml" => "", + "lock" => "󰌾", + "json" => "", + "yaml" | "yml" => "", + "md" | "markdown" => "󰍔", + "py" => "", + "rb" => "", + "go" => "", + "sh" | "bash" => "", + "zsh" => "", + "fish" => "", + "ts" => "", + "tsx" => "", + "js" => "", + "jsx" => "", + "mjs" | "cjs" => "", + "html" | "htm" => "", + "css" => "", + "scss" | "sass" => "", + "less" => "", + "vue" => "󰡄", + "svelte" => "󱄄", + "java" => "", + "kt" => "󱈙", + "swift" => "", + "c" => "", + "h" => "󰙱", + "cpp" | "cxx" | "cc" => "", + "hpp" | "hh" | "hxx" => "󰙲", + "cs" => "󰌛", + "php" => "", + "zig" => "", + "lua" => "", + "sql" => "", + "erl" | "hrl" => "", + "ex" | "exs" => "", + "hs" => "", + "scala" => "", + "dart" => "", + "gradle" => "", + "groovy" => "", + "xml" => "󰗀", + "ini" | "cfg" => "", + "env" => "", + "log" => "󰌱", + "txt" => "󰈙", + "pdf" => "", + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "󰋩", + "svg" => "󰜡", + "ico" => "󰞏", + "lockb" => "󰌾", + "wasm" => "", + _ => "󰈙", + } +} + +fn nerd_icon_by_special_name(name: &str) -> Option<&'static str> { + match name { + "Cargo.toml" => Some("󰓾"), + "Cargo.lock" => Some("󰌾"), + "Makefile" | "makefile" => Some(""), + "Dockerfile" => Some("󰡨"), + ".gitignore" => Some(""), + ".gitmodules" => Some(""), + "README.md" | "readme.md" => Some("󰍔"), + "LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some(""), + "package.json" => Some(""), + "package-lock.json" => Some(""), + "yarn.lock" => Some(""), + "pnpm-lock.yaml" | "pnpm-lock.yml" => Some(""), + "tsconfig.json" => Some(""), + "config.toml" => Some(""), + _ => None, + } +} + +fn ascii_icon_for(node: &FileNode) -> &'static str { + if node.depth == 0 { + return "[]"; + } + if node.is_dir { + return if node.is_expanded { "[]" } else { "<>" }; + } + + let name = node.name.as_str(); + if let Some(icon) = ascii_icon_by_special_name(name) { + return icon; + } + + let ext = Path::new(name) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + match ext.as_str() { + "rs" => "RS", + "toml" => "TL", + "lock" => "LK", + "json" => "JS", + "yaml" | "yml" => "YM", + "md" | "markdown" => "MD", + "py" => "PY", + "rb" => "RB", + "go" => "GO", + "sh" | "bash" | "zsh" | "fish" => "SH", + "ts" => "TS", + "tsx" => "TX", + "js" | "jsx" | "mjs" | "cjs" => "JS", + "html" | "htm" => "HT", + "css" => "CS", + "scss" | "sass" => "SC", + "vue" => "VU", + "svelte" => "SV", + "java" => "JV", + "kt" => "KT", + "swift" => "SW", + "c" => "C", + "h" => "H", + "cpp" | "cxx" | "cc" => "C+", + "hpp" | "hh" | "hxx" => "H+", + "cs" => "CS", + "php" => "PH", + "zig" => "ZG", + "lua" => "LU", + "sql" => "SQ", + "erl" | "hrl" => "ER", + "ex" | "exs" => "EX", + "hs" => "HS", + "scala" => "SC", + "dart" => "DT", + "gradle" => "GR", + "groovy" => "GR", + "xml" => "XM", + "ini" | "cfg" => "CF", + "env" => "EV", + "log" => "LG", + "txt" => "--", + "pdf" => "PD", + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "IM", + "svg" => "SG", + "wasm" => "WM", + _ => "--", + } +} + +fn ascii_icon_by_special_name(name: &str) -> Option<&'static str> { + match name { + "Cargo.toml" => Some("TL"), + "Cargo.lock" => Some("LK"), + "Makefile" | "makefile" => Some("MK"), + "Dockerfile" => Some("DK"), + ".gitignore" => Some("GI"), + ".gitmodules" => Some("GI"), + "README.md" | "readme.md" => Some("MD"), + "LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some("LC"), + "package.json" => Some("PJ"), + "package-lock.json" => Some("PL"), + "yarn.lock" => Some("YL"), + "pnpm-lock.yaml" | "pnpm-lock.yml" => Some("PL"), + "tsconfig.json" => Some("TC"), + "config.toml" => Some("CF"), + _ => None, + } +} diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index 8625926..6112459 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -6,11 +6,13 @@ //! to test in isolation. mod command_palette; +mod file_icons; mod file_tree; mod search; mod workspace; pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion}; +pub use file_icons::{FileIconResolver, FileIconSet, IconDetection}; pub use file_tree::{ FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, }; diff --git a/crates/owlen-tui/src/state/workspace.rs b/crates/owlen-tui/src/state/workspace.rs index 5517536..8159afd 100644 --- a/crates/owlen-tui/src/state/workspace.rs +++ b/crates/owlen-tui/src/state/workspace.rs @@ -617,6 +617,10 @@ impl CodeWorkspace { self.active_tab().and_then(|tab| tab.active_pane()) } + pub fn panes(&self) -> impl Iterator + '_ { + self.tabs.iter().flat_map(|tab| tab.panes.values()) + } + pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> { self.active_tab_mut().and_then(|tab| tab.active_pane_mut()) } diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 9b753d2..49a0925 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -1,19 +1,20 @@ +use pathdiff::diff_paths; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; -use std::collections::HashMap; -use std::path::Path; +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; use crate::state::{ - CodePane, EditorTab, FileFilterMode, LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, - SplitAxis, + CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, + RepoSearchRowKind, SplitAxis, VisibleFileEntry, }; use owlen_core::model::DetailedModelInfo; use owlen_core::theme::Theme; @@ -89,6 +90,7 @@ fn panel_border_style(is_active: bool, is_focused: bool, theme: &Theme) -> Style mod focus_tests { use super::*; use ratatui::style::{Modifier, Style}; + use std::path::Path; fn theme() -> Theme { Theme::default() @@ -166,6 +168,12 @@ mod focus_tests { assert_eq!(inactive.fg, Some(theme.unfocused_panel_border)); assert!(inactive.add_modifier.contains(Modifier::DIM)); } + + #[test] + fn breadcrumbs_include_repo_segments() { + let crumb = build_breadcrumbs("repo", Path::new("src/lib.rs")); + assert_eq!(crumb, "repo > src > lib.rs"); + } } pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { @@ -325,6 +333,91 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { } } +#[derive(Debug, Clone)] +struct TreeLineRenderInfo { + ancestor_has_sibling: Vec, + is_last_sibling: bool, +} + +fn compute_tree_line_info( + entries: &[VisibleFileEntry], + nodes: &[FileNode], +) -> Vec { + let mut info = Vec::with_capacity(entries.len()); + let mut sibling_stack: Vec = Vec::new(); + + for (index, entry) in entries.iter().enumerate() { + let depth = entry.depth; + if sibling_stack.len() >= depth { + sibling_stack.truncate(depth); + } + + let is_last = is_last_visible_sibling(entries, nodes, index); + info.push(TreeLineRenderInfo { + ancestor_has_sibling: sibling_stack.clone(), + is_last_sibling: is_last, + }); + + sibling_stack.push(!is_last); + } + + info +} + +fn is_last_visible_sibling( + entries: &[VisibleFileEntry], + nodes: &[FileNode], + position: usize, +) -> bool { + let depth = entries[position].depth; + let node_index = entries[position].index; + let parent = nodes[node_index].parent; + + for next in entries.iter().skip(position + 1) { + if next.depth < depth { + break; + } + if next.depth == depth && nodes[next.index].parent == parent { + return false; + } + } + true +} + +fn collect_unsaved_relative_paths(app: &ChatApp, root: &Path) -> HashSet { + let mut set = HashSet::new(); + for pane in app.workspace().panes() { + if !pane.is_dirty { + continue; + } + if let Some(abs) = pane.absolute_path() + && let Some(rel) = diff_paths(abs, root) + { + set.insert(rel); + continue; + } + if let Some(display) = pane.display_path() { + let display_path = PathBuf::from(display); + if display_path.is_relative() { + set.insert(display_path); + } + } + } + set +} + +fn build_breadcrumbs(repo_name: &str, path: &Path) -> String { + let mut parts = vec![repo_name.to_string()]; + for component in path.components() { + if let Component::Normal(segment) = component + && !segment.is_empty() + { + parts.push(segment.to_string_lossy().into_owned()); + } + } + parts.join(" > ") +} + fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let theme = app.theme().clone(); let has_focus = matches!(app.focused_panel(), FocusedPanel::Files); @@ -386,8 +479,16 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { tree.set_viewport_height(viewport_height); } + let root_path = { + let tree = app.file_tree(); + tree.root().to_path_buf() + }; + let unsaved_paths = collect_unsaved_relative_paths(app, &root_path); + let tree = app.file_tree(); let entries = tree.visible_entries(); + let render_info = compute_tree_line_info(entries, tree.nodes()); + let icon_resolver = app.file_icons(); let start = tree.scroll_top().min(entries.len()); let end = (start + viewport_height).min(entries.len()); let error_message = tree.last_error().map(|msg| msg.to_string()); @@ -420,18 +521,33 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .style(Style::default()), ); } else { + let mut guide_style = Style::default().fg(theme.placeholder); + if !has_focus { + guide_style = guide_style.add_modifier(Modifier::DIM); + } + for (offset, entry) in entries[start..end].iter().enumerate() { let absolute_idx = start + offset; let is_selected = absolute_idx == tree.cursor(); let node = &tree.nodes()[entry.index]; - let indent_level = entry.depth.saturating_sub(1); + let info = &render_info[absolute_idx]; let mut spans: Vec> = Vec::new(); spans.push(focus_beacon_span(is_selected, has_focus, &theme)); spans.push(Span::raw(" ")); - if indent_level > 0 { - spans.push(Span::raw(" ".repeat(indent_level))); + for &has_more in &info.ancestor_has_sibling { + let glyph = if has_more { "│" } else { " " }; + spans.push(Span::styled(format!("{glyph} "), guide_style)); + } + + if entry.depth > 0 { + let branch = if info.is_last_sibling { + "└─" + } else { + "├─" + }; + spans.push(Span::styled(branch.to_string(), guide_style)); } let toggle_symbol = if node.is_dir { @@ -445,30 +561,23 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { } else { " " }; - spans.push(Span::styled( - toggle_symbol.to_string(), - Style::default().fg(theme.text), - )); + spans.push(Span::styled(toggle_symbol.to_string(), guide_style)); - let marker_style = match node.git.cleanliness { - '●' => Style::default().fg(theme.error), - '○' => Style::default().fg(theme.info), - _ => Style::default().fg(theme.text).add_modifier(Modifier::DIM), - }; - spans.push(Span::styled( - format!("{} ", node.git.cleanliness), - marker_style, - )); - - if let Some(badge) = node.git.badge { - spans.push(Span::styled( - format!("{badge} "), - Style::default().fg(theme.info), - )); + let mut icon_style = if node.is_dir { + Style::default().fg(theme.info) } else { - spans.push(Span::raw(" ".to_string())); + Style::default().fg(theme.text) + }; + if !has_focus && !is_selected { + icon_style = icon_style.add_modifier(Modifier::DIM); } + if node.is_hidden { + icon_style = icon_style.add_modifier(Modifier::DIM); + } + let icon = icon_resolver.icon_for(node); + spans.push(Span::styled(format!("{icon} "), icon_style)); + let is_unsaved = !node.is_dir && unsaved_paths.contains(&node.path); let mut name_style = Style::default().fg(theme.text); if node.is_dir { name_style = name_style.add_modifier(Modifier::BOLD); @@ -476,9 +585,49 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if node.is_hidden { name_style = name_style.add_modifier(Modifier::DIM); } + if is_unsaved { + name_style = name_style.add_modifier(Modifier::ITALIC); + } spans.push(Span::styled(node.name.clone(), name_style)); + let mut marker_spans: Vec> = Vec::new(); + if node.git.cleanliness != '✓' { + marker_spans.push(Span::styled("*", Style::default().fg(theme.info))); + } + if is_unsaved { + marker_spans.push(Span::styled( + "~", + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + )); + } + if node.is_hidden && show_hidden { + marker_spans.push(Span::styled( + "gh", + Style::default() + .fg(theme.pane_hint_text) + .add_modifier(Modifier::DIM | Modifier::ITALIC), + )); + } + if let Some(badge) = node.git.badge { + marker_spans.push(Span::styled( + badge.to_string(), + Style::default().fg(theme.info), + )); + } + + if !marker_spans.is_empty() { + spans.push(Span::raw(" ")); + for (idx, marker) in marker_spans.into_iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + spans.push(marker); + } + } + let mut line_style = Style::default(); if is_selected { line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg); @@ -1850,6 +1999,11 @@ fn render_code_workspace(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { return; } + let (repo_name, repo_root) = { + let tree = app.file_tree(); + (tree.repo_name().to_string(), tree.root().to_path_buf()) + }; + let show_tab_bar = area.height > 2; let content_area = if show_tab_bar { let segments = Layout::default() @@ -1862,7 +2016,14 @@ fn render_code_workspace(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { area }; - render_code_tab_content(frame, content_area, app, &theme); + render_code_tab_content( + frame, + content_area, + app, + &theme, + &repo_name, + repo_root.as_path(), + ); } fn render_code_tab_bar(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, theme: &Theme) { @@ -1923,7 +2084,14 @@ fn render_code_tab_bar(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, theme: frame.render_widget(paragraph, area); } -fn render_code_tab_content(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp, theme: &Theme) { +fn render_code_tab_content( + frame: &mut Frame<'_>, + area: Rect, + app: &mut ChatApp, + theme: &Theme, + repo_name: &str, + repo_root: &Path, +) { if area.width == 0 || area.height == 0 { return; } @@ -1945,12 +2113,23 @@ fn render_code_tab_content(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp, return; } let active_pane = *active; - render_workspace_node(frame, area, root, panes, active_pane, theme, has_focus); + render_workspace_node( + frame, + area, + root, + panes, + active_pane, + theme, + has_focus, + repo_name, + repo_root, + ); } else { render_empty_workspace(frame, area, theme); } } +#[allow(clippy::too_many_arguments)] fn render_workspace_node( frame: &mut Frame<'_>, area: Rect, @@ -1959,6 +2138,8 @@ fn render_workspace_node( active_pane: PaneId, theme: &Theme, has_focus: bool, + repo_name: &str, + repo_root: &Path, ) { if area.width == 0 || area.height == 0 { return; @@ -1968,7 +2149,9 @@ fn render_workspace_node( LayoutNode::Leaf(id) => { if let Some(pane) = panes.get_mut(id) { let is_active = *id == active_pane; - render_code_pane(frame, area, pane, theme, has_focus, is_active); + render_code_pane( + frame, area, pane, theme, has_focus, is_active, repo_name, repo_root, + ); } else { render_empty_workspace(frame, area, theme); } @@ -1989,6 +2172,8 @@ fn render_workspace_node( active_pane, theme, has_focus, + repo_name, + repo_root, ); } if second_area.width > 0 && second_area.height > 0 { @@ -2000,12 +2185,15 @@ fn render_workspace_node( active_pane, theme, has_focus, + repo_name, + repo_root, ); } } } } +#[allow(clippy::too_many_arguments)] fn render_code_pane( frame: &mut Frame<'_>, area: Rect, @@ -2013,6 +2201,8 @@ fn render_code_pane( theme: &Theme, has_focus: bool, is_active: bool, + repo_name: &str, + repo_root: &Path, ) { if area.width == 0 || area.height == 0 { return; @@ -2053,7 +2243,7 @@ fn render_code_pane( pane.scroll.on_viewport(viewport_height); let scroll_position = pane.scroll.scroll.min(u16::MAX as usize) as u16; - let title = pane + let fallback_title = pane .display_path() .map(|s| s.to_string()) .or_else(|| { @@ -2062,7 +2252,21 @@ fn render_code_pane( }) .unwrap_or_else(|| pane.title.clone()); - let mut title_spans = panel_title_spans(title, is_active, has_focus && is_active, theme); + let breadcrumb = pane + .absolute_path() + .and_then(|abs| { + diff_paths(abs, repo_root) + .or_else(|| abs.strip_prefix(repo_root).ok().map(PathBuf::from)) + .map(|rel| build_breadcrumbs(repo_name, rel.as_path())) + }) + .or_else(|| { + pane.display_path() + .map(|display| build_breadcrumbs(repo_name, Path::new(display))) + }); + + let header_label = breadcrumb.unwrap_or_else(|| fallback_title.clone()); + + let mut title_spans = panel_title_spans(header_label, is_active, has_focus && is_active, theme); if is_active { title_spans.push(Span::raw(" ")); title_spans.push(Span::styled(