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-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 = []
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -221,10 +221,11 @@ 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")
|
|
||||||
{
|
{
|
||||||
legacy.provider_type = "ollama".to_string();
|
if let Some(mut legacy) = config.providers.remove("ollama-cloud") {
|
||||||
config.providers.insert("ollama".to_string(), legacy);
|
legacy.provider_type = "ollama".to_string();
|
||||||
|
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,30 +359,31 @@ 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
|
.map(|value| value.trim())
|
||||||
.as_ref()
|
.filter(|value| !value.is_empty())
|
||||||
.map(|value| value.trim())
|
|
||||||
.filter(|value| !value.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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
|
.settings()
|
||||||
|
.get("consent_records")
|
||||||
|
.and_then(|consent_data| {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ 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!(
|
||||||
@@ -431,13 +431,13 @@ 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);
|
||||||
|
|||||||
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"
|
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
@@ -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.
|
||||||
|
|||||||
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
|
//! # 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;
|
||||||
|
|||||||
@@ -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,16 +302,18 @@ 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.ensure_cursor_in_view();
|
self.cursor = cursor_pos;
|
||||||
|
self.ensure_cursor_in_view();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,10 +563,10 @@ 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);
|
||||||
@@ -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;
|
'●' => {
|
||||||
break;
|
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 {
|
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 {
|
||||||
|
|||||||
@@ -182,12 +182,14 @@ 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
|
) {
|
||||||
.rows
|
if let Some(idx) = self
|
||||||
.iter()
|
.rows
|
||||||
.position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. }))
|
.iter()
|
||||||
{
|
.position(|row| matches!(row.kind, RepoSearchRowKind::Match { .. }))
|
||||||
self.selected_row = idx;
|
{
|
||||||
|
self.selected_row = idx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.ensure_selection_visible();
|
self.ensure_selection_visible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user