feat(ui): add file icon resolver with Nerd/ASCII sets, env override, and breadcrumb display

- 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.
This commit is contained in:
2025-10-13 00:25:30 +02:00
parent 15f81d9728
commit 0da8a3f193
7 changed files with 604 additions and 40 deletions

View File

@@ -14,7 +14,7 @@ use std::time::Duration;
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml"; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `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 /// Core configuration shared by all OWLEN clients
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -724,6 +724,21 @@ pub struct UiSettings {
pub syntax_highlighting: bool, pub syntax_highlighting: bool,
#[serde(default = "UiSettings::default_show_timestamps")] #[serde(default = "UiSettings::default_show_timestamps")]
pub show_timestamps: bool, 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 { impl UiSettings {
@@ -771,6 +786,10 @@ impl UiSettings {
true true
} }
const fn default_icon_mode() -> IconMode {
IconMode::Auto
}
fn deserialize_role_label_mode<'de, D>( fn deserialize_role_label_mode<'de, D>(
deserializer: D, deserializer: D,
) -> std::result::Result<RoleLabelDisplay, D::Error> ) -> std::result::Result<RoleLabelDisplay, D::Error>
@@ -838,6 +857,7 @@ impl Default for UiSettings {
show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(),
syntax_highlighting: Self::default_syntax_highlighting(), syntax_highlighting: Self::default_syntax_highlighting(),
show_timestamps: Self::default_show_timestamps(), show_timestamps: Self::default_show_timestamps(),
icon_mode: Self::default_icon_mode(),
} }
} }
} }

View File

