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:
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
320
crates/owlen-tui/src/state/file_icons.rs
Normal file
320
crates/owlen-tui/src/state/file_icons.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user