20 Commits

Author SHA1 Message Date
96e0436d43 feat(tui): add markdown table parsing and rendering
Implemented full markdown table support:
- Parse tables with headers, rows, and alignment.
- Render tables as a grid when width permits, falling back to a stacked layout for narrow widths.
- Added helper structs (`ParsedTable`, `TableAlignment`) and functions for splitting rows, parsing alignments, column width constraints, cell alignment, and wrapping.
- Integrated table rendering into `render_markdown_lines`.
- Added unit tests for grid rendering and narrow fallback behavior.
2025-10-14 01:50:12 +02:00
498e6e61b6 feat(tui): add markdown rendering support and toggle command
- Introduce new `owlen-markdown` crate that converts Markdown strings to `ratatui::Text` with headings, lists, bold/italic, and inline code.
- Add `render_markdown` config option (default true) and expose it via `app.render_markdown_enabled()`.
- Implement `:markdown [on|off]` command to toggle markdown rendering.
- Update help overlay to document the new markdown toggle.
- Adjust UI rendering to conditionally apply markdown styling based on the markdown flag and code mode.
- Wire the new crate into `owlen-tui` Cargo.toml.
2025-10-14 01:35:13 +02:00
99064b6c41 feat(tui): enable syntax highlighting by default and refactor highlighting logic
- Set `default_syntax_highlighting` to true in core config.
- Added language‑aware syntax selector (`select_syntax_for_language`) and highlighter builder (`build_highlighter_for_language`) with unit test.
- Integrated new highlight module into `ChatApp`, using `UnicodeSegmentation` for proper grapheme handling.
- Simplified `should_highlight_code` to always return true and removed extended‑color detection logic.
- Reworked code rendering to use `inline_code_spans_from_text` and `wrap_highlight_segments` for accurate line wrapping and styling.
- Cleaned up removed legacy keyword/comment parsing and extended‑color detection code.
2025-10-14 00:17:17 +02:00
ee58b0ac32 feat(tui): add role‑based dimmed message border style and color utilities
- Introduce `message_border_style` to render message borders with a dimmed version of the role color.
- Add `dim_color` and `color_to_rgb` helpers for color manipulation.
- Update role styling to use `theme.mode_command` for system messages.
- Adjust card rendering functions to accept role and apply the new border style.
2025-10-13 23:45:04 +02:00
990f93d467 feat(tui): deduplicate model metadata and populate model details cache from session
- Add `seen_meta` set and `push_meta` helper to avoid duplicate entries when building model metadata strings.
- Extend metadata handling to include context length fallback, architecture/family information, embedding length, size formatting, and quantization details.
- Introduce `populate_model_details_cache_from_session` to load model details from the controller, with a fallback to cached details.
- Update `refresh_models` to use the new cache‑population method instead of manually clearing the cache.
2025-10-13 23:36:26 +02:00
44a00619b5 feat(tui): improve popup layout and rendering for model selector and theme browser
- Add robust size calculations with configurable width bounds and height clamping.
- Guard against zero‑size areas and empty model/theme lists.
- Render popups centered with dynamic positioning, preventing negative Y coordinates.
- Introduce multi‑line list items, badges, and metadata display for models.
- Add ellipsis helper for long descriptions and separate title/metadata generation.
- Refactor theme selector to show current theme, built‑in/custom indicators, and a centered footer.
- Update highlight styles and selection handling for both popups.
2025-10-13 23:23:41 +02:00
6923ee439f fix(tui): add width bounds and y‑position clamp for popups
- Limit popup width to a configurable range (40‑80 characters) and ensure a minimum width of 1.
- Preserve original width when the terminal is narrower than the minimum.
- Clamp the y coordinate to the top of the area to avoid negative positioning.
2025-10-13 23:04:36 +02:00
c997b19b53 feat(tui): make system/status output height dynamic and refactor rendering
- Introduce `system_status_message` helper to determine the message shown in the system/status pane.
- Calculate wrapped line count based on available width, clamp visible rows to 1–5, and set the layout constraint dynamically.
- Update `render_system_output` to accept the pre‑computed message, choose color based on error prefix, and render each line individually, defaulting to “Ready” when empty.
- Adjust UI layout to use the new dynamic constraint for the system/status section.
2025-10-13 23:00:34 +02:00
c9daf68fea feat(tui): add syntax highlighting for code panes using syntect and a new highlight module 2025-10-13 22:50:25 +02:00
ba9d083088 feat(tui): add git status colors to file tree UI
- Map git badges and cleanliness states to specific `Color` values and modifiers.
- Apply these colors to file icons, filenames, and markers in the UI.
- Propagate the most relevant dirty badge from child nodes up to parent directories.
- Extend the help overlay with a “GIT COLORS” section describing the new color legend.
2025-10-13 22:32:32 +02:00
825dfc0722 feat(tui): add Ctrl+↑/↓ shortcuts to resize chat/thinking split
- Update help UI to show “Ctrl+↑/↓ → resize chat/thinking split”.
- Introduce `ensure_ratio_bounds` and `nudge_ratio` on `LayoutNode` to clamp and adjust split ratios.
- Ensure vertical split favors the thinking panel when it becomes focused.
- Add `adjust_vertical_split` method in `ChatApp` and handle Ctrl+↑/↓ in normal mode to modify the split and update status messages.
2025-10-13 22:23:36 +02:00
3e4eacd1d3 feat(tui): add Ctrl+←/→ shortcuts to resize files panel
- Update help UI to show “Ctrl+←/→ → resize files panel”.
- Change `set_file_panel_width` to return the clamped width.
- Implement Ctrl+←/→ handling in keyboard input to adjust the files panel width, update status messages, and respect panel collapse state.
2025-10-13 22:14:19 +02:00
23253219a3 feat(tui): add help overlay shortcuts (F1/?) and update help UI and status messages
- Introduced a new “HELP & QUICK COMMANDS” section with bold header and shortcuts for toggling the help overlay and opening the files panel.
- Updated command help text to “Open the help overlay”.
- Extended onboarding and tutorial status lines to display the help shortcut.
- Modified help command handling to set the status to “Help” and clear errors.
2025-10-13 22:09:52 +02:00
cc2b85a86d feat(tui): add :create command, introduce :files/:explorer toggles, default filter to glob and update UI hints 2025-10-13 21:59:03 +02:00
58dd6f3efa feat(tui): add double‑Ctrl+C quick‑exit and update command help texts
- Introduce “Ctrl+C twice” shortcut for quitting the application and display corresponding help line.
- Rename and clarify session‑related commands (`:session save`) and add short aliases (`:w[!]`, `:q[!]`, `:wq[!]`) with updated help entries.
- Adjust quit help text to remove `:q, :quit` redundancy and replace with the new quick‑exit hint.
- Update UI key hint to show only “Esc” for cancel actions.
- Implement double‑Ctrl+C detection in `ChatApp` using `DOUBLE_CTRL_C_WINDOW`, track `last_ctrl_c`, reset on other keys, and show status messages prompting the second press.
- Minor wording tweaks in help dialogs and README to reflect the new command syntax and quick‑exit behavior.
2025-10-13 19:51:00 +02:00
c81d0f1593 feat(tui): add file save/close commands and session save handling
- Updated command specs: added `w`, `write`, `wq`, `x`, and `session save` with proper descriptions.
- Introduced `SaveStatus` enum and helper methods for path display and buffer labeling.
- Implemented `update_paths` in `Workspace` to keep title in sync with file paths.
- Added comprehensive `save_active_code_buffer` and enhanced `close_active_code_buffer` logic, including force‑close via `!`.
- Parsed force flag from commands (e.g., `:q!`) and routed commands to new save/close workflows.
- Integrated session save subcommand with optional description generation.
2025-10-13 19:42:41 +02:00
ae0dd3fc51 feat(ui): shrink system/status output height and improve file panel toggle feedback
- Adjust layout constraint from 5 to 4 lines to match 2 lines of content plus borders.
- Refactor file focus key handling to toggle the file panel state and set status messages (“Files panel shown” / “Files panel hidden”) instead of always expanding and using a static status.
2025-10-13 19:18:50 +02:00
80dffa9f41 feat(ui): embed header in main block and base layout on inner content area
- Render the app title with version as the block title instead of a separate header widget.
- Compute `content_area` via `main_block.inner` and use it for file panel, main area, model info panel, and toast rendering.
- Remove header constraints and the `render_header` function, simplifying the layout.
- Add early exit when `content_area` has zero width or height to avoid rendering errors.
2025-10-13 19:06:55 +02:00
ab0ae4fe04 feat(ui): reduce header height and remove model/provider display
- Decrease header constraint from 4 lines to 3.
- Drop rendering of the model and provider label from the header area.
2025-10-13 19:00:56 +02:00
d31e068277 feat(ui): include app version in header title
Add `APP_VERSION` constant derived from `CARGO_PKG_VERSION` and update the header rendering to display the version (e.g., “🦉 OWLEN v1.2.3 – AI Assistant”).
2025-10-13 18:58:52 +02:00
26 changed files with 3151 additions and 928 deletions