@@ -28,10 +28,10 @@ use crate::config;
use crate::events::Event; use crate::events::Event;
use crate::model_info_panel::ModelInfoPanel; use crate::model_info_panel::ModelInfoPanel;
use crate::state::{ use crate::state::{
CodeWorkspace, CommandPalette, FileFilterMode, FileNode, FileTreeState, ModelPaletteEntry, CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState,
PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage,
SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot, spawn_repo_search_task, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot,
spawn_symbol_search_task, spawn_repo_search_task, spawn_symbol_search_task,
}; };
use crate::ui::format_tool_output; use crate::ui::format_tool_output;
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // 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 resize_snap_index: usize, // Cycles through 25/50/75 snaps
last_snap_direction: Option<PaneDirection>, last_snap_direction: Option<PaneDirection>,
file_tree: FileTreeState, // Workspace file tree state 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_collapsed: bool, // Whether the file panel is collapsed
file_panel_width: u16, // Cached file panel width file_panel_width: u16, // Cached file panel width
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
@@ -384,6 +385,7 @@ impl ChatApp {
let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert;
let syntax_highlighting = config_guard.ui.syntax_highlighting; let syntax_highlighting = config_guard.ui.syntax_highlighting;
let show_timestamps = config_guard.ui.show_timestamps; let show_timestamps = config_guard.ui.show_timestamps;
let icon_mode = config_guard.ui.icon_mode;
drop(config_guard); drop(config_guard);
let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| {
eprintln!("Warning: Theme '{}' not found, using default", theme_name); 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 workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let file_tree = FileTreeState::new(workspace_root); let file_tree = FileTreeState::new(workspace_root);
let file_icons = FileIconResolver::from_mode(icon_mode);
let mut app = Self { let mut app = Self {
controller, controller,
@@ -454,6 +457,7 @@ impl ChatApp {
resize_snap_index: 0, resize_snap_index: 0,
last_snap_direction: None, last_snap_direction: None,
file_tree, file_tree,
file_icons,
file_panel_collapsed: true, file_panel_collapsed: true,
file_panel_width: 32, file_panel_width: 32,
saved_sessions: Vec::new(), saved_sessions: Vec::new(),
@@ -479,6 +483,12 @@ impl ChatApp {
show_message_timestamps: show_timestamps, 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(); app.update_command_palette_catalog();
if let Err(err) = app.restore_workspace_layout().await { if let Err(err) = app.restore_workspace_layout().await {
@@ -973,6 +983,10 @@ impl ChatApp {
&mut self.file_tree &mut self.file_tree
} }
pub fn file_icons(&self) -> &FileIconResolver {
&self.file_icons
}
pub fn workspace(&self) -> &CodeWorkspace { pub fn workspace(&self) -> &CodeWorkspace {
&self.code_workspace &self.code_workspace
} }

View File

@@ -1,6 +1,6 @@
pub use owlen_core::config::{ pub use owlen_core::config::{
Config, DEFAULT_CONFIG_PATH, GeneralSettings, InputSettings, StorageSettings, UiSettings, Config, DEFAULT_CONFIG_PATH, GeneralSettings, IconMode, InputSettings, StorageSettings,
default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, UiSettings, default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout,
}; };
/// Attempt to load configuration from default location /// Attempt to load configuration from default location

View File

@@ -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<FileIconSet> {
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,
}
}

View File

@@ -6,11 +6,13 @@
//! to test in isolation. //! to test in isolation.
mod command_palette; mod command_palette;
mod file_icons;
mod file_tree; mod file_tree;
mod search; mod search;
mod workspace; mod workspace;
pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion}; pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion};
pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
pub use file_tree::{ pub use file_tree::{
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
}; };

View File

@@ -617,6 +617,10 @@ impl CodeWorkspace {
self.active_tab().and_then(|tab| tab.active_pane()) self.active_tab().and_then(|tab| tab.active_pane())
} }
pub fn panes(&self) -> impl Iterator<Item = &CodePane> + '_ {
self.tabs.iter().flat_map(|tab| tab.panes.values())
}
pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> { pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> {
self.active_tab_mut().and_then(|tab| tab.active_pane_mut()) self.active_tab_mut().and_then(|tab| tab.active_pane_mut())
} }

View File

@@ -1,19 +1,20 @@
use pathdiff::diff_paths;
use ratatui::Frame; use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use serde_json; use serde_json;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::Path; use std::path::{Component, Path, PathBuf};
use tui_textarea::TextArea; use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind};
use crate::state::{ use crate::state::{
CodePane, EditorTab, FileFilterMode, LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId,
SplitAxis, RepoSearchRowKind, SplitAxis, VisibleFileEntry,
}; };
use owlen_core::model::DetailedModelInfo; use owlen_core::model::DetailedModelInfo;
use owlen_core::theme::Theme; 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 { mod focus_tests {
use super::*; use super::*;
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use std::path::Path;
fn theme() -> Theme { fn theme() -> Theme {
Theme::default() Theme::default()
@@ -166,6 +168,12 @@ mod focus_tests {
assert_eq!(inactive.fg, Some(theme.unfocused_panel_border)); assert_eq!(inactive.fg, Some(theme.unfocused_panel_border));
assert!(inactive.add_modifier.contains(Modifier::DIM)); 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) { 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<bool>,
is_last_sibling: bool,
}
fn compute_tree_line_info(
entries: &[VisibleFileEntry],
nodes: &[FileNode],
) -> Vec<TreeLineRenderInfo> {
let mut info = Vec::with_capacity(entries.len());
let mut sibling_stack: Vec<bool> = 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<PathBuf> {
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) { fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let theme = app.theme().clone(); let theme = app.theme().clone();
let has_focus = matches!(app.focused_panel(), FocusedPanel::Files); 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); 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 tree = app.file_tree();
let entries = tree.visible_entries(); 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 start = tree.scroll_top().min(entries.len());
let end = (start + viewport_height).min(entries.len()); let end = (start + viewport_height).min(entries.len());
let error_message = tree.last_error().map(|msg| msg.to_string()); 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()), .style(Style::default()),
); );
} else { } 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() { for (offset, entry) in entries[start..end].iter().enumerate() {
let absolute_idx = start + offset; let absolute_idx = start + offset;
let is_selected = absolute_idx == tree.cursor(); let is_selected = absolute_idx == tree.cursor();
let node = &tree.nodes()[entry.index]; let node = &tree.nodes()[entry.index];
let indent_level = entry.depth.saturating_sub(1); let info = &render_info[absolute_idx];
let mut spans: Vec<Span<'static>> = Vec::new(); let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(focus_beacon_span(is_selected, has_focus, &theme)); spans.push(focus_beacon_span(is_selected, has_focus, &theme));
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
if indent_level > 0 { for &has_more in &info.ancestor_has_sibling {
spans.push(Span::raw(" ".repeat(indent_level))); 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 { let toggle_symbol = if node.is_dir {
@@ -445,30 +561,23 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
} else { } else {
" " " "
}; };
spans.push(Span::styled( spans.push(Span::styled(toggle_symbol.to_string(), guide_style));
toggle_symbol.to_string(),
Style::default().fg(theme.text),
));
let marker_style = match node.git.cleanliness { let mut icon_style = if node.is_dir {
'●' => Style::default().fg(theme.error), Style::default().fg(theme.info)
'○' => 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),
));
} else { } 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); let mut name_style = Style::default().fg(theme.text);
if node.is_dir { if node.is_dir {
name_style = name_style.add_modifier(Modifier::BOLD); 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 { if node.is_hidden {
name_style = name_style.add_modifier(Modifier::DIM); 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)); spans.push(Span::styled(node.name.clone(), name_style));
let mut marker_spans: Vec<Span<'static>> = 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(); let mut line_style = Style::default();
if is_selected { if is_selected {
line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg); 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; 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 show_tab_bar = area.height > 2;
let content_area = if show_tab_bar { let content_area = if show_tab_bar {
let segments = Layout::default() let segments = Layout::default()
@@ -1862,7 +2016,14 @@ fn render_code_workspace(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
area 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) { 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); 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 { if area.width == 0 || area.height == 0 {
return; return;
} }
@@ -1945,12 +2113,23 @@ fn render_code_tab_content(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp,
return; return;
} }
let active_pane = *active; 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 { } else {
render_empty_workspace(frame, area, theme); render_empty_workspace(frame, area, theme);
} }
} }
#[allow(clippy::too_many_arguments)]
fn render_workspace_node( fn render_workspace_node(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
area: Rect, area: Rect,
@@ -1959,6 +2138,8 @@ fn render_workspace_node(
active_pane: PaneId, active_pane: PaneId,
theme: &Theme, theme: &Theme,
has_focus: bool, has_focus: bool,
repo_name: &str,
repo_root: &Path,
) { ) {
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return; return;
@@ -1968,7 +2149,9 @@ fn render_workspace_node(
LayoutNode::Leaf(id) => { LayoutNode::Leaf(id) => {
if let Some(pane) = panes.get_mut(id) { if let Some(pane) = panes.get_mut(id) {
let is_active = *id == active_pane; 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 { } else {
render_empty_workspace(frame, area, theme); render_empty_workspace(frame, area, theme);
} }
@@ -1989,6 +2172,8 @@ fn render_workspace_node(
active_pane, active_pane,
theme, theme,
has_focus, has_focus,
repo_name,
repo_root,
); );
} }
if second_area.width > 0 && second_area.height > 0 { if second_area.width > 0 && second_area.height > 0 {
@@ -2000,12 +2185,15 @@ fn render_workspace_node(
active_pane, active_pane,
theme, theme,
has_focus, has_focus,
repo_name,
repo_root,
); );
} }
} }
} }
} }
#[allow(clippy::too_many_arguments)]
fn render_code_pane( fn render_code_pane(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
area: Rect, area: Rect,
@@ -2013,6 +2201,8 @@ fn render_code_pane(
theme: &Theme, theme: &Theme,
has_focus: bool, has_focus: bool,
is_active: bool, is_active: bool,
repo_name: &str,
repo_root: &Path,
) { ) {
if area.width == 0 || area.height == 0 { if area.width == 0 || area.height == 0 {
return; return;
@@ -2053,7 +2243,7 @@ fn render_code_pane(
pane.scroll.on_viewport(viewport_height); pane.scroll.on_viewport(viewport_height);
let scroll_position = pane.scroll.scroll.min(u16::MAX as usize) as u16; let scroll_position = pane.scroll.scroll.min(u16::MAX as usize) as u16;
let title = pane let fallback_title = pane
.display_path() .display_path()
.map(|s| s.to_string()) .map(|s| s.to_string())
.or_else(|| { .or_else(|| {
@@ -2062,7 +2252,21 @@ fn render_code_pane(
}) })
.unwrap_or_else(|| pane.title.clone()); .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 { if is_active {
title_spans.push(Span::raw(" ")); title_spans.push(Span::raw(" "));
title_spans.push(Span::styled( title_spans.push(Span::styled(