Compare commits
5 Commits
55e6b0583d
...
0da8a3f193
| Author | SHA1 | Date | |
|---|---|---|---|
| 0da8a3f193 | |||
| 15f81d9728 | |||
| b80db89391 | |||
| f413a63c5a | |||
| 33ad3797a1 |
@@ -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)]
|
||||
@@ -722,6 +722,23 @@ pub struct UiSettings {
|
||||
pub show_cursor_outside_insert: bool,
|
||||
#[serde(default = "UiSettings::default_syntax_highlighting")]
|
||||
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 {
|
||||
@@ -765,6 +782,14 @@ impl UiSettings {
|
||||
false
|
||||
}
|
||||
|
||||
const fn default_show_timestamps() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_icon_mode() -> IconMode {
|
||||
IconMode::Auto
|
||||
}
|
||||
|
||||
fn deserialize_role_label_mode<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<RoleLabelDisplay, D::Error>
|
||||
@@ -831,6 +856,8 @@ impl Default for UiSettings {
|
||||
scrollback_lines: Self::default_scrollback_lines(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub enum InputMode {
|
||||
Command,
|
||||
SessionBrowser,
|
||||
ThemeBrowser,
|
||||
RepoSearch,
|
||||
SymbolSearch,
|
||||
}
|
||||
|
||||
impl fmt::Display for InputMode {
|
||||
@@ -35,6 +37,8 @@ impl fmt::Display for InputMode {
|
||||
InputMode::Command => "Command",
|
||||
InputMode::SessionBrowser => "Sessions",
|
||||
InputMode::ThemeBrowser => "Themes",
|
||||
InputMode::RepoSearch => "Search",
|
||||
InputMode::SymbolSearch => "Symbols",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
@@ -43,6 +47,7 @@ impl fmt::Display for InputMode {
|
||||
/// Represents which panel is currently focused in the TUI layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FocusedPanel {
|
||||
Files,
|
||||
Chat,
|
||||
Thinking,
|
||||
Input,
|
||||
|
||||
@@ -36,6 +36,42 @@ pub struct Theme {
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub unfocused_panel_border: Color,
|
||||
|
||||
/// Foreground color for the active pane beacon (`▌`)
|
||||
#[serde(default = "Theme::default_focus_beacon_fg")]
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub focus_beacon_fg: Color,
|
||||
|
||||
/// Background color for the active pane beacon (`▌`)
|
||||
#[serde(default = "Theme::default_focus_beacon_bg")]
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub focus_beacon_bg: Color,
|
||||
|
||||
/// Foreground color for the inactive pane beacon (`▌`)
|
||||
#[serde(default = "Theme::default_unfocused_beacon_fg")]
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub unfocused_beacon_fg: Color,
|
||||
|
||||
/// Title color for active pane headers
|
||||
#[serde(default = "Theme::default_pane_header_active")]
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub pane_header_active: Color,
|
||||
|
||||
/// Title color for inactive pane headers
|
||||
#[serde(default = "Theme::default_pane_header_inactive")]
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub pane_header_inactive: Color,
|
||||
|
||||
/// Hint text color used within pane headers
|
||||
#[serde(default = "Theme::default_pane_hint_text")]
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub pane_hint_text: Color,
|
||||
|
||||
/// Color for user message role indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
@@ -313,6 +349,30 @@ impl Theme {
|
||||
Color::Cyan
|
||||
}
|
||||
|
||||
const fn default_focus_beacon_fg() -> Color {
|
||||
Color::LightMagenta
|
||||
}
|
||||
|
||||
const fn default_focus_beacon_bg() -> Color {
|
||||
Color::Black
|
||||
}
|
||||
|
||||
const fn default_unfocused_beacon_fg() -> Color {
|
||||
Color::DarkGray
|
||||
}
|
||||
|
||||
const fn default_pane_header_active() -> Color {
|
||||
Color::White
|
||||
}
|
||||
|
||||
const fn default_pane_header_inactive() -> Color {
|
||||
Color::Gray
|
||||
}
|
||||
|
||||
const fn default_pane_hint_text() -> Color {
|
||||
Color::DarkGray
|
||||
}
|
||||
|
||||
const fn default_operating_chat_fg() -> Color {
|
||||
Color::Black
|
||||
}
|
||||
@@ -474,6 +534,12 @@ fn default_dark() -> Theme {
|
||||
background: Color::Black,
|
||||
focused_panel_border: Color::LightMagenta,
|
||||
unfocused_panel_border: Color::Rgb(95, 20, 135),
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::LightBlue,
|
||||
assistant_message_role: Color::Yellow,
|
||||
tool_output: Color::Gray,
|
||||
@@ -523,6 +589,12 @@ fn default_light() -> Theme {
|
||||
background: Color::White,
|
||||
focused_panel_border: Color::Rgb(74, 144, 226),
|
||||
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(0, 85, 164),
|
||||
assistant_message_role: Color::Rgb(142, 68, 173),
|
||||
tool_output: Color::Gray,
|
||||
@@ -572,7 +644,13 @@ fn gruvbox() -> Theme {
|
||||
background: Color::Rgb(40, 40, 40), // #282828
|
||||
focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange)
|
||||
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
|
||||
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
||||
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
|
||||
tool_output: Color::Rgb(146, 131, 116),
|
||||
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
|
||||
@@ -617,11 +695,17 @@ fn gruvbox() -> Theme {
|
||||
fn dracula() -> Theme {
|
||||
Theme {
|
||||
name: "dracula".to_string(),
|
||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
||||
background: Color::Rgb(40, 42, 54), // #282a36
|
||||
focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
||||
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
||||
background: Color::Rgb(40, 42, 54), // #282a36
|
||||
focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
||||
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||
tool_output: Color::Rgb(98, 114, 164),
|
||||
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
|
||||
@@ -670,6 +754,12 @@ fn solarized() -> Theme {
|
||||
background: Color::Rgb(0, 43, 54), // #002b36 (base03)
|
||||
focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue)
|
||||
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
|
||||
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
|
||||
tool_output: Color::Rgb(101, 123, 131),
|
||||
@@ -719,6 +809,12 @@ fn midnight_ocean() -> Theme {
|
||||
background: Color::Rgb(13, 17, 23),
|
||||
focused_panel_border: Color::Rgb(88, 166, 255),
|
||||
unfocused_panel_border: Color::Rgb(48, 54, 61),
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(121, 192, 255),
|
||||
assistant_message_role: Color::Rgb(137, 221, 255),
|
||||
tool_output: Color::Rgb(84, 110, 122),
|
||||
@@ -764,11 +860,17 @@ fn midnight_ocean() -> Theme {
|
||||
fn rose_pine() -> Theme {
|
||||
Theme {
|
||||
name: "rose-pine".to_string(),
|
||||
text: Color::Rgb(224, 222, 244), // #e0def4
|
||||
background: Color::Rgb(25, 23, 36), // #191724
|
||||
focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love)
|
||||
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
||||
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
||||
text: Color::Rgb(224, 222, 244), // #e0def4
|
||||
background: Color::Rgb(25, 23, 36), // #191724
|
||||
focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love)
|
||||
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
||||
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
|
||||
tool_output: Color::Rgb(110, 106, 134),
|
||||
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
|
||||
@@ -813,11 +915,17 @@ fn rose_pine() -> Theme {
|
||||
fn monokai() -> Theme {
|
||||
Theme {
|
||||
name: "monokai".to_string(),
|
||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
||||
background: Color::Rgb(39, 40, 34), // #272822
|
||||
focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink)
|
||||
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
||||
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
||||
background: Color::Rgb(39, 40, 34), // #272822
|
||||
focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink)
|
||||
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
||||
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
|
||||
tool_output: Color::Rgb(117, 113, 94),
|
||||
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
|
||||
@@ -862,11 +970,17 @@ fn monokai() -> Theme {
|
||||
fn material_dark() -> Theme {
|
||||
Theme {
|
||||
name: "material-dark".to_string(),
|
||||
text: Color::Rgb(238, 255, 255), // #eeffff
|
||||
background: Color::Rgb(38, 50, 56), // #263238
|
||||
focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan)
|
||||
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
||||
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
||||
text: Color::Rgb(238, 255, 255), // #eeffff
|
||||
background: Color::Rgb(38, 50, 56), // #263238
|
||||
focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan)
|
||||
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
||||
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
|
||||
tool_output: Color::Rgb(84, 110, 122),
|
||||
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
|
||||
@@ -915,6 +1029,12 @@ fn material_light() -> Theme {
|
||||
background: Color::Rgb(236, 239, 241),
|
||||
focused_panel_border: Color::Rgb(0, 150, 136),
|
||||
unfocused_panel_border: Color::Rgb(176, 190, 197),
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(68, 138, 255),
|
||||
assistant_message_role: Color::Rgb(124, 77, 255),
|
||||
tool_output: Color::Rgb(144, 164, 174),
|
||||
@@ -964,6 +1084,12 @@ fn grayscale_high_contrast() -> Theme {
|
||||
background: Color::Black,
|
||||
focused_panel_border: Color::White,
|
||||
unfocused_panel_border: Color::Rgb(76, 76, 76),
|
||||
focus_beacon_fg: Theme::default_focus_beacon_fg(),
|
||||
focus_beacon_bg: Theme::default_focus_beacon_bg(),
|
||||
unfocused_beacon_fg: Theme::default_unfocused_beacon_fg(),
|
||||
pane_header_active: Theme::default_pane_header_active(),
|
||||
pane_header_inactive: Theme::default_pane_header_inactive(),
|
||||
pane_hint_text: Theme::default_pane_hint_text(),
|
||||
user_message_role: Color::Rgb(240, 240, 240),
|
||||
assistant_message_role: Color::Rgb(214, 214, 214),
|
||||
tool_output: Color::Rgb(189, 189, 189),
|
||||
|
||||
@@ -20,6 +20,13 @@ textwrap = { workspace = true }
|
||||
unicode-width = "0.1"
|
||||
unicode-segmentation = "1.11"
|
||||
async-trait = "0.1"
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
pathdiff = "0.2"
|
||||
tree-sitter = "0.20"
|
||||
tree-sitter-rust = "0.20"
|
||||
dirs = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
@@ -30,6 +37,8 @@ futures-util = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -160,6 +160,26 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
keyword: "stop-agent",
|
||||
description: "Stop the running agent",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "agent status",
|
||||
description: "Show current agent status",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "agent start",
|
||||
description: "Arm the agent for the next request",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "agent stop",
|
||||
description: "Stop the running agent",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "layout save",
|
||||
description: "Persist the current pane layout",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "layout load",
|
||||
description: "Restore the last saved pane layout",
|
||||
},
|
||||
];
|
||||
|
||||
/// Return the static catalog of commands.
|
||||
@@ -168,29 +188,35 @@ pub fn all() -> &'static [CommandSpec] {
|
||||
}
|
||||
|
||||
/// Return the default suggestion list (all command keywords).
|
||||
pub fn default_suggestions() -> Vec<String> {
|
||||
COMMANDS
|
||||
.iter()
|
||||
.map(|spec| spec.keyword.to_string())
|
||||
.collect()
|
||||
pub fn default_suggestions() -> Vec<CommandSpec> {
|
||||
COMMANDS.to_vec()
|
||||
}
|
||||
|
||||
/// Generate keyword suggestions for the given input.
|
||||
pub fn suggestions(input: &str) -> Vec<String> {
|
||||
pub fn suggestions(input: &str) -> Vec<CommandSpec> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return default_suggestions();
|
||||
}
|
||||
COMMANDS
|
||||
|
||||
let mut matches: Vec<(usize, usize, CommandSpec)> = COMMANDS
|
||||
.iter()
|
||||
.filter_map(|spec| {
|
||||
if spec.keyword.starts_with(trimmed) {
|
||||
Some(spec.keyword.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
match_score(spec.keyword, trimmed).map(|score| (score.0, score.1, *spec))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
|
||||
if matches.is_empty() {
|
||||
return default_suggestions();
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| {
|
||||
a.0.cmp(&b.0)
|
||||
.then(a.1.cmp(&b.1))
|
||||
.then(a.2.keyword.cmp(b.2.keyword))
|
||||
});
|
||||
|
||||
matches.into_iter().map(|(_, _, spec)| spec).collect()
|
||||
}
|
||||
|
||||
pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> {
|
||||
@@ -219,6 +245,19 @@ pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suggestions_prioritize_agent_start() {
|
||||
let results = suggestions("agent st");
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0].keyword, "agent start");
|
||||
assert!(results.iter().any(|spec| spec.keyword == "agent stop"));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_subsequence(text: &str, pattern: &str) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return true;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
use crate::commands;
|
||||
use crate::commands::{self, CommandSpec};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
|
||||
const MAX_RESULTS: usize = 12;
|
||||
const MAX_HISTORY_RESULTS: usize = 4;
|
||||
const HISTORY_CAPACITY: usize = 20;
|
||||
|
||||
/// Encapsulates the command-line style palette used in command mode.
|
||||
///
|
||||
/// The palette keeps track of the raw buffer, matching suggestions, and the
|
||||
/// currently highlighted suggestion index. It contains no terminal-specific
|
||||
/// logic which makes it straightforward to unit test.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaletteGroup {
|
||||
History,
|
||||
Command,
|
||||
Model,
|
||||
Provider,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaletteSuggestion {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
pub group: PaletteGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelPaletteEntry {
|
||||
pub id: String,
|
||||
@@ -25,10 +47,11 @@ impl ModelPaletteEntry {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CommandPalette {
|
||||
buffer: String,
|
||||
suggestions: Vec<String>,
|
||||
suggestions: Vec<PaletteSuggestion>,
|
||||
selected: usize,
|
||||
models: Vec<ModelPaletteEntry>,
|
||||
providers: Vec<String>,
|
||||
history: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
@@ -40,7 +63,7 @@ impl CommandPalette {
|
||||
&self.buffer
|
||||
}
|
||||
|
||||
pub fn suggestions(&self) -> &[String] {
|
||||
pub fn suggestions(&self) -> &[PaletteSuggestion] {
|
||||
&self.suggestions
|
||||
}
|
||||
|
||||
@@ -54,6 +77,28 @@ impl CommandPalette {
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
pub fn remember(&mut self, value: impl AsRef<str>) {
|
||||
let trimmed = value.as_ref().trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid duplicate consecutive entries by removing any existing matching value.
|
||||
if let Some(pos) = self
|
||||
.history
|
||||
.iter()
|
||||
.position(|entry| entry.eq_ignore_ascii_case(trimmed))
|
||||
{
|
||||
self.history.remove(pos);
|
||||
}
|
||||
|
||||
self.history.push_back(trimmed.to_string());
|
||||
|
||||
while self.history.len() > HISTORY_CAPACITY {
|
||||
self.history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_buffer(&mut self, value: impl Into<String>) {
|
||||
self.buffer = value.into();
|
||||
self.refresh_suggestions();
|
||||
@@ -98,11 +143,11 @@ impl CommandPalette {
|
||||
.get(self.selected)
|
||||
.cloned()
|
||||
.or_else(|| self.suggestions.first().cloned());
|
||||
if let Some(value) = selected.clone() {
|
||||
self.buffer = value;
|
||||
if let Some(entry) = selected.clone() {
|
||||
self.buffer = entry.value.clone();
|
||||
self.refresh_suggestions();
|
||||
}
|
||||
selected
|
||||
selected.map(|entry| entry.value)
|
||||
}
|
||||
|
||||
pub fn refresh_suggestions(&mut self) {
|
||||
@@ -119,40 +164,177 @@ impl CommandPalette {
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<String> {
|
||||
if let Some(rest) = trimmed.strip_prefix("model ") {
|
||||
let suggestions = self.model_suggestions("model", rest.trim());
|
||||
if suggestions.is_empty() {
|
||||
commands::suggestions(trimmed)
|
||||
} else {
|
||||
suggestions
|
||||
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<PaletteSuggestion> {
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
let mut results: Vec<PaletteSuggestion> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
|
||||
fn push_entries(
|
||||
results: &mut Vec<PaletteSuggestion>,
|
||||
seen: &mut HashSet<String>,
|
||||
entries: Vec<PaletteSuggestion>,
|
||||
) {
|
||||
for entry in entries {
|
||||
if seen.insert(entry.value.to_ascii_lowercase()) {
|
||||
results.push(entry);
|
||||
}
|
||||
if results.len() >= MAX_RESULTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if let Some(rest) = trimmed.strip_prefix("m ") {
|
||||
let suggestions = self.model_suggestions("m", rest.trim());
|
||||
if suggestions.is_empty() {
|
||||
commands::suggestions(trimmed)
|
||||
} else {
|
||||
suggestions
|
||||
}
|
||||
} else if let Some(rest) = trimmed.strip_prefix("provider ") {
|
||||
let suggestions = self.provider_suggestions("provider", rest.trim());
|
||||
if suggestions.is_empty() {
|
||||
commands::suggestions(trimmed)
|
||||
} else {
|
||||
suggestions
|
||||
}
|
||||
} else {
|
||||
commands::suggestions(trimmed)
|
||||
}
|
||||
|
||||
let history = self.history_suggestions(trimmed);
|
||||
push_entries(&mut results, &mut seen, history);
|
||||
if results.len() >= MAX_RESULTS {
|
||||
return results;
|
||||
}
|
||||
|
||||
if lowered.starts_with("model ") {
|
||||
let rest = trimmed[5..].trim();
|
||||
push_entries(
|
||||
&mut results,
|
||||
&mut seen,
|
||||
self.model_suggestions("model", rest),
|
||||
);
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if lowered.starts_with("m ") {
|
||||
let rest = trimmed[2..].trim();
|
||||
push_entries(&mut results, &mut seen, self.model_suggestions("m", rest));
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if lowered == "model" {
|
||||
push_entries(&mut results, &mut seen, self.model_suggestions("model", ""));
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if lowered.starts_with("provider ") {
|
||||
let rest = trimmed[9..].trim();
|
||||
push_entries(
|
||||
&mut results,
|
||||
&mut seen,
|
||||
self.provider_suggestions("provider", rest),
|
||||
);
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if lowered == "provider" {
|
||||
push_entries(
|
||||
&mut results,
|
||||
&mut seen,
|
||||
self.provider_suggestions("provider", ""),
|
||||
);
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// General query – combine commands, models, and providers using fuzzy order.
|
||||
push_entries(&mut results, &mut seen, self.command_entries(trimmed));
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(
|
||||
&mut results,
|
||||
&mut seen,
|
||||
self.model_suggestions("model", trimmed),
|
||||
);
|
||||
}
|
||||
if results.len() < MAX_RESULTS {
|
||||
push_entries(
|
||||
&mut results,
|
||||
&mut seen,
|
||||
self.provider_suggestions("provider", trimmed),
|
||||
);
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn model_suggestions(&self, keyword: &str, query: &str) -> Vec<String> {
|
||||
fn history_suggestions(&self, query: &str) -> Vec<PaletteSuggestion> {
|
||||
if self.history.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if query.trim().is_empty() {
|
||||
return self
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(MAX_HISTORY_RESULTS)
|
||||
.map(|value| PaletteSuggestion {
|
||||
value: value.to_string(),
|
||||
label: value.to_string(),
|
||||
detail: Some("Recent command".to_string()),
|
||||
group: PaletteGroup::History,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut matches: Vec<(usize, usize, usize, &String)> = self
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.enumerate()
|
||||
.filter_map(|(recency, value)| {
|
||||
commands::match_score(value, query)
|
||||
.map(|(primary, secondary)| (primary, secondary, recency, value))
|
||||
})
|
||||
.collect();
|
||||
|
||||
matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
|
||||
|
||||
matches
|
||||
.into_iter()
|
||||
.take(MAX_HISTORY_RESULTS)
|
||||
.map(|(_, _, _, value)| PaletteSuggestion {
|
||||
value: value.to_string(),
|
||||
label: value.to_string(),
|
||||
detail: Some("Recent command".to_string()),
|
||||
group: PaletteGroup::History,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn command_entries(&self, query: &str) -> Vec<PaletteSuggestion> {
|
||||
let specs: Vec<CommandSpec> = commands::suggestions(query);
|
||||
specs
|
||||
.into_iter()
|
||||
.map(|spec| PaletteSuggestion {
|
||||
value: spec.keyword.to_string(),
|
||||
label: spec.keyword.to_string(),
|
||||
detail: Some(spec.description.to_string()),
|
||||
group: PaletteGroup::Command,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn model_suggestions(&self, keyword: &str, query: &str) -> Vec<PaletteSuggestion> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.models
|
||||
.iter()
|
||||
.take(15)
|
||||
.map(|entry| format!("{keyword} {}", entry.id))
|
||||
.map(|entry| PaletteSuggestion {
|
||||
value: format!("{keyword} {}", entry.id),
|
||||
label: entry.display_name().to_string(),
|
||||
detail: Some(format!("Model · {}", entry.provider)),
|
||||
group: PaletteGroup::Model,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
@@ -174,17 +356,27 @@ impl CommandPalette {
|
||||
matches
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.map(|(_, _, entry)| format!("{keyword} {}", entry.id))
|
||||
.map(|(_, _, entry)| PaletteSuggestion {
|
||||
value: format!("{keyword} {}", entry.id),
|
||||
label: entry.display_name().to_string(),
|
||||
detail: Some(format!("Model · {}", entry.provider)),
|
||||
group: PaletteGroup::Model,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<String> {
|
||||
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<PaletteSuggestion> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.providers
|
||||
.iter()
|
||||
.take(15)
|
||||
.map(|provider| format!("{keyword} {}", provider))
|
||||
.map(|provider| PaletteSuggestion {
|
||||
value: format!("{keyword} {}", provider),
|
||||
label: provider.to_string(),
|
||||
detail: Some("Provider".to_string()),
|
||||
group: PaletteGroup::Provider,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
@@ -201,7 +393,47 @@ impl CommandPalette {
|
||||
matches
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.map(|(_, _, provider)| format!("{keyword} {}", provider))
|
||||
.map(|(_, _, provider)| PaletteSuggestion {
|
||||
value: format!("{keyword} {}", provider),
|
||||
label: provider.to_string(),
|
||||
detail: Some("Provider".to_string()),
|
||||
group: PaletteGroup::Provider,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn history_entries_are_prioritized() {
|
||||
let mut palette = CommandPalette::new();
|
||||
palette.remember("open foo.rs");
|
||||
palette.remember("model llama");
|
||||
palette.ensure_suggestions();
|
||||
|
||||
let suggestions = palette.suggestions();
|
||||
assert!(!suggestions.is_empty());
|
||||
assert_eq!(suggestions[0].value, "model llama");
|
||||
assert!(matches!(suggestions[0].group, PaletteGroup::History));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_deduplicates_case_insensitively() {
|
||||
let mut palette = CommandPalette::new();
|
||||
palette.remember("open foo.rs");
|
||||
palette.remember("OPEN FOO.RS");
|
||||
palette.ensure_suggestions();
|
||||
|
||||
let history_entries: Vec<_> = palette
|
||||
.suggestions()
|
||||
.iter()
|
||||
.filter(|entry| matches!(entry.group, PaletteGroup::History))
|
||||
.collect();
|
||||
|
||||
assert_eq!(history_entries.len(), 1);
|
||||
assert_eq!(history_entries[0].value, "OPEN FOO.RS");
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
705
crates/owlen-tui/src/state/file_tree.rs
Normal file
705
crates/owlen-tui/src/state/file_tree.rs
Normal file
@@ -0,0 +1,705 @@
|
||||
use crate::commands;
|
||||
use anyhow::{Context, Result};
|
||||
use globset::{Glob, GlobBuilder, GlobSetBuilder};
|
||||
use ignore::WalkBuilder;
|
||||
use pathdiff::diff_paths;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Indicates which matching strategy is applied when filtering the file tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FilterMode {
|
||||
Glob,
|
||||
Fuzzy,
|
||||
}
|
||||
|
||||
/// Git-related decorations rendered alongside a file entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitDecoration {
|
||||
pub badge: Option<char>,
|
||||
pub cleanliness: char,
|
||||
}
|
||||
|
||||
impl GitDecoration {
|
||||
pub fn clean() -> Self {
|
||||
Self {
|
||||
badge: None,
|
||||
cleanliness: '✓',
|
||||
}
|
||||
}
|
||||
|
||||
pub fn staged(badge: Option<char>) -> Self {
|
||||
Self {
|
||||
badge,
|
||||
cleanliness: '○',
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirty(badge: Option<char>) -> Self {
|
||||
Self {
|
||||
badge,
|
||||
cleanliness: '●',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Node representing a single entry (file or directory) in the tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileNode {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub parent: Option<usize>,
|
||||
pub children: Vec<usize>,
|
||||
pub depth: usize,
|
||||
pub is_dir: bool,
|
||||
pub is_expanded: bool,
|
||||
pub is_hidden: bool,
|
||||
pub git: GitDecoration,
|
||||
}
|
||||
|
||||
impl FileNode {
|
||||
fn should_default_expand(&self) -> bool {
|
||||
self.depth < 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible entry metadata returned to the renderer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibleFileEntry {
|
||||
pub index: usize,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
/// Tracks the entire file tree state including filters, selection, and scroll.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeState {
|
||||
root: PathBuf,
|
||||
repo_name: String,
|
||||
nodes: Vec<FileNode>,
|
||||
visible: Vec<VisibleFileEntry>,
|
||||
cursor: usize,
|
||||
scroll_top: usize,
|
||||
viewport_height: usize,
|
||||
filter_mode: FilterMode,
|
||||
filter_query: String,
|
||||
show_hidden: bool,
|
||||
filter_matches: Vec<bool>,
|
||||
last_error: Option<String>,
|
||||
git_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl FileTreeState {
|
||||
/// Construct a new file tree rooted at the provided path.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let mut root_path = root.into();
|
||||
if let Ok(canonical) = root_path.canonicalize() {
|
||||
root_path = canonical;
|
||||
}
|
||||
let repo_name = root_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| root_path.display().to_string());
|
||||
|
||||
let mut state = Self {
|
||||
root: root_path,
|
||||
repo_name,
|
||||
nodes: Vec::new(),
|
||||
visible: Vec::new(),
|
||||
cursor: 0,
|
||||
scroll_top: 0,
|
||||
viewport_height: 20,
|
||||
filter_mode: FilterMode::Fuzzy,
|
||||
filter_query: String::new(),
|
||||
show_hidden: false,
|
||||
filter_matches: Vec::new(),
|
||||
last_error: None,
|
||||
git_branch: None,
|
||||
};
|
||||
|
||||
if let Err(err) = state.refresh() {
|
||||
state.nodes.clear();
|
||||
state.visible.clear();
|
||||
state.filter_matches.clear();
|
||||
state.last_error = Some(err.to_string());
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Rebuild the file tree from disk and recompute visibility.
|
||||
pub fn refresh(&mut self) -> Result<()> {
|
||||
let git_map = collect_git_status(&self.root).unwrap_or_default();
|
||||
self.nodes = build_nodes(&self.root, self.show_hidden, git_map)?;
|
||||
self.git_branch = current_git_branch(&self.root).unwrap_or(None);
|
||||
if self.nodes.is_empty() {
|
||||
self.visible.clear();
|
||||
self.filter_matches.clear();
|
||||
self.cursor = 0;
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_valid_cursor();
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn repo_name(&self) -> &str {
|
||||
&self.repo_name
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.visible.is_empty()
|
||||
}
|
||||
|
||||
pub fn visible_entries(&self) -> &[VisibleFileEntry] {
|
||||
&self.visible
|
||||
}
|
||||
|
||||
pub fn nodes(&self) -> &[FileNode] {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.visible.get(self.cursor).map(|entry| entry.index)
|
||||
}
|
||||
|
||||
pub fn selected_node(&self) -> Option<&FileNode> {
|
||||
self.selected_index().and_then(|idx| self.nodes.get(idx))
|
||||
}
|
||||
|
||||
pub fn selected_node_mut(&mut self) -> Option<&mut FileNode> {
|
||||
let idx = self.selected_index()?;
|
||||
self.nodes.get_mut(idx)
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn scroll_top(&self) -> usize {
|
||||
self.scroll_top
|
||||
}
|
||||
|
||||
pub fn viewport_height(&self) -> usize {
|
||||
self.viewport_height
|
||||
}
|
||||
|
||||
pub fn filter_mode(&self) -> FilterMode {
|
||||
self.filter_mode
|
||||
}
|
||||
|
||||
pub fn filter_query(&self) -> &str {
|
||||
&self.filter_query
|
||||
}
|
||||
|
||||
pub fn show_hidden(&self) -> bool {
|
||||
self.show_hidden
|
||||
}
|
||||
|
||||
pub fn git_branch(&self) -> Option<&str> {
|
||||
self.git_branch.as_deref()
|
||||
}
|
||||
|
||||
pub fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_viewport_height(&mut self, height: usize) {
|
||||
self.viewport_height = height.max(1);
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
|
||||
pub fn move_cursor(&mut self, delta: isize) {
|
||||
if self.visible.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let len = self.visible.len() as isize;
|
||||
let new_cursor = (self.cursor as isize + delta).clamp(0, len - 1) as usize;
|
||||
self.cursor = new_cursor;
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
|
||||
pub fn jump_to_top(&mut self) {
|
||||
if !self.visible.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jump_to_bottom(&mut self) {
|
||||
if !self.visible.is_empty() {
|
||||
self.cursor = self.visible.len().saturating_sub(1);
|
||||
let viewport = self.viewport_height.max(1);
|
||||
self.scroll_top = self.visible.len().saturating_sub(viewport);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
let amount = self.viewport_height.max(1) as isize;
|
||||
self.move_cursor(amount);
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
let amount = -(self.viewport_height.max(1) as isize);
|
||||
self.move_cursor(amount);
|
||||
}
|
||||
|
||||
pub fn toggle_expand(&mut self) {
|
||||
if let Some(node) = self.selected_node_mut() {
|
||||
if !node.is_dir {
|
||||
return;
|
||||
}
|
||||
node.is_expanded = !node.is_expanded;
|
||||
self.rebuild_visible();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_filter_query(&mut self, query: impl Into<String>) {
|
||||
self.filter_query = query.into();
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
pub fn clear_filter(&mut self) {
|
||||
self.filter_query.clear();
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
pub fn toggle_filter_mode(&mut self) {
|
||||
self.filter_mode = match self.filter_mode {
|
||||
FilterMode::Glob => FilterMode::Fuzzy,
|
||||
FilterMode::Fuzzy => FilterMode::Glob,
|
||||
};
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
pub fn toggle_hidden(&mut self) -> Result<()> {
|
||||
self.show_hidden = !self.show_hidden;
|
||||
self.refresh()
|
||||
}
|
||||
|
||||
/// Expand directories along the provided path and position the cursor.
|
||||
pub fn reveal(&mut self, path: &Path) {
|
||||
if self.nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(rel) = diff_paths(path, &self.root)
|
||||
&& let Some(index) = self
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|node| node.path == rel || node.path == path)
|
||||
{
|
||||
self.expand_to(index);
|
||||
if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) {
|
||||
self.cursor = cursor_pos;
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_to(&mut self, index: usize) {
|
||||
let mut current = Some(index);
|
||||
while let Some(idx) = current {
|
||||
if let Some(parent) = self.nodes.get(idx).and_then(|node| node.parent) {
|
||||
if let Some(parent_node) = self.nodes.get_mut(parent) {
|
||||
parent_node.is_expanded = true;
|
||||
}
|
||||
current = Some(parent);
|
||||
} else {
|
||||
current = None;
|
||||
}
|
||||
}
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
fn ensure_valid_cursor(&mut self) {
|
||||
if self.cursor >= self.visible.len() {
|
||||
self.cursor = self.visible.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_cursor_in_view(&mut self) {
|
||||
if self.visible.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let viewport = self.viewport_height.max(1);
|
||||
if self.cursor < self.scroll_top {
|
||||
self.scroll_top = self.cursor;
|
||||
} else if self.cursor >= self.scroll_top + viewport {
|
||||
self.scroll_top = self.cursor + 1 - viewport;
|
||||
}
|
||||
}
|
||||
|
||||
fn recompute_filter_cache(&mut self) {
|
||||
let has_filter = !self.filter_query.trim().is_empty();
|
||||
self.filter_matches = if !has_filter {
|
||||
vec![true; self.nodes.len()]
|
||||
} else {
|
||||
self.nodes
|
||||
.iter()
|
||||
.map(|node| match self.filter_mode {
|
||||
FilterMode::Glob => glob_matches(self.filter_query.trim(), node),
|
||||
FilterMode::Fuzzy => fuzzy_matches(self.filter_query.trim(), node),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if has_filter {
|
||||
// Ensure parent directories of matches are preserved.
|
||||
for idx in (0..self.nodes.len()).rev() {
|
||||
let children = self.nodes[idx].children.clone();
|
||||
if !self.filter_matches[idx]
|
||||
&& children
|
||||
.iter()
|
||||
.any(|child| self.filter_matches.get(*child).copied().unwrap_or(false))
|
||||
{
|
||||
self.filter_matches[idx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_visible(&mut self) {
|
||||
self.visible.clear();
|
||||
|
||||
if self.nodes.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let has_filter = !self.filter_query.trim().is_empty();
|
||||
self.walk_visible(0, has_filter);
|
||||
if self.visible.is_empty() {
|
||||
// At minimum show the root node.
|
||||
self.visible.push(VisibleFileEntry {
|
||||
index: 0,
|
||||
depth: self.nodes[0].depth,
|
||||
});
|
||||
}
|
||||
let max_index = self.visible.len().saturating_sub(1);
|
||||
self.cursor = self.cursor.min(max_index);
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
|
||||
fn walk_visible(&mut self, index: usize, filter_override: bool) {
|
||||
if !self.filter_matches.get(index).copied().unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (depth, descend, children) = {
|
||||
let node = match self.nodes.get(index) {
|
||||
Some(node) => node,
|
||||
None => return,
|
||||
};
|
||||
let descend = if filter_override {
|
||||
node.is_dir
|
||||
} else {
|
||||
node.is_dir && node.is_expanded
|
||||
};
|
||||
let children = if node.is_dir {
|
||||
node.children.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(node.depth, descend, children)
|
||||
};
|
||||
|
||||
self.visible.push(VisibleFileEntry { index, depth });
|
||||
|
||||
if descend {
|
||||
for child in children {
|
||||
self.walk_visible(child, filter_override);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn glob_matches(pattern: &str, node: &FileNode) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
match GlobBuilder::new(pattern).literal_separator(true).build() {
|
||||
Ok(glob) => {
|
||||
builder.add(glob);
|
||||
if let Ok(set) = builder.build() {
|
||||
return set.is_match(&node.path) || set.is_match(node.name.as_str());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if let Ok(glob) = Glob::new("**") {
|
||||
builder.add(glob);
|
||||
if let Ok(set) = builder.build() {
|
||||
return set.is_match(&node.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn fuzzy_matches(query: &str, node: &FileNode) -> bool {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let path_str = node.path.to_string_lossy();
|
||||
let name = node.name.as_str();
|
||||
|
||||
commands::match_score(&path_str, query)
|
||||
.or_else(|| commands::match_score(name, query))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn build_nodes(
|
||||
root: &Path,
|
||||
show_hidden: bool,
|
||||
git_map: HashMap<PathBuf, GitDecoration>,
|
||||
) -> Result<Vec<FileNode>> {
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder.hidden(!show_hidden);
|
||||
builder.git_global(true);
|
||||
builder.git_ignore(true);
|
||||
builder.git_exclude(true);
|
||||
builder.follow_links(false);
|
||||
builder.sort_by_file_path(|a, b| a.file_name().cmp(&b.file_name()));
|
||||
|
||||
let owlen_ignore = root.join(".owlenignore");
|
||||
if owlen_ignore.exists() {
|
||||
builder.add_ignore(&owlen_ignore);
|
||||
}
|
||||
|
||||
let mut nodes: Vec<FileNode> = Vec::new();
|
||||
let mut index_by_path: HashMap<PathBuf, usize> = HashMap::new();
|
||||
|
||||
for result in builder.build() {
|
||||
let entry = match result {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("File tree walk error: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip errors or entries without metadata.
|
||||
let file_type = match entry.file_type() {
|
||||
Some(ft) => ft,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let depth = entry.depth();
|
||||
if depth == 0 && !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative = if depth == 0 {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
diff_paths(entry.path(), root).unwrap_or_else(|| entry.path().to_path_buf())
|
||||
};
|
||||
|
||||
let name = if depth == 0 {
|
||||
root.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| root.display().to_string())
|
||||
} else {
|
||||
entry.file_name().to_string_lossy().into_owned()
|
||||
};
|
||||
|
||||
let parent = if depth == 0 {
|
||||
None
|
||||
} else {
|
||||
entry
|
||||
.path()
|
||||
.parent()
|
||||
.and_then(|parent| diff_paths(parent, root))
|
||||
.and_then(|rel_parent| index_by_path.get(&rel_parent).copied())
|
||||
};
|
||||
|
||||
let git = git_map
|
||||
.get(&relative)
|
||||
.cloned()
|
||||
.unwrap_or_else(GitDecoration::clean);
|
||||
|
||||
let mut node = FileNode {
|
||||
name,
|
||||
path: relative.clone(),
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
depth,
|
||||
is_dir: file_type.is_dir(),
|
||||
is_expanded: false,
|
||||
is_hidden: is_hidden(entry.file_name()),
|
||||
git,
|
||||
};
|
||||
|
||||
node.is_expanded = node.should_default_expand();
|
||||
|
||||
let index = nodes.len();
|
||||
if let Some(parent_idx) = parent
|
||||
&& let Some(parent_node) = nodes.get_mut(parent_idx)
|
||||
{
|
||||
parent_node.children.push(index);
|
||||
}
|
||||
|
||||
index_by_path.insert(relative, index);
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
propagate_directory_git_state(&mut nodes);
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
fn is_hidden(name: &OsStr) -> bool {
|
||||
name.to_string_lossy().starts_with('.')
|
||||
}
|
||||
|
||||
fn propagate_directory_git_state(nodes: &mut [FileNode]) {
|
||||
for idx in (0..nodes.len()).rev() {
|
||||
if !nodes[idx].is_dir {
|
||||
continue;
|
||||
}
|
||||
let mut has_dirty = false;
|
||||
let mut has_staged = false;
|
||||
for child in nodes[idx].children.clone() {
|
||||
match nodes.get(child).map(|n| n.git.cleanliness) {
|
||||
Some('●') => {
|
||||
has_dirty = true;
|
||||
break;
|
||||
}
|
||||
Some('○') => {
|
||||
has_staged = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
nodes[idx].git = if has_dirty {
|
||||
GitDecoration::dirty(None)
|
||||
} else if has_staged {
|
||||
GitDecoration::staged(None)
|
||||
} else {
|
||||
GitDecoration::clean()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_git_status(root: &Path) -> Result<HashMap<PathBuf, GitDecoration>> {
|
||||
if !root.join(".git").exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.arg("status")
|
||||
.arg("--porcelain")
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run git status in {}", root.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut chars = line.chars();
|
||||
let x = chars.next().unwrap_or(' ');
|
||||
let y = chars.next().unwrap_or(' ');
|
||||
if x == '!' || y == '!' {
|
||||
// ignored entry
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut path_part = line[3..].trim();
|
||||
if let Some(idx) = path_part.rfind(" -> ") {
|
||||
path_part = &path_part[idx + 4..];
|
||||
}
|
||||
|
||||
let path = PathBuf::from(path_part);
|
||||
|
||||
if let Some(decoration) = decode_git_status(x, y) {
|
||||
map.insert(path, decoration);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn current_git_branch(root: &Path) -> Result<Option<String>> {
|
||||
if !root.join(".git").exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.arg("rev-parse")
|
||||
.arg("--abbrev-ref")
|
||||
.arg("HEAD")
|
||||
.output()
|
||||
.with_context(|| format!("Failed to query git branch in {}", root.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if branch.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(branch))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_git_status(x: char, y: char) -> Option<GitDecoration> {
|
||||
if x == ' ' && y == ' ' {
|
||||
return Some(GitDecoration::clean());
|
||||
}
|
||||
|
||||
if x == '?' && y == '?' {
|
||||
return Some(GitDecoration::dirty(Some('A')));
|
||||
}
|
||||
|
||||
let badge = match (x, y) {
|
||||
('M', _) | (_, 'M') => Some('M'),
|
||||
('A', _) | (_, 'A') => Some('A'),
|
||||
('D', _) | (_, 'D') => Some('D'),
|
||||
('R', _) | (_, 'R') => Some('R'),
|
||||
('C', _) | (_, 'C') => Some('A'),
|
||||
('U', _) | (_, 'U') => Some('U'),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if y != ' ' {
|
||||
Some(GitDecoration::dirty(badge))
|
||||
} else if x != ' ' {
|
||||
Some(GitDecoration::staged(badge))
|
||||
} else {
|
||||
Some(GitDecoration::clean())
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,22 @@
|
||||
//! to test in isolation.
|
||||
|
||||
mod command_palette;
|
||||
mod file_icons;
|
||||
mod file_tree;
|
||||
mod search;
|
||||
mod workspace;
|
||||
|
||||
pub use command_palette::{CommandPalette, ModelPaletteEntry};
|
||||
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,
|
||||
};
|
||||
pub use search::{
|
||||
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
||||
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
||||
spawn_repo_search_task, spawn_symbol_search_task,
|
||||
};
|
||||
pub use workspace::{
|
||||
CodePane, CodeWorkspace, EditorTab, LayoutNode, PaneDirection, PaneId, PaneRestoreRequest,
|
||||
SplitAxis, WorkspaceSnapshot,
|
||||
};
|
||||
|
||||
1056
crates/owlen-tui/src/state/search.rs
Normal file
1056
crates/owlen-tui/src/state/search.rs
Normal file
File diff suppressed because it is too large
Load Diff
887
crates/owlen-tui/src/state/workspace.rs
Normal file
887
crates/owlen-tui/src/state/workspace.rs
Normal file
@@ -0,0 +1,887 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use owlen_core::state::AutoScroll;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cardinal direction used for navigating between panes or resizing splits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaneDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ChildSide {
|
||||
First,
|
||||
Second,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PathEntry {
|
||||
axis: SplitAxis,
|
||||
side: ChildSide,
|
||||
}
|
||||
|
||||
/// Identifier assigned to each pane rendered inside a tab.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PaneId(u64);
|
||||
|
||||
impl PaneId {
|
||||
fn next(counter: &mut u64) -> Self {
|
||||
*counter += 1;
|
||||
PaneId(*counter)
|
||||
}
|
||||
|
||||
pub fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_raw(raw: u64) -> Self {
|
||||
PaneId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifier used to refer to a tab within the workspace.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TabId(u64);
|
||||
|
||||
impl TabId {
|
||||
fn next(counter: &mut u64) -> Self {
|
||||
*counter += 1;
|
||||
TabId(*counter)
|
||||
}
|
||||
|
||||
pub fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_raw(raw: u64) -> Self {
|
||||
TabId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction used when splitting a pane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SplitAxis {
|
||||
/// Split horizontally to create a pane below the current one.
|
||||
Horizontal,
|
||||
/// Split vertically to create a pane to the right of the current one.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Layout node describing either a leaf pane or a container split.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum LayoutNode {
|
||||
Leaf(PaneId),
|
||||
Split {
|
||||
axis: SplitAxis,
|
||||
ratio: f32,
|
||||
first: Box<LayoutNode>,
|
||||
second: Box<LayoutNode>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutNode {
|
||||
fn replace_leaf(&mut self, target: PaneId, replacement: LayoutNode) -> bool {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => {
|
||||
if *id == target {
|
||||
*self = replacement;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
first.replace_leaf(target, replacement.clone())
|
||||
|| second.replace_leaf(target, replacement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter_leaves<'a>(&'a self, panes: &'a HashMap<PaneId, CodePane>) -> Vec<&'a CodePane> {
|
||||
let mut collected = Vec::new();
|
||||
self.collect_leaves(panes, &mut collected);
|
||||
collected
|
||||
}
|
||||
|
||||
fn collect_leaves<'a>(
|
||||
&'a self,
|
||||
panes: &'a HashMap<PaneId, CodePane>,
|
||||
output: &mut Vec<&'a CodePane>,
|
||||
) {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => {
|
||||
if let Some(pane) = panes.get(id) {
|
||||
output.push(pane);
|
||||
}
|
||||
}
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
first.collect_leaves(panes, output);
|
||||
second.collect_leaves(panes, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to(&self, target: PaneId) -> Option<Vec<PathEntry>> {
|
||||
let mut path = Vec::new();
|
||||
if self.path_to_inner(target, &mut path) {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_inner(&self, target: PaneId, path: &mut Vec<PathEntry>) -> bool {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => *id == target,
|
||||
LayoutNode::Split {
|
||||
axis,
|
||||
first,
|
||||
second,
|
||||
..
|
||||
} => {
|
||||
path.push(PathEntry {
|
||||
axis: *axis,
|
||||
side: ChildSide::First,
|
||||
});
|
||||
if first.path_to_inner(target, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
path.push(PathEntry {
|
||||
axis: *axis,
|
||||
side: ChildSide::Second,
|
||||
});
|
||||
if second.path_to_inner(target, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subtree(&self, path: &[PathEntry]) -> Option<&LayoutNode> {
|
||||
let mut node = self;
|
||||
for entry in path {
|
||||
match node {
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
node = match entry.side {
|
||||
ChildSide::First => first.as_ref(),
|
||||
ChildSide::Second => second.as_ref(),
|
||||
};
|
||||
}
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
fn subtree_mut(&mut self, path: &[PathEntry]) -> Option<&mut LayoutNode> {
|
||||
let mut node = self;
|
||||
for entry in path {
|
||||
match node {
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
node = match entry.side {
|
||||
ChildSide::First => first.as_mut(),
|
||||
ChildSide::Second => second.as_mut(),
|
||||
};
|
||||
}
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
fn extreme_leaf(&self, prefer_second: bool) -> Option<PaneId> {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => Some(*id),
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
if prefer_second {
|
||||
second
|
||||
.extreme_leaf(prefer_second)
|
||||
.or_else(|| first.extreme_leaf(prefer_second))
|
||||
} else {
|
||||
first
|
||||
.extreme_leaf(prefer_second)
|
||||
.or_else(|| second.extreme_leaf(prefer_second))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderable pane that holds file contents and scroll state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodePane {
|
||||
pub id: PaneId,
|
||||
pub absolute_path: Option<PathBuf>,
|
||||
pub display_path: Option<String>,
|
||||
pub title: String,
|
||||
pub lines: Vec<String>,
|
||||
pub scroll: AutoScroll,
|
||||
pub viewport_height: usize,
|
||||
pub is_dirty: bool,
|
||||
pub is_staged: bool,
|
||||
}
|
||||
|
||||
impl CodePane {
|
||||
pub fn new(id: PaneId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
absolute_path: None,
|
||||
display_path: None,
|
||||
title: "Untitled".to_string(),
|
||||
lines: Vec::new(),
|
||||
scroll: AutoScroll::default(),
|
||||
viewport_height: 0,
|
||||
is_dirty: false,
|
||||
is_staged: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_contents(
|
||||
&mut self,
|
||||
absolute_path: Option<PathBuf>,
|
||||
display_path: Option<String>,
|
||||
lines: Vec<String>,
|
||||
) {
|
||||
self.absolute_path = absolute_path;
|
||||
self.display_path = display_path;
|
||||
self.title = self
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or_else(|| self.display_path.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
self.lines = lines;
|
||||
self.scroll = AutoScroll::default();
|
||||
self.scroll.content_len = self.lines.len();
|
||||
self.scroll.stick_to_bottom = false;
|
||||
self.scroll.scroll = 0;
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.absolute_path = None;
|
||||
self.display_path = None;
|
||||
self.title = "Untitled".to_string();
|
||||
self.lines.clear();
|
||||
self.scroll = AutoScroll::default();
|
||||
self.viewport_height = 0;
|
||||
self.is_dirty = false;
|
||||
self.is_staged = false;
|
||||
}
|
||||
|
||||
pub fn set_viewport_height(&mut self, height: usize) {
|
||||
self.viewport_height = height;
|
||||
}
|
||||
|
||||
pub fn display_path(&self) -> Option<&str> {
|
||||
self.display_path.as_deref()
|
||||
}
|
||||
|
||||
pub fn absolute_path(&self) -> Option<&Path> {
|
||||
self.absolute_path.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual tab containing a layout tree and panes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorTab {
|
||||
pub id: TabId,
|
||||
pub title: String,
|
||||
pub root: LayoutNode,
|
||||
pub panes: HashMap<PaneId, CodePane>,
|
||||
pub active: PaneId,
|
||||
}
|
||||
|
||||
impl EditorTab {
|
||||
fn new(id: TabId, title: String, pane: CodePane) -> Self {
|
||||
let active = pane.id;
|
||||
let mut panes = HashMap::new();
|
||||
panes.insert(pane.id, pane);
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
root: LayoutNode::Leaf(active),
|
||||
panes,
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_pane(&self) -> Option<&CodePane> {
|
||||
self.panes.get(&self.active)
|
||||
}
|
||||
|
||||
pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> {
|
||||
self.panes.get_mut(&self.active)
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, pane: PaneId) {
|
||||
if self.panes.contains_key(&pane) {
|
||||
self.active = pane;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_title_from_active(&mut self) {
|
||||
if let Some(pane) = self.active_pane() {
|
||||
self.title = pane
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or_else(|| pane.display_path.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn active_path(&self) -> Option<Vec<PathEntry>> {
|
||||
self.root.path_to(self.active)
|
||||
}
|
||||
|
||||
pub fn move_focus(&mut self, direction: PaneDirection) -> bool {
|
||||
let path = match self.active_path() {
|
||||
Some(path) => path,
|
||||
None => return false,
|
||||
};
|
||||
let axis = match direction {
|
||||
PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical,
|
||||
PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal,
|
||||
};
|
||||
|
||||
for (idx, entry) in path.iter().enumerate().rev() {
|
||||
if entry.axis != axis {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (required_side, target_side, prefer_second) = match direction {
|
||||
PaneDirection::Left => (ChildSide::Second, ChildSide::First, true),
|
||||
PaneDirection::Right => (ChildSide::First, ChildSide::Second, false),
|
||||
PaneDirection::Up => (ChildSide::Second, ChildSide::First, true),
|
||||
PaneDirection::Down => (ChildSide::First, ChildSide::Second, false),
|
||||
};
|
||||
|
||||
if entry.side != required_side {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent_path = &path[..idx];
|
||||
let Some(parent) = self.root.subtree(parent_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let LayoutNode::Split { first, second, .. } = parent {
|
||||
let target = match target_side {
|
||||
ChildSide::First => first.as_ref(),
|
||||
ChildSide::Second => second.as_ref(),
|
||||
};
|
||||
if let Some(pane_id) = target.extreme_leaf(prefer_second)
|
||||
&& self.panes.contains_key(&pane_id)
|
||||
{
|
||||
self.active = pane_id;
|
||||
self.update_title_from_active();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option<f32> {
|
||||
let path = self.active_path()?;
|
||||
|
||||
let axis = match direction {
|
||||
PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical,
|
||||
PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal,
|
||||
};
|
||||
|
||||
let (idx, entry) = path
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, entry)| entry.axis == axis)?;
|
||||
|
||||
let parent_path = &path[..idx];
|
||||
let parent = self.root.subtree_mut(parent_path)?;
|
||||
|
||||
let LayoutNode::Split { ratio, .. } = parent else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let sign = match direction {
|
||||
PaneDirection::Left => {
|
||||
if entry.side == ChildSide::First {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
PaneDirection::Right => {
|
||||
if entry.side == ChildSide::First {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
PaneDirection::Up => {
|
||||
if entry.side == ChildSide::First {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
PaneDirection::Down => {
|
||||
if entry.side == ChildSide::First {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut new_ratio = (*ratio + amount * sign).clamp(0.1, 0.9);
|
||||
if (new_ratio - *ratio).abs() < f32::EPSILON {
|
||||
return Some(self.active_share_from(entry.side, new_ratio));
|
||||
}
|
||||
*ratio = new_ratio;
|
||||
new_ratio = new_ratio.clamp(0.1, 0.9);
|
||||
Some(self.active_share_from(entry.side, new_ratio))
|
||||
}
|
||||
|
||||
pub fn snap_active_share(
|
||||
&mut self,
|
||||
direction: PaneDirection,
|
||||
desired_share: f32,
|
||||
) -> Option<f32> {
|
||||
let path = self.active_path()?;
|
||||
|
||||
let axis = match direction {
|
||||
PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical,
|
||||
PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal,
|
||||
};
|
||||
|
||||
let (idx, entry) = path
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, entry)| entry.axis == axis)?;
|
||||
|
||||
let parent_path = &path[..idx];
|
||||
let parent = self.root.subtree_mut(parent_path)?;
|
||||
|
||||
let LayoutNode::Split { ratio, .. } = parent else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut target_ratio = match entry.side {
|
||||
ChildSide::First => desired_share,
|
||||
ChildSide::Second => 1.0 - desired_share,
|
||||
}
|
||||
.clamp(0.1, 0.9);
|
||||
|
||||
if (target_ratio - *ratio).abs() < f32::EPSILON {
|
||||
return Some(self.active_share_from(entry.side, target_ratio));
|
||||
}
|
||||
|
||||
*ratio = target_ratio;
|
||||
target_ratio = target_ratio.clamp(0.1, 0.9);
|
||||
Some(self.active_share_from(entry.side, target_ratio))
|
||||
}
|
||||
|
||||
pub fn active_share(&self) -> Option<f32> {
|
||||
let path = self.active_path()?;
|
||||
let (idx, entry) =
|
||||
path.iter().enumerate().rev().find(|(_, entry)| {
|
||||
matches!(entry.axis, SplitAxis::Horizontal | SplitAxis::Vertical)
|
||||
})?;
|
||||
let parent_path = &path[..idx];
|
||||
let parent = self.root.subtree(parent_path)?;
|
||||
if let LayoutNode::Split { ratio, .. } = parent {
|
||||
Some(self.active_share_from(entry.side, *ratio))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn active_share_from(&self, side: ChildSide, ratio: f32) -> f32 {
|
||||
match side {
|
||||
ChildSide::First => ratio,
|
||||
ChildSide::Second => 1.0 - ratio,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level workspace managing tabs and panes for the code viewer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeWorkspace {
|
||||
tabs: Vec<EditorTab>,
|
||||
active_tab: usize,
|
||||
next_tab_id: u64,
|
||||
next_pane_id: u64,
|
||||
}
|
||||
|
||||
const WORKSPACE_SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSnapshot {
|
||||
version: u32,
|
||||
active_tab: usize,
|
||||
next_tab_id: u64,
|
||||
next_pane_id: u64,
|
||||
tabs: Vec<TabSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TabSnapshot {
|
||||
id: u64,
|
||||
title: String,
|
||||
active: u64,
|
||||
root: LayoutNode,
|
||||
panes: Vec<PaneSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PaneSnapshot {
|
||||
id: u64,
|
||||
absolute_path: Option<String>,
|
||||
display_path: Option<String>,
|
||||
is_dirty: bool,
|
||||
is_staged: bool,
|
||||
scroll: ScrollSnapshot,
|
||||
viewport_height: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScrollSnapshot {
|
||||
pub scroll: usize,
|
||||
pub stick_to_bottom: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaneRestoreRequest {
|
||||
pub pane_id: PaneId,
|
||||
pub absolute_path: Option<PathBuf>,
|
||||
pub display_path: Option<String>,
|
||||
pub scroll: ScrollSnapshot,
|
||||
}
|
||||
|
||||
impl Default for CodeWorkspace {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeWorkspace {
|
||||
pub fn new() -> Self {
|
||||
let mut next_tab_id = 0;
|
||||
let mut next_pane_id = 0;
|
||||
let pane_id = PaneId::next(&mut next_pane_id);
|
||||
let first_pane = CodePane::new(pane_id);
|
||||
let tab_id = TabId::next(&mut next_tab_id);
|
||||
let title = format!("Tab {}", tab_id.0);
|
||||
let first_tab = EditorTab::new(tab_id, title, first_pane);
|
||||
Self {
|
||||
tabs: vec![first_tab],
|
||||
active_tab: 0,
|
||||
next_tab_id,
|
||||
next_pane_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tabs(&self) -> &[EditorTab] {
|
||||
&self.tabs
|
||||
}
|
||||
|
||||
pub fn tabs_mut(&mut self) -> &mut [EditorTab] {
|
||||
&mut self.tabs
|
||||
}
|
||||
|
||||
pub fn active_tab_index(&self) -> usize {
|
||||
self.active_tab.min(self.tabs.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
pub fn active_tab(&self) -> Option<&EditorTab> {
|
||||
self.tabs.get(self.active_tab_index())
|
||||
}
|
||||
|
||||
pub fn active_tab_mut(&mut self) -> Option<&mut EditorTab> {
|
||||
let idx = self.active_tab_index();
|
||||
self.tabs.get_mut(idx)
|
||||
}
|
||||
|
||||
pub fn active_pane(&self) -> Option<&CodePane> {
|
||||
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> {
|
||||
self.active_tab_mut().and_then(|tab| tab.active_pane_mut())
|
||||
}
|
||||
|
||||
pub fn set_active_tab(&mut self, index: usize) {
|
||||
if index < self.tabs.len() {
|
||||
self.active_tab = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_tab(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
let mut next_tab_id = self.next_tab_id;
|
||||
let mut next_pane_id = self.next_pane_id;
|
||||
let pane_id = PaneId::next(&mut next_pane_id);
|
||||
let pane = CodePane::new(pane_id);
|
||||
let tab_id = TabId::next(&mut next_tab_id);
|
||||
let title = format!("Tab {}", tab_id.0);
|
||||
let tab = EditorTab::new(tab_id, title, pane);
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = 0;
|
||||
self.next_tab_id = next_tab_id;
|
||||
self.next_pane_id = next_pane_id;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_active_contents(
|
||||
&mut self,
|
||||
absolute: Option<PathBuf>,
|
||||
display: Option<String>,
|
||||
lines: Vec<String>,
|
||||
) {
|
||||
self.ensure_tab();
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
if let Some(pane) = tab.active_pane_mut() {
|
||||
pane.set_contents(absolute, display, lines);
|
||||
}
|
||||
tab.update_title_from_active();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_active_pane(&mut self) {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
if let Some(pane) = tab.active_pane_mut() {
|
||||
pane.clear();
|
||||
}
|
||||
tab.update_title_from_active();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_active_viewport_height(&mut self, height: usize) {
|
||||
if let Some(pane) = self.active_pane_mut() {
|
||||
pane.set_viewport_height(height);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_pane_id(&self) -> Option<PaneId> {
|
||||
self.active_tab().map(|tab| tab.active)
|
||||
}
|
||||
|
||||
pub fn split_active(&mut self, axis: SplitAxis) -> Option<PaneId> {
|
||||
self.ensure_tab();
|
||||
let active_id = self.active_tab()?.active;
|
||||
let new_pane_id = PaneId::next(&mut self.next_pane_id);
|
||||
let replacement = LayoutNode::Split {
|
||||
axis,
|
||||
ratio: 0.5,
|
||||
first: Box::new(LayoutNode::Leaf(active_id)),
|
||||
second: Box::new(LayoutNode::Leaf(new_pane_id)),
|
||||
};
|
||||
|
||||
self.active_tab_mut().and_then(|tab| {
|
||||
if tab.root.replace_leaf(active_id, replacement) {
|
||||
tab.panes.insert(new_pane_id, CodePane::new(new_pane_id));
|
||||
tab.active = new_pane_id;
|
||||
Some(new_pane_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_new_tab(&mut self) -> PaneId {
|
||||
let pane_id = PaneId::next(&mut self.next_pane_id);
|
||||
let pane = CodePane::new(pane_id);
|
||||
let tab_id = TabId::next(&mut self.next_tab_id);
|
||||
let title = format!("Tab {}", tab_id.0);
|
||||
let tab = EditorTab::new(tab_id, title, pane);
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = self.tabs.len().saturating_sub(1);
|
||||
pane_id
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> WorkspaceSnapshot {
|
||||
let tabs = self
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
let panes = tab
|
||||
.panes
|
||||
.values()
|
||||
.map(|pane| PaneSnapshot {
|
||||
id: pane.id.raw(),
|
||||
absolute_path: pane
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
display_path: pane.display_path.clone(),
|
||||
is_dirty: pane.is_dirty,
|
||||
is_staged: pane.is_staged,
|
||||
scroll: ScrollSnapshot {
|
||||
scroll: pane.scroll.scroll,
|
||||
stick_to_bottom: pane.scroll.stick_to_bottom,
|
||||
},
|
||||
viewport_height: pane.viewport_height,
|
||||
})
|
||||
.collect();
|
||||
|
||||
TabSnapshot {
|
||||
id: tab.id.raw(),
|
||||
title: tab.title.clone(),
|
||||
active: tab.active.raw(),
|
||||
root: tab.root.clone(),
|
||||
panes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
WorkspaceSnapshot {
|
||||
version: WORKSPACE_SNAPSHOT_VERSION,
|
||||
active_tab: self.active_tab_index(),
|
||||
next_tab_id: self.next_tab_id,
|
||||
next_pane_id: self.next_pane_id,
|
||||
tabs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_snapshot(&mut self, snapshot: WorkspaceSnapshot) -> Vec<PaneRestoreRequest> {
|
||||
if snapshot.version != WORKSPACE_SNAPSHOT_VERSION {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut restore_requests = Vec::new();
|
||||
let mut tabs = Vec::new();
|
||||
|
||||
for tab_snapshot in snapshot.tabs {
|
||||
let mut panes = HashMap::new();
|
||||
for pane_snapshot in tab_snapshot.panes {
|
||||
let pane_id = PaneId::from_raw(pane_snapshot.id);
|
||||
let mut pane = CodePane::new(pane_id);
|
||||
pane.absolute_path = pane_snapshot.absolute_path.as_ref().map(PathBuf::from);
|
||||
pane.display_path = pane_snapshot.display_path.clone();
|
||||
pane.is_dirty = pane_snapshot.is_dirty;
|
||||
pane.is_staged = pane_snapshot.is_staged;
|
||||
pane.scroll.scroll = pane_snapshot.scroll.scroll;
|
||||
pane.scroll.stick_to_bottom = pane_snapshot.scroll.stick_to_bottom;
|
||||
pane.viewport_height = pane_snapshot.viewport_height;
|
||||
pane.scroll.content_len = pane.lines.len();
|
||||
pane.title = pane
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or_else(|| pane.display_path.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
panes.insert(pane_id, pane);
|
||||
|
||||
if pane_snapshot.absolute_path.is_some() {
|
||||
restore_requests.push(PaneRestoreRequest {
|
||||
pane_id,
|
||||
absolute_path: pane_snapshot.absolute_path.map(PathBuf::from),
|
||||
display_path: pane_snapshot.display_path.clone(),
|
||||
scroll: pane_snapshot.scroll.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if panes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tab_id = TabId::from_raw(tab_snapshot.id);
|
||||
let mut tab = EditorTab {
|
||||
id: tab_id,
|
||||
title: tab_snapshot.title,
|
||||
root: tab_snapshot.root,
|
||||
panes,
|
||||
active: PaneId::from_raw(tab_snapshot.active),
|
||||
};
|
||||
tab.update_title_from_active();
|
||||
tabs.push(tab);
|
||||
}
|
||||
|
||||
if tabs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.tabs = tabs;
|
||||
self.active_tab = snapshot.active_tab.min(self.tabs.len().saturating_sub(1));
|
||||
self.next_tab_id = snapshot.next_tab_id;
|
||||
self.next_pane_id = snapshot.next_pane_id;
|
||||
|
||||
restore_requests
|
||||
}
|
||||
|
||||
pub fn move_focus(&mut self, direction: PaneDirection) -> bool {
|
||||
let active_index = self.active_tab_index();
|
||||
if let Some(tab) = self.tabs.get_mut(active_index) {
|
||||
tab.move_focus(direction)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option<f32> {
|
||||
let active_index = self.active_tab_index();
|
||||
self.tabs
|
||||
.get_mut(active_index)
|
||||
.and_then(|tab| tab.resize_active_step(direction, amount))
|
||||
}
|
||||
|
||||
pub fn snap_active_share(
|
||||
&mut self,
|
||||
direction: PaneDirection,
|
||||
desired_share: f32,
|
||||
) -> Option<f32> {
|
||||
let active_index = self.active_tab_index();
|
||||
self.tabs
|
||||
.get_mut(active_index)
|
||||
.and_then(|tab| tab.snap_active_share(direction, desired_share))
|
||||
}
|
||||
|
||||
pub fn active_share(&self) -> Option<f32> {
|
||||
self.active_tab().and_then(|tab| tab.active_share())
|
||||
}
|
||||
|
||||
pub fn set_pane_contents(
|
||||
&mut self,
|
||||
pane_id: PaneId,
|
||||
absolute: Option<PathBuf>,
|
||||
display: Option<String>,
|
||||
lines: Vec<String>,
|
||||
) -> bool {
|
||||
for tab in &mut self.tabs {
|
||||
if let Some(pane) = tab.panes.get_mut(&pane_id) {
|
||||
pane.set_contents(absolute, display, lines);
|
||||
tab.update_title_from_active();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn restore_scroll(&mut self, pane_id: PaneId, snapshot: &ScrollSnapshot) -> bool {
|
||||
for tab in &mut self.tabs {
|
||||
if let Some(pane) = tab.panes.get_mut(&pane_id) {
|
||||
pane.scroll.scroll = snapshot.scroll;
|
||||
pane.scroll.stick_to_bottom = snapshot.stick_to_bottom;
|
||||
pane.scroll.content_len = pane.lines.len();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user