View File

@@ -9,6 +9,7 @@ members = [
"crates/owlen-mcp-client", "crates/owlen-mcp-client",
"crates/owlen-mcp-code-server", "crates/owlen-mcp-code-server",
"crates/owlen-mcp-prompt-server", "crates/owlen-mcp-prompt-server",
"crates/owlen-markdown",
] ]
exclude = [] exclude = []

View File

@@ -90,7 +90,8 @@ OWLEN uses a modal, vim-inspired interface. Press `F1` (available from any mode)
- **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`. - **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`.
- **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`. - **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`.
- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:save`, `:theme`. - **Command Mode**: Enter with `:`. Access commands like `:quit`, `:w`, `:session save`, `:theme`.
- **Quick Exit**: Press `Ctrl+C` twice in Normal mode to quit quickly (first press still cancels active generations).
- **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings. - **Tutorial Command**: Type `:tutorial` any time for a quick summary of the most important keybindings.
- **MCP Slash Commands**: Owlen auto-registers zero-argument MCP tools as slash commands—type `/mcp__github__list_prs` (for example) to pull remote context directly into the chat log. - **MCP Slash Commands**: Owlen auto-registers zero-argument MCP tools as slash commands—type `/mcp__github__list_prs` (for example) to pull remote context directly into the chat log.

View File

@@ -221,11 +221,12 @@ fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
if provider == "ollama" if provider == "ollama"
&& config.providers.contains_key("ollama-cloud") && config.providers.contains_key("ollama-cloud")
&& !config.providers.contains_key("ollama") && !config.providers.contains_key("ollama")
&& let Some(mut legacy) = config.providers.remove("ollama-cloud")
{ {
if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
legacy.provider_type = "ollama".to_string(); legacy.provider_type = "ollama".to_string();
config.providers.insert("ollama".to_string(), legacy); config.providers.insert("ollama".to_string(), legacy);
} }
}
core_config::ensure_provider_config(config, provider); core_config::ensure_provider_config(config, provider);
@@ -315,8 +316,10 @@ fn unlock_vault(path: &Path) -> Result<encryption::VaultHandle> {
use std::env; use std::env;
if path.exists() { if path.exists() {
if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD") if let Some(password) = env::var("OWLEN_MASTER_PASSWORD")
&& !password.trim().is_empty() .ok()
.map(|value| value.trim().to_string())
.filter(|password| !password.is_empty())
{ {
return encryption::unlock_with_password(path.to_path_buf(), &password) return encryption::unlock_with_password(path.to_path_buf(), &password)
.context("Failed to unlock vault with OWLEN_MASTER_PASSWORD"); .context("Failed to unlock vault with OWLEN_MASTER_PASSWORD");
@@ -356,28 +359,29 @@ async fn hydrate_api_key(
config: &mut Config, config: &mut Config,
manager: Option<&Arc<CredentialManager>>, manager: Option<&Arc<CredentialManager>>,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
if let Some(manager) = manager let credentials = match manager {
&& let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await? Some(manager) => manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await?,
{ None => None,
};
if let Some(credentials) = credentials {
let key = credentials.api_key.trim().to_string(); let key = credentials.api_key.trim().to_string();
if !key.is_empty() { if !key.is_empty() {
set_env_if_missing("OLLAMA_API_KEY", &key); set_env_if_missing("OLLAMA_API_KEY", &key);
set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key); set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key);
} }
if let Some(cfg) = provider_entry_mut(config) let Some(cfg) = provider_entry_mut(config) else {
&& cfg.base_url.is_none() return Ok(Some(key));
&& !credentials.endpoint.trim().is_empty() };
{ if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() {
cfg.base_url = Some(credentials.endpoint); cfg.base_url = Some(credentials.endpoint.clone());
} }
return Ok(Some(key)); return Ok(Some(key));
} }
if let Some(cfg) = provider_entry(config) if let Some(key) = provider_entry(config)
&& let Some(key) = cfg .and_then(|cfg| cfg.api_key.as_ref())
.api_key
.as_ref()
.map(|value| value.trim()) .map(|value| value.trim())
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
{ {

View File

@@ -1,3 +1,5 @@
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
//! OWLEN CLI - Chat TUI client //! OWLEN CLI - Chat TUI client
mod cloud; mod cloud;

View File

@@ -151,8 +151,9 @@ fn handle_list(args: ListArgs) -> Result<()> {
"", "Scope", "Name", "Transport" "", "Scope", "Name", "Transport"
); );
for entry in scoped { for entry in scoped {
if let Some(target_scope) = filter_scope if filter_scope
&& entry.scope != target_scope .as_ref()
.is_some_and(|target_scope| entry.scope != *target_scope)
{ {
continue; continue;
} }
@@ -186,8 +187,9 @@ fn handle_list(args: ListArgs) -> Result<()> {
.collect(); .collect();
for entry in scoped_resources { for entry in scoped_resources {
if let Some(target_scope) = filter_scope if filter_scope
&& entry.scope != target_scope .as_ref()
.is_some_and(|target_scope| entry.scope != *target_scope)
{ {
continue; continue;
} }

View File

@@ -1332,6 +1332,8 @@ pub struct UiSettings {
pub show_cursor_outside_insert: bool, pub show_cursor_outside_insert: bool,
#[serde(default = "UiSettings::default_syntax_highlighting")] #[serde(default = "UiSettings::default_syntax_highlighting")]
pub syntax_highlighting: bool, pub syntax_highlighting: bool,
#[serde(default = "UiSettings::default_render_markdown")]
pub render_markdown: bool,
#[serde(default = "UiSettings::default_show_timestamps")] #[serde(default = "UiSettings::default_show_timestamps")]
pub show_timestamps: bool, pub show_timestamps: bool,
#[serde(default = "UiSettings::default_icon_mode")] #[serde(default = "UiSettings::default_icon_mode")]
@@ -1389,7 +1391,11 @@ impl UiSettings {
} }
const fn default_syntax_highlighting() -> bool { const fn default_syntax_highlighting() -> bool {
false true
}
const fn default_render_markdown() -> bool {
true
} }
const fn default_show_timestamps() -> bool { const fn default_show_timestamps() -> bool {
@@ -1466,6 +1472,7 @@ impl Default for UiSettings {
scrollback_lines: Self::default_scrollback_lines(), scrollback_lines: Self::default_scrollback_lines(),
show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(),
syntax_highlighting: Self::default_syntax_highlighting(), syntax_highlighting: Self::default_syntax_highlighting(),
render_markdown: Self::default_render_markdown(),
show_timestamps: Self::default_show_timestamps(), show_timestamps: Self::default_show_timestamps(),
icon_mode: Self::default_icon_mode(), icon_mode: Self::default_icon_mode(),
} }

View File

@@ -58,9 +58,14 @@ impl ConsentManager {
/// Load consent records from vault storage /// Load consent records from vault storage
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self { pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
let guard = vault.lock().expect("Vault mutex poisoned"); let guard = vault.lock().expect("Vault mutex poisoned");
if let Some(consent_data) = guard.settings().get("consent_records") if let Some(permanent_records) =
&& let Ok(permanent_records) = guard
.settings()
.get("consent_records")
.and_then(|consent_data| {
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone()) serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
.ok()
})
{ {
return Self { return Self {
permanent_records, permanent_records,
@@ -90,15 +95,19 @@ impl ConsentManager {
endpoints: Vec<String>, endpoints: Vec<String>,
) -> Result<ConsentScope> { ) -> Result<ConsentScope> {
// Check if already granted permanently // Check if already granted permanently
if let Some(existing) = self.permanent_records.get(tool_name) if self
&& existing.scope == ConsentScope::Permanent .permanent_records
.get(tool_name)
.is_some_and(|existing| existing.scope == ConsentScope::Permanent)
{ {
return Ok(ConsentScope::Permanent); return Ok(ConsentScope::Permanent);
} }
// Check if granted for session // Check if granted for session
if let Some(existing) = self.session_records.get(tool_name) if self
&& existing.scope == ConsentScope::Session .session_records
.get(tool_name)
.is_some_and(|existing| existing.scope == ConsentScope::Session)
{ {
return Ok(ConsentScope::Session); return Ok(ConsentScope::Session);
} }

View File

@@ -1,3 +1,5 @@
#![allow(clippy::collapsible_if)] // TODO: Remove once we can rely on Rust 2024 let-chains
//! Core traits and types for OWLEN LLM client //! Core traits and types for OWLEN LLM client
//! //!
//! This crate provides the foundational abstractions for building //! This crate provides the foundational abstractions for building

View File

@@ -156,13 +156,14 @@ mod tests {
use super::*; use super::*;
use crate::mcp::LocalMcpClient; use crate::mcp::LocalMcpClient;
use crate::tools::registry::ToolRegistry; use crate::tools::registry::ToolRegistry;
use crate::ui::NoOpUiController;
use crate::validation::SchemaValidator; use crate::validation::SchemaValidator;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
#[tokio::test] #[tokio::test]
async fn test_permission_layer_filters_dangerous_tools() { async fn test_permission_layer_filters_dangerous_tools() {
let config = Arc::new(Config::default()); let config = Arc::new(Config::default());
let ui = Arc::new(crate::ui::NoOpUiController); let ui = Arc::new(NoOpUiController);
let registry = Arc::new(ToolRegistry::new( let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new((*config).clone())), Arc::new(tokio::sync::Mutex::new((*config).clone())),
ui, ui,
@@ -186,7 +187,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_consent_callback_is_invoked() { async fn test_consent_callback_is_invoked() {
let config = Arc::new(Config::default()); let config = Arc::new(Config::default());
let ui = Arc::new(crate::ui::NoOpUiController); let ui = Arc::new(NoOpUiController);
let registry = Arc::new(ToolRegistry::new( let registry = Arc::new(ToolRegistry::new(
Arc::new(tokio::sync::Mutex::new((*config).clone())), Arc::new(tokio::sync::Mutex::new((*config).clone())),
ui, ui,

View File

@@ -42,7 +42,7 @@ impl ModelManager {
F: FnOnce() -> Fut, F: FnOnce() -> Fut,
Fut: Future<Output = Result<Vec<ModelInfo>>>, Fut: Future<Output = Result<Vec<ModelInfo>>>,
{ {
if !force_refresh && let Some(models) = self.cached_if_fresh().await { if let (false, Some(models)) = (force_refresh, self.cached_if_fresh().await) {
return Ok(models); return Ok(models);
} }

View File

@@ -378,10 +378,8 @@ impl OllamaProvider {
let family = pick_first_string(map, &["family", "model_family"]); let family = pick_first_string(map, &["family", "model_family"]);
let mut families = pick_string_list(map, &["families", "model_families"]); let mut families = pick_string_list(map, &["families", "model_families"]);
if families.is_empty() if families.is_empty() {
&& let Some(single) = family.clone() families.extend(family.clone());
{
families.push(single);
} }
let system = pick_first_string(map, &["system"]); let system = pick_first_string(map, &["system"]);

View File

@@ -71,16 +71,19 @@ impl Router {
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> { fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
// Check routing rules first // Check routing rules first
for rule in &self.routing_rules { for rule in &self.routing_rules {
if self.matches_pattern(&rule.model_pattern, model) if !self.matches_pattern(&rule.model_pattern, model) {
&& let Some(provider) = self.registry.get(&rule.provider) continue;
{ }
if let Some(provider) = self.registry.get(&rule.provider) {
return Ok(provider); return Ok(provider);
} }
} }
// Fall back to default provider // Fall back to default provider
if let Some(default) = &self.default_provider if let Some(provider) = self
&& let Some(provider) = self.registry.get(default) .default_provider
.as_ref()
.and_then(|default| self.registry.get(default))
{ {
return Ok(provider); return Ok(provider);
} }

View File

@@ -185,14 +185,20 @@ impl SandboxedProcess {
if let Ok(output) = output { if let Ok(output) = output {
let version_str = String::from_utf8_lossy(&output.stdout); let version_str = String::from_utf8_lossy(&output.stdout);
// Parse version like "bubblewrap 0.11.0" or "0.11.0" // Parse version like "bubblewrap 0.11.0" or "0.11.0"
if let Some(version_part) = version_str.split_whitespace().last() return version_str
&& let Some((major, rest)) = version_part.split_once('.') .split_whitespace()
&& let Some((minor, _patch)) = rest.split_once('.') .last()
&& let (Ok(maj), Ok(min)) = (major.parse::<u32>(), minor.parse::<u32>()) .and_then(|part| {
{ part.split_once('.').and_then(|(major, rest)| {
// --rlimit-as was added in 0.12.0 rest.split_once('.').and_then(|(minor, _)| {
return maj > 0 || (maj == 0 && min >= 12); let maj = major.parse::<u32>().ok()?;
} let min = minor.parse::<u32>().ok()?;
Some((maj, min))
})
})
})
.map(|(maj, min)| maj > 0 || (maj == 0 && min >= 12))
.unwrap_or(false);
} }
// If we can't determine the version, assume it doesn't support it (safer default) // If we can't determine the version, assume it doesn't support it (safer default)

View File

@@ -53,8 +53,8 @@ fn extract_resource_content(value: &Value) -> Option<String> {
Value::Array(items) => { Value::Array(items) => {
let mut segments = Vec::new(); let mut segments = Vec::new();
for item in items { for item in items {
if let Some(segment) = extract_resource_content(item) if let Some(segment) =
&& !segment.is_empty() extract_resource_content(item).filter(|segment| !segment.is_empty())
{ {
segments.push(segment); segments.push(segment);
} }
@@ -69,17 +69,19 @@ fn extract_resource_content(value: &Value) -> Option<String> {
const PREFERRED_FIELDS: [&str; 6] = const PREFERRED_FIELDS: [&str; 6] =
["content", "contents", "text", "value", "body", "data"]; ["content", "contents", "text", "value", "body", "data"];
for key in PREFERRED_FIELDS.iter() { for key in PREFERRED_FIELDS.iter() {
if let Some(inner) = map.get(*key) if let Some(text) = map
&& let Some(text) = extract_resource_content(inner) .get(*key)
&& !text.is_empty() .and_then(extract_resource_content)
.filter(|text| !text.is_empty())
{ {
return Some(text); return Some(text);
} }
} }
if let Some(inner) = map.get("chunks") if let Some(text) = map
&& let Some(text) = extract_resource_content(inner) .get("chunks")
&& !text.is_empty() .and_then(extract_resource_content)
.filter(|text| !text.is_empty())
{ {
return Some(text); return Some(text);
} }
@@ -566,9 +568,10 @@ impl SessionController {
.expect("Consent manager mutex poisoned"); .expect("Consent manager mutex poisoned");
consent.grant_consent(tool_name, data_types, endpoints); consent.grant_consent(tool_name, data_types, endpoints);
if let Some(vault) = &self.vault let Some(vault) = &self.vault else {
&& let Err(e) = consent.persist_to_vault(vault) return;
{ };
if let Err(e) = consent.persist_to_vault(vault) {
eprintln!("Warning: Failed to persist consent to vault: {}", e); eprintln!("Warning: Failed to persist consent to vault: {}", e);
} }
} }
@@ -588,10 +591,13 @@ impl SessionController {
consent.grant_consent_with_scope(tool_name, data_types, endpoints, scope); consent.grant_consent_with_scope(tool_name, data_types, endpoints, scope);
// Only persist to vault for permanent consent // Only persist to vault for permanent consent
if is_permanent if !is_permanent {
&& let Some(vault) = &self.vault return;
&& let Err(e) = consent.persist_to_vault(vault) }
{ let Some(vault) = &self.vault else {
return;
};
if let Err(e) = consent.persist_to_vault(vault) {
eprintln!("Warning: Failed to persist consent to vault: {}", e); eprintln!("Warning: Failed to persist consent to vault: {}", e);
} }
} }

View File

@@ -50,15 +50,15 @@ impl StorageManager {
/// Create a storage manager using the provided database path /// Create a storage manager using the provided database path
pub async fn with_database_path(database_path: PathBuf) -> Result<Self> { pub async fn with_database_path(database_path: PathBuf) -> Result<Self> {
if let Some(parent) = database_path.parent() if let Some(parent) = database_path.parent() {
&& !parent.exists() if !parent.exists() {
{
std::fs::create_dir_all(parent).map_err(|e| { std::fs::create_dir_all(parent).map_err(|e| {
Error::Storage(format!( Error::Storage(format!(
"Failed to create database directory {parent:?}: {e}" "Failed to create database directory {parent:?}: {e}"
)) ))
})?; })?;
} }
}
let options = SqliteConnectOptions::from_str(&format!( let options = SqliteConnectOptions::from_str(&format!(
"sqlite://{}", "sqlite://{}",
@@ -431,14 +431,14 @@ impl StorageManager {
} }
} }
if migrated > 0 if migrated > 0 {
&& let Err(err) = archive_legacy_directory(&legacy_dir) if let Err(err) = archive_legacy_directory(&legacy_dir) {
{
println!( println!(
"Warning: migrated sessions but failed to archive legacy directory: {}", "Warning: migrated sessions but failed to archive legacy directory: {}",
err err
); );
} }
}
println!("Migrated {} legacy sessions.", migrated); println!("Migrated {} legacy sessions.", migrated);
Ok(()) Ok(())

View File

@@ -0,0 +1,10 @@
[package]
name = "owlen-markdown"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Lightweight markdown to ratatui::Text renderer for OWLEN"
[dependencies]
ratatui = { workspace = true }
unicode-width = "0.1"

View File

@@ -0,0 +1,270 @@
use ratatui::prelude::*;
use ratatui::text::{Line, Span, Text};
use unicode_width::UnicodeWidthStr;
/// Convert a markdown string into a `ratatui::Text`.
///
/// This lightweight renderer supports common constructs (headings, lists, bold,
/// italics, and inline code) and is designed to keep dependencies minimal for
/// the OWLEN project.
pub fn from_str(input: &str) -> Text<'static> {
let mut lines = Vec::new();
let mut in_code_block = false;
for raw_line in input.lines() {
let line = raw_line.trim_end_matches('\r');
let trimmed = line.trim_start();
let indent = &line[..line.len() - trimmed.len()];
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::raw(indent.to_string()));
}
spans.push(Span::styled(
trimmed.to_string(),
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::DIM),
));
lines.push(Line::from(spans));
continue;
}
if trimmed.is_empty() {
lines.push(Line::from(Vec::<Span<'static>>::new()));
continue;
}
if trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|c| *c == '#').count().min(6);
let content = trimmed[level..].trim_start();
let mut style = Style::default().add_modifier(Modifier::BOLD);
style = match level {
1 => style.fg(Color::LightCyan),
2 => style.fg(Color::Cyan),
_ => style.fg(Color::LightBlue),
};
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::raw(indent.to_string()));
}
spans.push(Span::styled(content.to_string(), style));
lines.push(Line::from(spans));
continue;
}
if let Some(rest) = trimmed.strip_prefix("- ") {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::raw(indent.to_string()));
}
spans.push(Span::styled(
"".to_string(),
Style::default().fg(Color::LightGreen),
));
spans.extend(parse_inline(rest));
lines.push(Line::from(spans));
continue;
}
if let Some(rest) = trimmed.strip_prefix("* ") {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::raw(indent.to_string()));
}
spans.push(Span::styled(
"".to_string(),
Style::default().fg(Color::LightGreen),
));
spans.extend(parse_inline(rest));
lines.push(Line::from(spans));
continue;
}
if let Some((number, rest)) = parse_ordered_item(trimmed) {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::raw(indent.to_string()));
}
spans.push(Span::styled(
format!("{number}. "),
Style::default().fg(Color::LightGreen),
));
spans.extend(parse_inline(rest));
lines.push(Line::from(spans));
continue;
}
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::raw(indent.to_string()));
}
spans.extend(parse_inline(trimmed));
lines.push(Line::from(spans));
}
if input.is_empty() {
lines.push(Line::from(Vec::<Span<'static>>::new()));
}
Text::from(lines)
}
fn parse_ordered_item(line: &str) -> Option<(u32, &str)> {
let mut parts = line.splitn(2, '.');
let number = parts.next()?.trim();
let rest = parts.next()?;
if number.chars().all(|c| c.is_ascii_digit()) {
let value = number.parse().ok()?;
let rest = rest.trim_start();
Some((value, rest))
} else {
None
}
}
fn parse_inline(text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
let len = bytes.len();
let mut plain_start = 0;
while i < len {
if bytes[i] == b'`' {
if let Some(offset) = text[i + 1..].find('`') {
if i > plain_start {
spans.push(Span::raw(text[plain_start..i].to_string()));
}
let content = &text[i + 1..i + 1 + offset];
spans.push(Span::styled(
content.to_string(),
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
));
i += offset + 2;
plain_start = i;
continue;
} else {
break;
}
}
if bytes[i] == b'*' {
if i + 1 < len && bytes[i + 1] == b'*' {
if let Some(offset) = text[i + 2..].find("**") {
if i > plain_start {
spans.push(Span::raw(text[plain_start..i].to_string()));
}
let content = &text[i + 2..i + 2 + offset];
spans.push(Span::styled(
content.to_string(),
Style::default().add_modifier(Modifier::BOLD),
));
i += offset + 4;
plain_start = i;
continue;
}
} else if let Some(offset) = text[i + 1..].find('*') {
if i > plain_start {
spans.push(Span::raw(text[plain_start..i].to_string()));
}
let content = &text[i + 1..i + 1 + offset];
spans.push(Span::styled(
content.to_string(),
Style::default().add_modifier(Modifier::ITALIC),
));
i += offset + 2;
plain_start = i;
continue;
}
}
if bytes[i] == b'_' {
if i + 1 < len && bytes[i + 1] == b'_' {
if let Some(offset) = text[i + 2..].find("__") {
if i > plain_start {
spans.push(Span::raw(text[plain_start..i].to_string()));
}
let content = &text[i + 2..i + 2 + offset];
spans.push(Span::styled(
content.to_string(),
Style::default().add_modifier(Modifier::BOLD),
));
i += offset + 4;
plain_start = i;
continue;
}
} else if let Some(offset) = text[i + 1..].find('_') {
if i > plain_start {
spans.push(Span::raw(text[plain_start..i].to_string()));
}
let content = &text[i + 1..i + 1 + offset];
spans.push(Span::styled(
content.to_string(),
Style::default().add_modifier(Modifier::ITALIC),
));
i += offset + 2;
plain_start = i;
continue;
}
}
i += 1;
}
if plain_start < len {
spans.push(Span::raw(text[plain_start..].to_string()));
}
if spans.is_empty() {
spans.push(Span::raw(String::new()));
}
spans
}
#[allow(dead_code)]
fn visual_length(spans: &[Span<'_>]) -> usize {
spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headings_are_bold() {
let text = from_str("# Heading");
assert_eq!(text.lines.len(), 1);
let line = &text.lines[0];
assert!(
line.spans
.iter()
.any(|span| span.style.contains(Modifier::BOLD))
);
}
#[test]
fn inline_code_styled() {
let text = from_str("Use `code` inline.");
let styled = text
.lines
.iter()
.flat_map(|line| &line.spans)
.find(|span| span.content.as_ref() == "code")
.cloned()
.unwrap();
assert!(styled.style.contains(Modifier::BOLD));
}
}

View File

@@ -27,6 +27,9 @@ tree-sitter = "0.20"
tree-sitter-rust = "0.20" tree-sitter-rust = "0.20"
dirs = { workspace = true } dirs = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
syntect = "5.3"
once_cell = "1.19"
owlen-markdown = { path = "../owlen-markdown" }
# Async runtime # Async runtime
tokio = { workspace = true } tokio = { workspace = true }

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,15 @@ const COMMANDS: &[CommandSpec] = &[
}, },
CommandSpec { CommandSpec {
keyword: "q", keyword: "q",
description: "Alias for quit", description: "Close the active file",
},
CommandSpec {
keyword: "w",
description: "Save the active file",
},
CommandSpec {
keyword: "write",
description: "Alias for w",
}, },
CommandSpec { CommandSpec {
keyword: "clear", keyword: "clear",
@@ -25,12 +33,16 @@ const COMMANDS: &[CommandSpec] = &[
description: "Alias for clear", description: "Alias for clear",
}, },
CommandSpec { CommandSpec {
keyword: "w", keyword: "save",
description: "Alias for write", description: "Alias for w",
}, },
CommandSpec { CommandSpec {
keyword: "save", keyword: "wq",
description: "Alias for write", description: "Save and close the active file",
},
CommandSpec {
keyword: "x",
description: "Alias for wq",
}, },
CommandSpec { CommandSpec {
keyword: "load", keyword: "load",
@@ -44,6 +56,10 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "open", keyword: "open",
description: "Open a file in the code view", description: "Open a file in the code view",
}, },
CommandSpec {
keyword: "create",
description: "Create a file (creates missing directories)",
},
CommandSpec { CommandSpec {
keyword: "close", keyword: "close",
description: "Close the active code view", description: "Close the active code view",
@@ -68,9 +84,13 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "sessions", keyword: "sessions",
description: "List saved sessions", description: "List saved sessions",
}, },
CommandSpec {
keyword: "session save",
description: "Save the current conversation",
},
CommandSpec { CommandSpec {
keyword: "help", keyword: "help",
description: "Show help documentation", description: "Open the help overlay",
}, },
CommandSpec { CommandSpec {
keyword: "h", keyword: "h",
@@ -128,6 +148,10 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "reload", keyword: "reload",
description: "Reload configuration and themes", description: "Reload configuration and themes",
}, },
CommandSpec {
keyword: "markdown",
description: "Toggle markdown rendering",
},
CommandSpec { CommandSpec {
keyword: "e", keyword: "e",
description: "Edit a file", description: "Edit a file",
@@ -180,6 +204,14 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "layout load", keyword: "layout load",
description: "Restore the last saved pane layout", description: "Restore the last saved pane layout",
}, },
CommandSpec {
keyword: "files",
description: "Toggle the files panel",
},
CommandSpec {
keyword: "explorer",
description: "Alias for files",
},
]; ];
/// Return the static catalog of commands. /// Return the static catalog of commands.

