5 Commits

Author SHA1 Message Date
0da8a3f193 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.
2025-10-13 00:25:30 +02:00
15f81d9728 feat(ui): add configurable message timestamps and card rendering layout 2025-10-12 23:57:46 +02:00
b80db89391 feat(command-palette): add grouped suggestions, history tracking, and model/provider fuzzy matching
- Export `PaletteGroup` and `PaletteSuggestion` to represent suggestion metadata.
- Implement command history with deduplication, capacity limit, and recent‑command suggestions.
- Enhance dynamic suggestion logic to include history, commands, models, and providers with fuzzy ranking.
- Add UI rendering for grouped suggestions, header with command palette label, and footer instructions.
- Update help text with new shortcuts (Ctrl+P, layout save/load) and expose new agent/layout commands.
2025-10-12 23:03:00 +02:00
f413a63c5a feat(ui): introduce focus beacon and unified panel styling helpers
Add `focus_beacon_span`, `panel_title_spans`, `panel_hint_style`, and `panel_border_style` utilities to centralize panel header, hint, border, and beacon rendering. Integrate these helpers across all UI panels (files, chat, thinking, agent actions, input, status bar) and update help text. Extend `Theme` with new color fields for beacons, pane headers, and hint text, providing defaults for all built‑in themes. Include comprehensive unit tests for the new styling functions.
2025-10-12 21:37:34 +02:00
33ad3797a1 feat(state): add file‑tree and repository‑search state modules
Introduce `FileTreeState` for managing a navigable file hierarchy with Git decorations, filtering, and cursor/scroll handling.
Add `RepoSearchState` and related types to support asynchronous ripgrep‑backed repository searches, including result aggregation, pagination, and UI interaction.
2025-10-12 20:18:25 +02:00
14 changed files with 7973 additions and 808 deletions

View File

