Compare commits
20 Commits
690f5c7056
...
96e0436d43
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e0436d43 | |||
| 498e6e61b6 | |||
| 99064b6c41 | |||
| ee58b0ac32 | |||
| 990f93d467 | |||
| 44a00619b5 | |||
| 6923ee439f | |||
| c997b19b53 | |||
| c9daf68fea | |||
| ba9d083088 | |||
| 825dfc0722 | |||
| 3e4eacd1d3 | |||
| 23253219a3 | |||
| cc2b85a86d | |||
| 58dd6f3efa | |||
| c81d0f1593 | |||
| ae0dd3fc51 | |||
| 80dffa9f41 | |||
| ab0ae4fe04 | |||
| d31e068277 |
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/owlen-mcp-client",
|
||||
"crates/owlen-mcp-code-server",
|
||||
"crates/owlen-mcp-prompt-server",
|
||||
"crates/owlen-markdown",
|
||||
]
|
||||
exclude = []
|
||||
|
||||
|
||||
@@ -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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -221,10 +221,11 @@ fn ensure_provider_entry(config: &mut Config, provider: &str, endpoint: &str) {
|
||||
if provider == "ollama"
|
||||
&& config.providers.contains_key("ollama-cloud")
|
||||
&& !config.providers.contains_key("ollama")
|
||||
&& let Some(mut legacy) = config.providers.remove("ollama-cloud")
|
||||
{
|
||||
legacy.provider_type = "ollama".to_string();
|
||||
config.providers.insert("ollama".to_string(), legacy);
|
||||
if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
|
||||
legacy.provider_type = "ollama".to_string();
|
||||
config.providers.insert("ollama".to_string(), legacy);
|
||||
}
|
||||
}
|
||||
|
||||
core_config::ensure_provider_config(config, provider);
|
||||
@@ -315,8 +316,10 @@ fn unlock_vault(path: &Path) -> Result<encryption::VaultHandle> {
|
||||
use std::env;
|
||||
|
||||
if path.exists() {
|
||||
if let Ok(password) = env::var("OWLEN_MASTER_PASSWORD")
|
||||
&& !password.trim().is_empty()
|
||||
if let Some(password) = env::var("OWLEN_MASTER_PASSWORD")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|password| !password.is_empty())
|
||||
{
|
||||
return encryption::unlock_with_password(path.to_path_buf(), &password)
|
||||
.context("Failed to unlock vault with OWLEN_MASTER_PASSWORD");
|
||||
@@ -356,30 +359,31 @@ async fn hydrate_api_key(
|
||||
config: &mut Config,
|
||||
manager: Option<&Arc<CredentialManager>>,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(manager) = manager
|
||||
&& let Some(credentials) = manager.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID).await?
|
||||
{
|
||||
let credentials = match manager {
|
||||
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();
|
||||
if !key.is_empty() {
|
||||
set_env_if_missing("OLLAMA_API_KEY", &key);
|
||||
set_env_if_missing("OLLAMA_CLOUD_API_KEY", &key);
|
||||
}
|
||||
|
||||
if let Some(cfg) = provider_entry_mut(config)
|
||||
&& cfg.base_url.is_none()
|
||||
&& !credentials.endpoint.trim().is_empty()
|
||||
{
|
||||
cfg.base_url = Some(credentials.endpoint);
|
||||
let Some(cfg) = provider_entry_mut(config) else {
|
||||
return Ok(Some(key));
|
||||
};
|
||||
if cfg.base_url.is_none() && !credentials.endpoint.trim().is_empty() {
|
||||
cfg.base_url = Some(credentials.endpoint.clone());
|
||||
}
|
||||
return Ok(Some(key));
|
||||
}
|
||||
|
||||
if let Some(cfg) = provider_entry(config)
|
||||
&& let Some(key) = cfg
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
if let Some(key) = provider_entry(config)
|
||||
.and_then(|cfg| cfg.api_key.as_ref())
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
set_env_if_missing("OLLAMA_API_KEY", key);
|
||||
set_env_if_missing("OLLAMA_CLOUD_API_KEY", key);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
|
||||
|
||||
//! OWLEN CLI - Chat TUI client
|
||||
|
||||
mod cloud;
|
||||
|
||||
@@ -151,8 +151,9 @@ fn handle_list(args: ListArgs) -> Result<()> {
|
||||
"", "Scope", "Name", "Transport"
|
||||
);
|
||||
for entry in scoped {
|
||||
if let Some(target_scope) = filter_scope
|
||||
&& entry.scope != target_scope
|
||||
if filter_scope
|
||||
.as_ref()
|
||||
.is_some_and(|target_scope| entry.scope != *target_scope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -186,8 +187,9 @@ fn handle_list(args: ListArgs) -> Result<()> {
|
||||
.collect();
|
||||
|
||||
for entry in scoped_resources {
|
||||
if let Some(target_scope) = filter_scope
|
||||
&& entry.scope != target_scope
|
||||
if filter_scope
|
||||
.as_ref()
|
||||
.is_some_and(|target_scope| entry.scope != *target_scope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1332,6 +1332,8 @@ pub struct UiSettings {
|
||||
pub show_cursor_outside_insert: bool,
|
||||
#[serde(default = "UiSettings::default_syntax_highlighting")]
|
||||
pub syntax_highlighting: bool,
|
||||
#[serde(default = "UiSettings::default_render_markdown")]
|
||||
pub render_markdown: bool,
|
||||
#[serde(default = "UiSettings::default_show_timestamps")]
|
||||
pub show_timestamps: bool,
|
||||
#[serde(default = "UiSettings::default_icon_mode")]
|
||||
@@ -1389,7 +1391,11 @@ impl UiSettings {
|
||||
}
|
||||
|
||||
const fn default_syntax_highlighting() -> bool {
|
||||
false
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_render_markdown() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_show_timestamps() -> bool {
|
||||
@@ -1466,6 +1472,7 @@ 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(),
|
||||
render_markdown: Self::default_render_markdown(),
|
||||
show_timestamps: Self::default_show_timestamps(),
|
||||
icon_mode: Self::default_icon_mode(),
|
||||
}
|
||||
|
||||
@@ -58,9 +58,14 @@ impl ConsentManager {
|
||||
/// Load consent records from vault storage
|
||||
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
|
||||
let guard = vault.lock().expect("Vault mutex poisoned");
|
||||
if let Some(consent_data) = guard.settings().get("consent_records")
|
||||
&& let Ok(permanent_records) =
|
||||
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
|
||||
if let Some(permanent_records) =
|
||||
guard
|
||||
.settings()
|
||||
.get("consent_records")
|
||||
.and_then(|consent_data| {
|
||||
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
|
||||
.ok()
|
||||
})
|
||||
{
|
||||
return Self {
|
||||
permanent_records,
|
||||
@@ -90,15 +95,19 @@ impl ConsentManager {
|
||||
endpoints: Vec<String>,
|
||||
) -> Result<ConsentScope> {
|
||||
// Check if already granted permanently
|
||||
if let Some(existing) = self.permanent_records.get(tool_name)
|
||||
&& existing.scope == ConsentScope::Permanent
|
||||
if self
|
||||
.permanent_records
|
||||
.get(tool_name)
|
||||
.is_some_and(|existing| existing.scope == ConsentScope::Permanent)
|
||||
{
|
||||
return Ok(ConsentScope::Permanent);
|
||||
}
|
||||
|
||||
// Check if granted for session
|
||||
if let Some(existing) = self.session_records.get(tool_name)
|
||||
&& existing.scope == ConsentScope::Session
|
||||
if self
|
||||
.session_records
|
||||
.get(tool_name)
|
||||
.is_some_and(|existing| existing.scope == ConsentScope::Session)
|
||||
{
|
||||
return Ok(ConsentScope::Session);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
//! This crate provides the foundational abstractions for building
|
||||
|
||||
@@ -156,13 +156,14 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::LocalMcpClient;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::ui::NoOpUiController;
|
||||
use crate::validation::SchemaValidator;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_permission_layer_filters_dangerous_tools() {
|
||||
let config = Arc::new(Config::default());
|
||||
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||
let ui = Arc::new(NoOpUiController);
|
||||
let registry = Arc::new(ToolRegistry::new(
|
||||
Arc::new(tokio::sync::Mutex::new((*config).clone())),
|
||||
ui,
|
||||
@@ -186,7 +187,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_consent_callback_is_invoked() {
|
||||
let config = Arc::new(Config::default());
|
||||
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||
let ui = Arc::new(NoOpUiController);
|
||||
let registry = Arc::new(ToolRegistry::new(
|
||||
Arc::new(tokio::sync::Mutex::new((*config).clone())),
|
||||
ui,
|
||||
|
||||
@@ -42,7 +42,7 @@ impl ModelManager {
|
||||
F: FnOnce() -> Fut,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -378,10 +378,8 @@ impl OllamaProvider {
|
||||
let family = pick_first_string(map, &["family", "model_family"]);
|
||||
let mut families = pick_string_list(map, &["families", "model_families"]);
|
||||
|
||||
if families.is_empty()
|
||||
&& let Some(single) = family.clone()
|
||||
{
|
||||
families.push(single);
|
||||
if families.is_empty() {
|
||||
families.extend(family.clone());
|
||||
}
|
||||
|
||||
let system = pick_first_string(map, &["system"]);
|
||||
|
||||
@@ -71,16 +71,19 @@ impl Router {
|
||||
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
|
||||
// Check routing rules first
|
||||
for rule in &self.routing_rules {
|
||||
if self.matches_pattern(&rule.model_pattern, model)
|
||||
&& let Some(provider) = self.registry.get(&rule.provider)
|
||||
{
|
||||
if !self.matches_pattern(&rule.model_pattern, model) {
|
||||
continue;
|
||||
}
|
||||
if let Some(provider) = self.registry.get(&rule.provider) {
|
||||
return Ok(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default provider
|
||||
if let Some(default) = &self.default_provider
|
||||
&& let Some(provider) = self.registry.get(default)
|
||||
if let Some(provider) = self
|
||||
.default_provider
|
||||
.as_ref()
|
||||
.and_then(|default| self.registry.get(default))
|
||||
{
|
||||
return Ok(provider);
|
||||
}
|
||||
|
||||
@@ -185,14 +185,20 @@ impl SandboxedProcess {
|
||||
if let Ok(output) = output {
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
// Parse version like "bubblewrap 0.11.0" or "0.11.0"
|
||||
if let Some(version_part) = version_str.split_whitespace().last()
|
||||
&& let Some((major, rest)) = version_part.split_once('.')
|
||||
&& let Some((minor, _patch)) = rest.split_once('.')
|
||||
&& let (Ok(maj), Ok(min)) = (major.parse::<u32>(), minor.parse::<u32>())
|
||||
{
|
||||
// --rlimit-as was added in 0.12.0
|
||||
return maj > 0 || (maj == 0 && min >= 12);
|
||||
}
|
||||
return version_str
|
||||
.split_whitespace()
|
||||
.last()
|
||||
.and_then(|part| {
|
||||
part.split_once('.').and_then(|(major, rest)| {
|
||||
rest.split_once('.').and_then(|(minor, _)| {
|
||||
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)
|
||||
|
||||
@@ -53,8 +53,8 @@ fn extract_resource_content(value: &Value) -> Option<String> {
|
||||
Value::Array(items) => {
|
||||
let mut segments = Vec::new();
|
||||
for item in items {
|
||||
if let Some(segment) = extract_resource_content(item)
|
||||
&& !segment.is_empty()
|
||||
if let Some(segment) =
|
||||
extract_resource_content(item).filter(|segment| !segment.is_empty())
|
||||
{
|
||||
segments.push(segment);
|
||||
}
|
||||
@@ -69,17 +69,19 @@ fn extract_resource_content(value: &Value) -> Option<String> {
|
||||
const PREFERRED_FIELDS: [&str; 6] =
|
||||
["content", "contents", "text", "value", "body", "data"];
|
||||
for key in PREFERRED_FIELDS.iter() {
|
||||
if let Some(inner) = map.get(*key)
|
||||
&& let Some(text) = extract_resource_content(inner)
|
||||
&& !text.is_empty()
|
||||
if let Some(text) = map
|
||||
.get(*key)
|
||||
.and_then(extract_resource_content)
|
||||
.filter(|text| !text.is_empty())
|
||||
{
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(inner) = map.get("chunks")
|
||||
&& let Some(text) = extract_resource_content(inner)
|
||||
&& !text.is_empty()
|
||||
if let Some(text) = map
|
||||
.get("chunks")
|
||||
.and_then(extract_resource_content)
|
||||
.filter(|text| !text.is_empty())
|
||||
{
|
||||
return Some(text);
|
||||
}
|
||||
@@ -566,9 +568,10 @@ impl SessionController {
|
||||
.expect("Consent manager mutex poisoned");
|
||||
consent.grant_consent(tool_name, data_types, endpoints);
|
||||
|
||||
if let Some(vault) = &self.vault
|
||||
&& 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);
|
||||
}
|
||||
}
|
||||
@@ -588,10 +591,13 @@ impl SessionController {
|
||||
consent.grant_consent_with_scope(tool_name, data_types, endpoints, scope);
|
||||
|
||||
// Only persist to vault for permanent consent
|
||||
if is_permanent
|
||||
&& let Some(vault) = &self.vault
|
||||
&& let Err(e) = consent.persist_to_vault(vault)
|
||||
{
|
||||
if !is_permanent {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ impl StorageManager {
|
||||
|
||||
/// Create a storage manager using the provided database path
|
||||
pub async fn with_database_path(database_path: PathBuf) -> Result<Self> {
|
||||
if let Some(parent) = database_path.parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
Error::Storage(format!(
|
||||
"Failed to create database directory {parent:?}: {e}"
|
||||
))
|
||||
})?;
|
||||
if let Some(parent) = database_path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
Error::Storage(format!(
|
||||
"Failed to create database directory {parent:?}: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let options = SqliteConnectOptions::from_str(&format!(
|
||||
@@ -431,13 +431,13 @@ impl StorageManager {
|
||||
}
|
||||
}
|
||||
|
||||
if migrated > 0
|
||||
&& let Err(err) = archive_legacy_directory(&legacy_dir)
|
||||
{
|
||||
println!(
|
||||
"Warning: migrated sessions but failed to archive legacy directory: {}",
|
||||
err
|
||||
);
|
||||
if migrated > 0 {
|
||||
if let Err(err) = archive_legacy_directory(&legacy_dir) {
|
||||
println!(
|
||||
"Warning: migrated sessions but failed to archive legacy directory: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Migrated {} legacy sessions.", migrated);
|
||||
|
||||
10
crates/owlen-markdown/Cargo.toml
Normal file
10
crates/owlen-markdown/Cargo.toml
Normal 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"
|
||||
270
crates/owlen-markdown/src/lib.rs
Normal file
270
crates/owlen-markdown/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ tree-sitter = "0.20"
|
||||
tree-sitter-rust = "0.20"
|
||||
dirs = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
syntect = "5.3"
|
||||
once_cell = "1.19"
|
||||
owlen-markdown = { path = "../owlen-markdown" }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,15 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
},
|
||||
CommandSpec {
|
||||
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 {
|
||||
keyword: "clear",
|
||||
@@ -25,12 +33,16 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
description: "Alias for clear",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "w",
|
||||
description: "Alias for write",
|
||||
keyword: "save",
|
||||
description: "Alias for w",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "save",
|
||||
description: "Alias for write",
|
||||
keyword: "wq",
|
||||
description: "Save and close the active file",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "x",
|
||||
description: "Alias for wq",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "load",
|
||||
@@ -44,6 +56,10 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
keyword: "open",
|
||||
description: "Open a file in the code view",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "create",
|
||||
description: "Create a file (creates missing directories)",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "close",
|
||||
description: "Close the active code view",
|
||||
@@ -68,9 +84,13 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
keyword: "sessions",
|
||||
description: "List saved sessions",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "session save",
|
||||
description: "Save the current conversation",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "help",
|
||||
description: "Show help documentation",
|
||||
description: "Open the help overlay",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "h",
|
||||
@@ -128,6 +148,10 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
keyword: "reload",
|
||||
description: "Reload configuration and themes",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "markdown",
|
||||
description: "Toggle markdown rendering",
|
||||
},
|
||||
CommandSpec {
|
||||
keyword: "e",
|
||||
description: "Edit a file",
|
||||
@@ -180,6 +204,14 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
keyword: "layout load",
|
||||
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.
|
||||
|
||||
160
crates/owlen-tui/src/highlight.rs
Normal file
160
crates/owlen-tui/src/highlight.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::collapsible_if)] // TODO: Remove once Rust 2024 let-chains are available
|
||||
|
||||
//! # Owlen TUI
|
||||
//!
|
||||
//! 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 config;
|
||||
pub mod events;
|
||||
pub mod highlight;
|
||||
pub mod model_info_panel;
|
||||
pub mod slash;
|
||||
pub mod state;
|
||||
|
||||
@@ -110,7 +110,7 @@ impl FileTreeState {
|
||||
cursor: 0,
|
||||
scroll_top: 0,
|
||||
viewport_height: 20,
|
||||
filter_mode: FilterMode::Fuzzy,
|
||||
filter_mode: FilterMode::Glob,
|
||||
filter_query: String::new(),
|
||||
show_hidden: false,
|
||||
filter_matches: Vec::new(),
|
||||
@@ -198,6 +198,14 @@ impl FileTreeState {
|
||||
&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 {
|
||||
self.show_hidden
|
||||
}
|
||||
@@ -276,12 +284,11 @@ impl FileTreeState {
|
||||
}
|
||||
|
||||
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::Fuzzy => FilterMode::Glob,
|
||||
};
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
self.set_filter_mode(next);
|
||||
}
|
||||
|
||||
pub fn toggle_hidden(&mut self) -> Result<()> {
|
||||
@@ -295,16 +302,18 @@ impl FileTreeState {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(rel) = diff_paths(path, &self.root)
|
||||
&& let Some(index) = self
|
||||
if let Some(rel) = diff_paths(path, &self.root) {
|
||||
if 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();
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,10 +563,10 @@ fn build_nodes(
|
||||
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);
|
||||
if let Some(parent_idx) = parent {
|
||||
if let Some(parent_node) = nodes.get_mut(parent_idx) {
|
||||
parent_node.children.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
index_by_path.insert(relative, index);
|
||||
@@ -578,22 +587,31 @@ fn propagate_directory_git_state(nodes: &mut [FileNode]) {
|
||||
continue;
|
||||
}
|
||||
let mut has_dirty = false;
|
||||
let mut dirty_badge: Option<char> = None;
|
||||
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;
|
||||
if let Some(child_node) = nodes.get(child) {
|
||||
match child_node.git.cleanliness {
|
||||
'●' => {
|
||||
has_dirty = true;
|
||||
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,
|
||||
});
|
||||
}
|
||||
'○' => {
|
||||
has_staged = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some('○') => {
|
||||
has_staged = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
nodes[idx].git = if has_dirty {
|
||||
GitDecoration::dirty(None)
|
||||
GitDecoration::dirty(dirty_badge)
|
||||
} else if has_staged {
|
||||
GitDecoration::staged(None)
|
||||
} else {
|
||||
|
||||
@@ -182,12 +182,14 @@ impl RepoSearchState {
|
||||
if matches!(
|
||||
self.rows[self.selected_row].kind,
|
||||
RepoSearchRowKind::FileHeader
|
||||
) && let Some(idx) = self
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. }))
|
||||
{
|
||||
self.selected_row = idx;
|
||||
) {
|
||||
if let Some(idx) = self
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. }))
|
||||
{
|
||||
self.selected_row = idx;
|
||||
}
|
||||
}
|
||||
self.ensure_selection_visible();
|
||||
}
|
||||
|
||||
@@ -85,6 +85,31 @@ pub enum 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 {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => {
|
||||
@@ -265,6 +290,17 @@ impl CodePane {
|
||||
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) {
|
||||
self.absolute_path = None;
|
||||
self.display_path = None;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user