View File

@@ -0,0 +1,160 @@
use once_cell::sync::Lazy;
use ratatui::style::{Color as TuiColor, Modifier, Style as TuiStyle};
use std::path::{Path, PathBuf};
use syntect::easy::HighlightLines;
use syntect::highlighting::{FontStyle, Style as SynStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
static THEME: Lazy<Theme> = Lazy::new(|| {
THEME_SET
.themes
.get("base16-ocean.dark")
.cloned()
.or_else(|| THEME_SET.themes.values().next().cloned())
.unwrap_or_default()
});
fn select_syntax(path_hint: Option<&Path>) -> &'static SyntaxReference {
if let Some(path) = path_hint {
if let Ok(Some(syntax)) = SYNTAX_SET.find_syntax_for_file(path) {
return syntax;
}
if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(ext) {
return syntax;
}
}
if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(name) {
return syntax;
}
}
}
SYNTAX_SET.find_syntax_plain_text()
}
fn select_syntax_for_language(language: Option<&str>) -> &'static SyntaxReference {
let token = language
.map(|lang| lang.trim().to_ascii_lowercase())
.filter(|lang| !lang.is_empty());
if let Some(token) = token {
let mut attempts: Vec<&str> = vec![token.as_str()];
match token.as_str() {
"c++" => attempts.extend(["cpp", "c"]),
"c#" | "cs" => attempts.extend(["csharp", "cs"]),
"shell" => attempts.extend(["bash", "sh"]),
"typescript" | "ts" => attempts.extend(["typescript", "ts", "tsx"]),
"javascript" | "js" => attempts.extend(["javascript", "js", "jsx"]),
"py" => attempts.push("python"),
"rs" => attempts.push("rust"),
"yml" => attempts.push("yaml"),
other => {
if let Some(stripped) = other.strip_prefix('.') {
attempts.push(stripped);
}
}
}
for candidate in attempts {
if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(candidate) {
return syntax;
}
if let Some(syntax) = SYNTAX_SET.find_syntax_by_extension(candidate) {
return syntax;
}
}
}
SYNTAX_SET.find_syntax_plain_text()
}
fn path_hint_from_components(absolute: Option<&Path>, display: Option<&str>) -> Option<PathBuf> {
if let Some(abs) = absolute {
return Some(abs.to_path_buf());
}
display.map(PathBuf::from)
}
fn style_from_syntect(style: SynStyle) -> TuiStyle {
let mut tui_style = TuiStyle::default().fg(TuiColor::Rgb(
style.foreground.r,
style.foreground.g,
style.foreground.b,
));
let mut modifiers = Modifier::empty();
if style.font_style.contains(FontStyle::BOLD) {
modifiers |= Modifier::BOLD;
}
if style.font_style.contains(FontStyle::ITALIC) {
modifiers |= Modifier::ITALIC;
}
if style.font_style.contains(FontStyle::UNDERLINE) {
modifiers |= Modifier::UNDERLINED;
}
if !modifiers.is_empty() {
tui_style = tui_style.add_modifier(modifiers);
}
tui_style
}
pub fn build_highlighter(
absolute: Option<&Path>,
display: Option<&str>,
) -> HighlightLines<'static> {
let hint_path = path_hint_from_components(absolute, display);
let syntax = select_syntax(hint_path.as_deref());
HighlightLines::new(syntax, &THEME)
}
pub fn highlight_line(
highlighter: &mut HighlightLines<'static>,
line: &str,
) -> Vec<(TuiStyle, String)> {
let mut segments = Vec::new();
match highlighter.highlight_line(line, &SYNTAX_SET) {
Ok(result) => {
for (style, piece) in result {
let tui_style = style_from_syntect(style);
segments.push((tui_style, piece.to_string()));
}
}
Err(_) => {
segments.push((TuiStyle::default(), line.to_string()));
}
}
if segments.is_empty() {
segments.push((TuiStyle::default(), String::new()));
}
segments
}
pub fn build_highlighter_for_language(language: Option<&str>) -> HighlightLines<'static> {
let syntax = select_syntax_for_language(language);
HighlightLines::new(syntax, &THEME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rust_highlighting_produces_colored_segment() {
let mut highlighter = build_highlighter_for_language(Some("rust"));
let segments = highlight_line(&mut highlighter, "fn main() {}");
assert!(
segments
.iter()
.any(|(style, text)| style.fg.is_some() && !text.trim().is_empty()),
"Expected at least one colored segment"
);
}
}