@@ -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(),
}
}
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -0,0 +1,320 @@
use std::env;
use std::path::Path;
use owlen_core::config::IconMode;
use unicode_width::UnicodeWidthChar;
use super::FileNode;
const ENV_ICON_OVERRIDE: &str = "OWLEN_TUI_ICONS";
/// Concrete icon sets that can be rendered in the terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileIconSet {
Nerd,
Ascii,
}
impl FileIconSet {
pub fn label(self) -> &'static str {
match self {
FileIconSet::Nerd => "Nerd",
FileIconSet::Ascii => "ASCII",
}
}
}
/// How the icon mode was decided.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconDetection {
/// Explicit configuration (config file or CLI flag) forced the mode.
Configured,
/// The runtime environment variable override selected the mode.
Environment,
/// Automatic heuristics guessed the appropriate mode.
Heuristic,
}
/// Resolves per-file icons with configurable fallbacks.
#[derive(Debug, Clone)]
pub struct FileIconResolver {
set: FileIconSet,
detection: IconDetection,
}
impl FileIconResolver {
/// Construct a resolver from the configured icon preference.
pub fn from_mode(pref: IconMode) -> Self {
let (set, detection) = match pref {
IconMode::Ascii => (FileIconSet::Ascii, IconDetection::Configured),
IconMode::Nerd => (FileIconSet::Nerd, IconDetection::Configured),
IconMode::Auto => detect_icon_set(),
};
Self { set, detection }
}
/// Effective icon set that will be rendered.
pub fn set(&self) -> FileIconSet {
self.set
}
/// How the icon set was chosen.
pub fn detection(&self) -> IconDetection {
self.detection
}
/// Human readable label for status lines.
pub fn status_label(&self) -> &'static str {
self.set.label()
}
/// Short label indicating where the decision originated.
pub fn detection_label(&self) -> &'static str {
match self.detection {
IconDetection::Configured => "config",
IconDetection::Environment => "env",
IconDetection::Heuristic => "auto",
}
}
/// Select the glyph to render for the given node.
pub fn icon_for(&self, node: &FileNode) -> &'static str {
match self.set {
FileIconSet::Nerd => nerd_icon_for(node),
FileIconSet::Ascii => ascii_icon_for(node),
}
}
}
fn detect_icon_set() -> (FileIconSet, IconDetection) {
if let Some(set) = env_icon_override() {
return (set, IconDetection::Environment);
}
if !locale_supports_unicode() || is_basic_terminal() {
return (FileIconSet::Ascii, IconDetection::Heuristic);
}
if nerd_glyph_has_compact_width() {
(FileIconSet::Nerd, IconDetection::Heuristic)
} else {
(FileIconSet::Ascii, IconDetection::Heuristic)
}
}
fn env_icon_override() -> Option<FileIconSet> {
let value = env::var(ENV_ICON_OVERRIDE).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"nerd" | "nerdfont" | "nf" | "fancy" => Some(FileIconSet::Nerd),
"ascii" | "plain" | "simple" => Some(FileIconSet::Ascii),
_ => None,
}
}
fn locale_supports_unicode() -> bool {
let vars = ["LC_ALL", "LC_CTYPE", "LANG"];
vars.iter()
.filter_map(|name| env::var(name).ok())
.map(|value| value.to_ascii_lowercase())
.any(|value| value.contains("utf-8") || value.contains("utf8"))
}
fn is_basic_terminal() -> bool {
matches!(env::var("TERM").ok().as_deref(), Some("linux" | "vt100"))
}
fn nerd_glyph_has_compact_width() -> bool {
// Sample glyphs chosen from the Nerd Font private use area.
const SAMPLE_ICONS: [&str; 3] = ["󰈙", "󰉋", ""];
SAMPLE_ICONS.iter().all(|icon| {
icon.chars()
.all(|ch| UnicodeWidthChar::width(ch).unwrap_or(1) == 1)
})
}
fn nerd_icon_for(node: &FileNode) -> &'static str {
if node.depth == 0 {
return "󰉖";
}
if node.is_dir {
return if node.is_expanded { "󰝰" } else { "󰉋" };
}
let name = node.name.as_str();
if let Some(icon) = nerd_icon_by_special_name(name) {
return icon;
}
let ext = Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"rs" => "",
"toml" => "",
"lock" => "󰌾",
"json" => "",
"yaml" | "yml" => "",
"md" | "markdown" => "󰍔",
"py" => "",
"rb" => "",
"go" => "",
"sh" | "bash" => "",
"zsh" => "",
"fish" => "",
"ts" => "",
"tsx" => "",
"js" => "",
"jsx" => "",
"mjs" | "cjs" => "",
"html" | "htm" => "",
"css" => "",
"scss" | "sass" => "",
"less" => "",
"vue" => "󰡄",
"svelte" => "󱄄",
"java" => "",
"kt" => "󱈙",
"swift" => "",
"c" => "",
"h" => "󰙱",
"cpp" | "cxx" | "cc" => "",
"hpp" | "hh" | "hxx" => "󰙲",
"cs" => "󰌛",
"php" => "",
"zig" => "",
"lua" => "",
"sql" => "",
"erl" | "hrl" => "",
"ex" | "exs" => "",
"hs" => "",
"scala" => "",
"dart" => "",
"gradle" => "",
"groovy" => "",
"xml" => "󰗀",
"ini" | "cfg" => "",
"env" => "",
"log" => "󰌱",
"txt" => "󰈙",
"pdf" => "",
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "󰋩",
"svg" => "󰜡",
"ico" => "󰞏",
"lockb" => "󰌾",
"wasm" => "",
_ => "󰈙",
}
}
fn nerd_icon_by_special_name(name: &str) -> Option<&'static str> {
match name {
"Cargo.toml" => Some("󰓾"),
"Cargo.lock" => Some("󰌾"),
"Makefile" | "makefile" => Some(""),
"Dockerfile" => Some("󰡨"),
".gitignore" => Some(""),
".gitmodules" => Some(""),
"README.md" | "readme.md" => Some("󰍔"),
"LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some(""),
"package.json" => Some(""),
"package-lock.json" => Some(""),
"yarn.lock" => Some(""),
"pnpm-lock.yaml" | "pnpm-lock.yml" => Some(""),
"tsconfig.json" => Some(""),
"config.toml" => Some(""),
_ => None,
}
}
fn ascii_icon_for(node: &FileNode) -> &'static str {
if node.depth == 0 {
return "[]";
}
if node.is_dir {
return if node.is_expanded { "[]" } else { "<>" };
}
let name = node.name.as_str();
if let Some(icon) = ascii_icon_by_special_name(name) {
return icon;
}
let ext = Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"rs" => "RS",
"toml" => "TL",
"lock" => "LK",
"json" => "JS",
"yaml" | "yml" => "YM",
"md" | "markdown" => "MD",
"py" => "PY",
"rb" => "RB",
"go" => "GO",
"sh" | "bash" | "zsh" | "fish" => "SH",
"ts" => "TS",
"tsx" => "TX",
"js" | "jsx" | "mjs" | "cjs" => "JS",
"html" | "htm" => "HT",
"css" => "CS",
"scss" | "sass" => "SC",
"vue" => "VU",
"svelte" => "SV",
"java" => "JV",
"kt" => "KT",
"swift" => "SW",
"c" => "C",
"h" => "H",
"cpp" | "cxx" | "cc" => "C+",
"hpp" | "hh" | "hxx" => "H+",
"cs" => "CS",
"php" => "PH",
"zig" => "ZG",
"lua" => "LU",
"sql" => "SQ",
"erl" | "hrl" => "ER",
"ex" | "exs" => "EX",
"hs" => "HS",
"scala" => "SC",
"dart" => "DT",
"gradle" => "GR",
"groovy" => "GR",
"xml" => "XM",
"ini" | "cfg" => "CF",
"env" => "EV",
"log" => "LG",
"txt" => "--",
"pdf" => "PD",
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => "IM",
"svg" => "SG",
"wasm" => "WM",
_ => "--",
}
}
fn ascii_icon_by_special_name(name: &str) -> Option<&'static str> {
match name {
"Cargo.toml" => Some("TL"),
"Cargo.lock" => Some("LK"),
"Makefile" | "makefile" => Some("MK"),
"Dockerfile" => Some("DK"),
".gitignore" => Some("GI"),
".gitmodules" => Some("GI"),
"README.md" | "readme.md" => Some("MD"),
"LICENSE" | "LICENSE.md" | "LICENSE.txt" => Some("LC"),
"package.json" => Some("PJ"),
"package-lock.json" => Some("PL"),
"yarn.lock" => Some("YL"),
"pnpm-lock.yaml" | "pnpm-lock.yml" => Some("PL"),
"tsconfig.json" => Some("TC"),
"config.toml" => Some("CF"),
_ => None,
}
}

View File

@@ -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())
}
}

View File

@@ -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,
};

File diff suppressed because it is too large Load Diff

View 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