View File

@@ -1,3 +1,5 @@
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
//! # Owlen TUI //! # Owlen TUI
//! //!
//! This crate contains all the logic for the terminal user interface (TUI) of Owlen. //! This crate contains all the logic for the terminal user interface (TUI) of Owlen.
@@ -17,6 +19,7 @@ pub mod code_app;
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod events; pub mod events;
pub mod highlight;
pub mod model_info_panel; pub mod model_info_panel;
pub mod slash; pub mod slash;
pub mod state; pub mod state;

View File

@@ -110,7 +110,7 @@ impl FileTreeState {
cursor: 0, cursor: 0,
scroll_top: 0, scroll_top: 0,
viewport_height: 20, viewport_height: 20,
filter_mode: FilterMode::Fuzzy, filter_mode: FilterMode::Glob,
filter_query: String::new(), filter_query: String::new(),
show_hidden: false, show_hidden: false,
filter_matches: Vec::new(), filter_matches: Vec::new(),
@@ -198,6 +198,14 @@ impl FileTreeState {
&self.filter_query &self.filter_query
} }
pub fn set_filter_mode(&mut self, mode: FilterMode) {
if self.filter_mode != mode {
self.filter_mode = mode;
self.recompute_filter_cache();
self.rebuild_visible();
}
}
pub fn show_hidden(&self) -> bool { pub fn show_hidden(&self) -> bool {
self.show_hidden self.show_hidden
} }
@@ -276,12 +284,11 @@ impl FileTreeState {
} }
pub fn toggle_filter_mode(&mut self) { pub fn toggle_filter_mode(&mut self) {
self.filter_mode = match self.filter_mode { let next = match self.filter_mode {
FilterMode::Glob => FilterMode::Fuzzy, FilterMode::Glob => FilterMode::Fuzzy,
FilterMode::Fuzzy => FilterMode::Glob, FilterMode::Fuzzy => FilterMode::Glob,
}; };
self.recompute_filter_cache(); self.set_filter_mode(next);
self.rebuild_visible();
} }
pub fn toggle_hidden(&mut self) -> Result<()> { pub fn toggle_hidden(&mut self) -> Result<()> {
@@ -295,19 +302,21 @@ impl FileTreeState {
return; return;
} }
if let Some(rel) = diff_paths(path, &self.root) if let Some(rel) = diff_paths(path, &self.root) {
&& let Some(index) = self if let Some(index) = self
.nodes .nodes
.iter() .iter()
.position(|node| node.path == rel || node.path == path) .position(|node| node.path == rel || node.path == path)
{ {
self.expand_to(index); self.expand_to(index);
if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) { if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index)
{
self.cursor = cursor_pos; self.cursor = cursor_pos;
self.ensure_cursor_in_view(); self.ensure_cursor_in_view();
} }
} }
} }
}
fn expand_to(&mut self, index: usize) { fn expand_to(&mut self, index: usize) {
let mut current = Some(index); let mut current = Some(index);
@@ -554,11 +563,11 @@ fn build_nodes(
node.is_expanded = node.should_default_expand(); node.is_expanded = node.should_default_expand();
let index = nodes.len(); let index = nodes.len();
if let Some(parent_idx) = parent if let Some(parent_idx) = parent {
&& let Some(parent_node) = nodes.get_mut(parent_idx) if let Some(parent_node) = nodes.get_mut(parent_idx) {
{
parent_node.children.push(index); parent_node.children.push(index);
} }
}
index_by_path.insert(relative, index); index_by_path.insert(relative, index);
nodes.push(node); nodes.push(node);
@@ -578,22 +587,31 @@ fn propagate_directory_git_state(nodes: &mut [FileNode]) {
continue; continue;
} }
let mut has_dirty = false; let mut has_dirty = false;
let mut dirty_badge: Option<char> = None;
let mut has_staged = false; let mut has_staged = false;
for child in nodes[idx].children.clone() { for child in nodes[idx].children.clone() {
match nodes.get(child).map(|n| n.git.cleanliness) { if let Some(child_node) = nodes.get(child) {
Some('●') => { match child_node.git.cleanliness {
'●' => {
has_dirty = true; has_dirty = true;
break; let candidate = child_node.git.badge.unwrap_or('M');
dirty_badge = Some(match (dirty_badge, candidate) {
(Some('D'), _) | (_, 'D') => 'D',
(Some('U'), _) | (_, 'U') => 'U',
(Some(existing), _) => existing,
(None, new_badge) => new_badge,
});
} }
Some('○') => { '○' => {
has_staged = true; has_staged = true;
} }
_ => {} _ => {}
} }
} }
}
nodes[idx].git = if has_dirty { nodes[idx].git = if has_dirty {
GitDecoration::dirty(None) GitDecoration::dirty(dirty_badge)
} else if has_staged { } else if has_staged {
GitDecoration::staged(None) GitDecoration::staged(None)
} else { } else {

View File

@@ -182,13 +182,15 @@ impl RepoSearchState {
if matches!( if matches!(
self.rows[self.selected_row].kind, self.rows[self.selected_row].kind,
RepoSearchRowKind::FileHeader RepoSearchRowKind::FileHeader
) && let Some(idx) = self ) {
if let Some(idx) = self
.rows .rows
.iter() .iter()
.position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. })) .position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. }))
{ {
self.selected_row = idx; self.selected_row = idx;
} }
}
self.ensure_selection_visible(); self.ensure_selection_visible();
} }
} }

View File

@@ -85,6 +85,31 @@ pub enum LayoutNode {
} }
impl LayoutNode { impl LayoutNode {
pub fn ensure_ratio_bounds(&mut self) {
match self {
LayoutNode::Split {
ratio,
first,
second,
..
} => {
*ratio = ratio.clamp(0.1, 0.9);
first.ensure_ratio_bounds();
second.ensure_ratio_bounds();
}
LayoutNode::Leaf(_) => {}
}
}
pub fn nudge_ratio(&mut self, delta: f32) {
match self {
LayoutNode::Split { ratio, .. } => {
*ratio = (*ratio + delta).clamp(0.1, 0.9);
}
LayoutNode::Leaf(_) => {}
}
}
fn replace_leaf(&mut self, target: PaneId, replacement: LayoutNode) -> bool { fn replace_leaf(&mut self, target: PaneId, replacement: LayoutNode) -> bool {
match self { match self {
LayoutNode::Leaf(id) => { LayoutNode::Leaf(id) => {
@@ -265,6 +290,17 @@ impl CodePane {
self.scroll.scroll = 0; self.scroll.scroll = 0;
} }
pub fn update_paths(&mut self, absolute_path: Option<PathBuf>, display_path: Option<String>) {
self.absolute_path = absolute_path;
self.display_path = display_path.clone();
self.title = self
.absolute_path
.as_ref()
.and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned()))
.or(display_path)
.unwrap_or_else(|| "Untitled".to_string());
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.absolute_path = None; self.absolute_path = None;
self.display_path = None; self.display_path = None;

File diff suppressed because it is too large Load Diff