feat(state): add file‑tree and repository‑search state modules
Introduce `FileTreeState` for managing a navigable file hierarchy with Git decorations, filtering, and cursor/scroll handling. Add `RepoSearchState` and related types to support asynchronous ripgrep‑backed repository searches, including result aggregation, pagination, and UI interaction.
This commit is contained in:
@@ -21,6 +21,8 @@ pub enum InputMode {
|
||||
Command,
|
||||
SessionBrowser,
|
||||
ThemeBrowser,
|
||||
RepoSearch,
|
||||
SymbolSearch,
|
||||
}
|
||||
|
||||
impl fmt::Display for InputMode {
|
||||
@@ -35,6 +37,8 @@ impl fmt::Display for InputMode {
|
||||
InputMode::Command => "Command",
|
||||
InputMode::SessionBrowser => "Sessions",
|
||||
InputMode::ThemeBrowser => "Themes",
|
||||
InputMode::RepoSearch => "Search",
|
||||
InputMode::SymbolSearch => "Symbols",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
@@ -43,6 +47,7 @@ impl fmt::Display for InputMode {
|
||||
/// Represents which panel is currently focused in the TUI layout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FocusedPanel {
|
||||
Files,
|
||||
Chat,
|
||||
Thinking,
|
||||
Input,
|
||||
|
||||
@@ -20,6 +20,13 @@ textwrap = { workspace = true }
|
||||
unicode-width = "0.1"
|
||||
unicode-segmentation = "1.11"
|
||||
async-trait = "0.1"
|
||||
globset = "0.4"
|
||||
ignore = "0.4"
|
||||
pathdiff = "0.2"
|
||||
tree-sitter = "0.20"
|
||||
tree-sitter-rust = "0.20"
|
||||
dirs = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
@@ -30,6 +37,7 @@ futures-util = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
705
crates/owlen-tui/src/state/file_tree.rs
Normal file
705
crates/owlen-tui/src/state/file_tree.rs
Normal file
@@ -0,0 +1,705 @@
|
||||
use crate::commands;
|
||||
use anyhow::{Context, Result};
|
||||
use globset::{Glob, GlobBuilder, GlobSetBuilder};
|
||||
use ignore::WalkBuilder;
|
||||
use pathdiff::diff_paths;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Indicates which matching strategy is applied when filtering the file tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FilterMode {
|
||||
Glob,
|
||||
Fuzzy,
|
||||
}
|
||||
|
||||
/// Git-related decorations rendered alongside a file entry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitDecoration {
|
||||
pub badge: Option<char>,
|
||||
pub cleanliness: char,
|
||||
}
|
||||
|
||||
impl GitDecoration {
|
||||
pub fn clean() -> Self {
|
||||
Self {
|
||||
badge: None,
|
||||
cleanliness: '✓',
|
||||
}
|
||||
}
|
||||
|
||||
pub fn staged(badge: Option<char>) -> Self {
|
||||
Self {
|
||||
badge,
|
||||
cleanliness: '○',
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirty(badge: Option<char>) -> Self {
|
||||
Self {
|
||||
badge,
|
||||
cleanliness: '●',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Node representing a single entry (file or directory) in the tree.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileNode {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub parent: Option<usize>,
|
||||
pub children: Vec<usize>,
|
||||
pub depth: usize,
|
||||
pub is_dir: bool,
|
||||
pub is_expanded: bool,
|
||||
pub is_hidden: bool,
|
||||
pub git: GitDecoration,
|
||||
}
|
||||
|
||||
impl FileNode {
|
||||
fn should_default_expand(&self) -> bool {
|
||||
self.depth < 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible entry metadata returned to the renderer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibleFileEntry {
|
||||
pub index: usize,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
/// Tracks the entire file tree state including filters, selection, and scroll.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileTreeState {
|
||||
root: PathBuf,
|
||||
repo_name: String,
|
||||
nodes: Vec<FileNode>,
|
||||
visible: Vec<VisibleFileEntry>,
|
||||
cursor: usize,
|
||||
scroll_top: usize,
|
||||
viewport_height: usize,
|
||||
filter_mode: FilterMode,
|
||||
filter_query: String,
|
||||
show_hidden: bool,
|
||||
filter_matches: Vec<bool>,
|
||||
last_error: Option<String>,
|
||||
git_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl FileTreeState {
|
||||
/// Construct a new file tree rooted at the provided path.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let mut root_path = root.into();
|
||||
if let Ok(canonical) = root_path.canonicalize() {
|
||||
root_path = canonical;
|
||||
}
|
||||
let repo_name = root_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| root_path.display().to_string());
|
||||
|
||||
let mut state = Self {
|
||||
root: root_path,
|
||||
repo_name,
|
||||
nodes: Vec::new(),
|
||||
visible: Vec::new(),
|
||||
cursor: 0,
|
||||
scroll_top: 0,
|
||||
viewport_height: 20,
|
||||
filter_mode: FilterMode::Fuzzy,
|
||||
filter_query: String::new(),
|
||||
show_hidden: false,
|
||||
filter_matches: Vec::new(),
|
||||
last_error: None,
|
||||
git_branch: None,
|
||||
};
|
||||
|
||||
if let Err(err) = state.refresh() {
|
||||
state.nodes.clear();
|
||||
state.visible.clear();
|
||||
state.filter_matches.clear();
|
||||
state.last_error = Some(err.to_string());
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Rebuild the file tree from disk and recompute visibility.
|
||||
pub fn refresh(&mut self) -> Result<()> {
|
||||
let git_map = collect_git_status(&self.root).unwrap_or_default();
|
||||
self.nodes = build_nodes(&self.root, self.show_hidden, git_map)?;
|
||||
self.git_branch = current_git_branch(&self.root).unwrap_or(None);
|
||||
if self.nodes.is_empty() {
|
||||
self.visible.clear();
|
||||
self.filter_matches.clear();
|
||||
self.cursor = 0;
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_valid_cursor();
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn repo_name(&self) -> &str {
|
||||
&self.repo_name
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.visible.is_empty()
|
||||
}
|
||||
|
||||
pub fn visible_entries(&self) -> &[VisibleFileEntry] {
|
||||
&self.visible
|
||||
}
|
||||
|
||||
pub fn nodes(&self) -> &[FileNode] {
|
||||
&self.nodes
|
||||
}
|
||||
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.visible.get(self.cursor).map(|entry| entry.index)
|
||||
}
|
||||
|
||||
pub fn selected_node(&self) -> Option<&FileNode> {
|
||||
self.selected_index().and_then(|idx| self.nodes.get(idx))
|
||||
}
|
||||
|
||||
pub fn selected_node_mut(&mut self) -> Option<&mut FileNode> {
|
||||
let idx = self.selected_index()?;
|
||||
self.nodes.get_mut(idx)
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn scroll_top(&self) -> usize {
|
||||
self.scroll_top
|
||||
}
|
||||
|
||||
pub fn viewport_height(&self) -> usize {
|
||||
self.viewport_height
|
||||
}
|
||||
|
||||
pub fn filter_mode(&self) -> FilterMode {
|
||||
self.filter_mode
|
||||
}
|
||||
|
||||
pub fn filter_query(&self) -> &str {
|
||||
&self.filter_query
|
||||
}
|
||||
|
||||
pub fn show_hidden(&self) -> bool {
|
||||
self.show_hidden
|
||||
}
|
||||
|
||||
pub fn git_branch(&self) -> Option<&str> {
|
||||
self.git_branch.as_deref()
|
||||
}
|
||||
|
||||
pub fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
|
||||
pub fn set_viewport_height(&mut self, height: usize) {
|
||||
self.viewport_height = height.max(1);
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
|
||||
pub fn move_cursor(&mut self, delta: isize) {
|
||||
if self.visible.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let len = self.visible.len() as isize;
|
||||
let new_cursor = (self.cursor as isize + delta).clamp(0, len - 1) as usize;
|
||||
self.cursor = new_cursor;
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
|
||||
pub fn jump_to_top(&mut self) {
|
||||
if !self.visible.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jump_to_bottom(&mut self) {
|
||||
if !self.visible.is_empty() {
|
||||
self.cursor = self.visible.len().saturating_sub(1);
|
||||
let viewport = self.viewport_height.max(1);
|
||||
self.scroll_top = self.visible.len().saturating_sub(viewport);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
let amount = self.viewport_height.max(1) as isize;
|
||||
self.move_cursor(amount);
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
let amount = -(self.viewport_height.max(1) as isize);
|
||||
self.move_cursor(amount);
|
||||
}
|
||||
|
||||
pub fn toggle_expand(&mut self) {
|
||||
if let Some(node) = self.selected_node_mut() {
|
||||
if !node.is_dir {
|
||||
return;
|
||||
}
|
||||
node.is_expanded = !node.is_expanded;
|
||||
self.rebuild_visible();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_filter_query(&mut self, query: impl Into<String>) {
|
||||
self.filter_query = query.into();
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
pub fn clear_filter(&mut self) {
|
||||
self.filter_query.clear();
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
pub fn toggle_filter_mode(&mut self) {
|
||||
self.filter_mode = match self.filter_mode {
|
||||
FilterMode::Glob => FilterMode::Fuzzy,
|
||||
FilterMode::Fuzzy => FilterMode::Glob,
|
||||
};
|
||||
self.recompute_filter_cache();
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
pub fn toggle_hidden(&mut self) -> Result<()> {
|
||||
self.show_hidden = !self.show_hidden;
|
||||
self.refresh()
|
||||
}
|
||||
|
||||
/// Expand directories along the provided path and position the cursor.
|
||||
pub fn reveal(&mut self, path: &Path) {
|
||||
if self.nodes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(rel) = diff_paths(path, &self.root)
|
||||
&& let Some(index) = self
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|node| node.path == rel || node.path == path)
|
||||
{
|
||||
self.expand_to(index);
|
||||
if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) {
|
||||
self.cursor = cursor_pos;
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_to(&mut self, index: usize) {
|
||||
let mut current = Some(index);
|
||||
while let Some(idx) = current {
|
||||
if let Some(parent) = self.nodes.get(idx).and_then(|node| node.parent) {
|
||||
if let Some(parent_node) = self.nodes.get_mut(parent) {
|
||||
parent_node.is_expanded = true;
|
||||
}
|
||||
current = Some(parent);
|
||||
} else {
|
||||
current = None;
|
||||
}
|
||||
}
|
||||
self.rebuild_visible();
|
||||
}
|
||||
|
||||
fn ensure_valid_cursor(&mut self) {
|
||||
if self.cursor >= self.visible.len() {
|
||||
self.cursor = self.visible.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_cursor_in_view(&mut self) {
|
||||
if self.visible.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let viewport = self.viewport_height.max(1);
|
||||
if self.cursor < self.scroll_top {
|
||||
self.scroll_top = self.cursor;
|
||||
} else if self.cursor >= self.scroll_top + viewport {
|
||||
self.scroll_top = self.cursor + 1 - viewport;
|
||||
}
|
||||
}
|
||||
|
||||
fn recompute_filter_cache(&mut self) {
|
||||
let has_filter = !self.filter_query.trim().is_empty();
|
||||
self.filter_matches = if !has_filter {
|
||||
vec![true; self.nodes.len()]
|
||||
} else {
|
||||
self.nodes
|
||||
.iter()
|
||||
.map(|node| match self.filter_mode {
|
||||
FilterMode::Glob => glob_matches(self.filter_query.trim(), node),
|
||||
FilterMode::Fuzzy => fuzzy_matches(self.filter_query.trim(), node),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
if has_filter {
|
||||
// Ensure parent directories of matches are preserved.
|
||||
for idx in (0..self.nodes.len()).rev() {
|
||||
let children = self.nodes[idx].children.clone();
|
||||
if !self.filter_matches[idx]
|
||||
&& children
|
||||
.iter()
|
||||
.any(|child| self.filter_matches.get(*child).copied().unwrap_or(false))
|
||||
{
|
||||
self.filter_matches[idx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_visible(&mut self) {
|
||||
self.visible.clear();
|
||||
|
||||
if self.nodes.is_empty() {
|
||||
self.cursor = 0;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let has_filter = !self.filter_query.trim().is_empty();
|
||||
self.walk_visible(0, has_filter);
|
||||
if self.visible.is_empty() {
|
||||
// At minimum show the root node.
|
||||
self.visible.push(VisibleFileEntry {
|
||||
index: 0,
|
||||
depth: self.nodes[0].depth,
|
||||
});
|
||||
}
|
||||
let max_index = self.visible.len().saturating_sub(1);
|
||||
self.cursor = self.cursor.min(max_index);
|
||||
self.ensure_cursor_in_view();
|
||||
}
|
||||
|
||||
fn walk_visible(&mut self, index: usize, filter_override: bool) {
|
||||
if !self.filter_matches.get(index).copied().unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (depth, descend, children) = {
|
||||
let node = match self.nodes.get(index) {
|
||||
Some(node) => node,
|
||||
None => return,
|
||||
};
|
||||
let descend = if filter_override {
|
||||
node.is_dir
|
||||
} else {
|
||||
node.is_dir && node.is_expanded
|
||||
};
|
||||
let children = if node.is_dir {
|
||||
node.children.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(node.depth, descend, children)
|
||||
};
|
||||
|
||||
self.visible.push(VisibleFileEntry { index, depth });
|
||||
|
||||
if descend {
|
||||
for child in children {
|
||||
self.walk_visible(child, filter_override);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn glob_matches(pattern: &str, node: &FileNode) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
match GlobBuilder::new(pattern).literal_separator(true).build() {
|
||||
Ok(glob) => {
|
||||
builder.add(glob);
|
||||
if let Ok(set) = builder.build() {
|
||||
return set.is_match(&node.path) || set.is_match(node.name.as_str());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if let Ok(glob) = Glob::new("**") {
|
||||
builder.add(glob);
|
||||
if let Ok(set) = builder.build() {
|
||||
return set.is_match(&node.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn fuzzy_matches(query: &str, node: &FileNode) -> bool {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let path_str = node.path.to_string_lossy();
|
||||
let name = node.name.as_str();
|
||||
|
||||
commands::match_score(&path_str, query)
|
||||
.or_else(|| commands::match_score(name, query))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn build_nodes(
|
||||
root: &Path,
|
||||
show_hidden: bool,
|
||||
git_map: HashMap<PathBuf, GitDecoration>,
|
||||
) -> Result<Vec<FileNode>> {
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder.hidden(!show_hidden);
|
||||
builder.git_global(true);
|
||||
builder.git_ignore(true);
|
||||
builder.git_exclude(true);
|
||||
builder.follow_links(false);
|
||||
builder.sort_by_file_path(|a, b| a.file_name().cmp(&b.file_name()));
|
||||
|
||||
let owlen_ignore = root.join(".owlenignore");
|
||||
if owlen_ignore.exists() {
|
||||
builder.add_ignore(&owlen_ignore);
|
||||
}
|
||||
|
||||
let mut nodes: Vec<FileNode> = Vec::new();
|
||||
let mut index_by_path: HashMap<PathBuf, usize> = HashMap::new();
|
||||
|
||||
for result in builder.build() {
|
||||
let entry = match result {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("File tree walk error: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip errors or entries without metadata.
|
||||
let file_type = match entry.file_type() {
|
||||
Some(ft) => ft,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let depth = entry.depth();
|
||||
if depth == 0 && !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative = if depth == 0 {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
diff_paths(entry.path(), root).unwrap_or_else(|| entry.path().to_path_buf())
|
||||
};
|
||||
|
||||
let name = if depth == 0 {
|
||||
root.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| root.display().to_string())
|
||||
} else {
|
||||
entry.file_name().to_string_lossy().into_owned()
|
||||
};
|
||||
|
||||
let parent = if depth == 0 {
|
||||
None
|
||||
} else {
|
||||
entry
|
||||
.path()
|
||||
.parent()
|
||||
.and_then(|parent| diff_paths(parent, root))
|
||||
.and_then(|rel_parent| index_by_path.get(&rel_parent).copied())
|
||||
};
|
||||
|
||||
let git = git_map
|
||||
.get(&relative)
|
||||
.cloned()
|
||||
.unwrap_or_else(GitDecoration::clean);
|
||||
|
||||
let mut node = FileNode {
|
||||
name,
|
||||
path: relative.clone(),
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
depth,
|
||||
is_dir: file_type.is_dir(),
|
||||
is_expanded: false,
|
||||
is_hidden: is_hidden(entry.file_name()),
|
||||
git,
|
||||
};
|
||||
|
||||
node.is_expanded = node.should_default_expand();
|
||||
|
||||
let index = nodes.len();
|
||||
if let Some(parent_idx) = parent
|
||||
&& let Some(parent_node) = nodes.get_mut(parent_idx)
|
||||
{
|
||||
parent_node.children.push(index);
|
||||
}
|
||||
|
||||
index_by_path.insert(relative, index);
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
propagate_directory_git_state(&mut nodes);
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
fn is_hidden(name: &OsStr) -> bool {
|
||||
name.to_string_lossy().starts_with('.')
|
||||
}
|
||||
|
||||
fn propagate_directory_git_state(nodes: &mut [FileNode]) {
|
||||
for idx in (0..nodes.len()).rev() {
|
||||
if !nodes[idx].is_dir {
|
||||
continue;
|
||||
}
|
||||
let mut has_dirty = false;
|
||||
let mut has_staged = false;
|
||||
for child in nodes[idx].children.clone() {
|
||||
match nodes.get(child).map(|n| n.git.cleanliness) {
|
||||
Some('●') => {
|
||||
has_dirty = true;
|
||||
break;
|
||||
}
|
||||
Some('○') => {
|
||||
has_staged = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
nodes[idx].git = if has_dirty {
|
||||
GitDecoration::dirty(None)
|
||||
} else if has_staged {
|
||||
GitDecoration::staged(None)
|
||||
} else {
|
||||
GitDecoration::clean()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_git_status(root: &Path) -> Result<HashMap<PathBuf, GitDecoration>> {
|
||||
if !root.join(".git").exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.arg("status")
|
||||
.arg("--porcelain")
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run git status in {}", root.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut chars = line.chars();
|
||||
let x = chars.next().unwrap_or(' ');
|
||||
let y = chars.next().unwrap_or(' ');
|
||||
if x == '!' || y == '!' {
|
||||
// ignored entry
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut path_part = line[3..].trim();
|
||||
if let Some(idx) = path_part.rfind(" -> ") {
|
||||
path_part = &path_part[idx + 4..];
|
||||
}
|
||||
|
||||
let path = PathBuf::from(path_part);
|
||||
|
||||
if let Some(decoration) = decode_git_status(x, y) {
|
||||
map.insert(path, decoration);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn current_git_branch(root: &Path) -> Result<Option<String>> {
|
||||
if !root.join(".git").exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.arg("rev-parse")
|
||||
.arg("--abbrev-ref")
|
||||
.arg("HEAD")
|
||||
.output()
|
||||
.with_context(|| format!("Failed to query git branch in {}", root.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if branch.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(branch))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_git_status(x: char, y: char) -> Option<GitDecoration> {
|
||||
if x == ' ' && y == ' ' {
|
||||
return Some(GitDecoration::clean());
|
||||
}
|
||||
|
||||
if x == '?' && y == '?' {
|
||||
return Some(GitDecoration::dirty(Some('A')));
|
||||
}
|
||||
|
||||
let badge = match (x, y) {
|
||||
('M', _) | (_, 'M') => Some('M'),
|
||||
('A', _) | (_, 'A') => Some('A'),
|
||||
('D', _) | (_, 'D') => Some('D'),
|
||||
('R', _) | (_, 'R') => Some('R'),
|
||||
('C', _) | (_, 'C') => Some('A'),
|
||||
('U', _) | (_, 'U') => Some('U'),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if y != ' ' {
|
||||
Some(GitDecoration::dirty(badge))
|
||||
} else if x != ' ' {
|
||||
Some(GitDecoration::staged(badge))
|
||||
} else {
|
||||
Some(GitDecoration::clean())
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,20 @@
|
||||
//! to test in isolation.
|
||||
|
||||
mod command_palette;
|
||||
mod file_tree;
|
||||
mod search;
|
||||
mod workspace;
|
||||
|
||||
pub use command_palette::{CommandPalette, ModelPaletteEntry};
|
||||
pub use file_tree::{
|
||||
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
||||
};
|
||||
pub use search::{
|
||||
RepoSearchFile, RepoSearchMatch, RepoSearchMessage, RepoSearchRow, RepoSearchRowKind,
|
||||
RepoSearchState, SymbolEntry, SymbolKind, SymbolSearchMessage, SymbolSearchState,
|
||||
spawn_repo_search_task, spawn_symbol_search_task,
|
||||
};
|
||||
pub use workspace::{
|
||||
CodePane, CodeWorkspace, EditorTab, LayoutNode, PaneDirection, PaneId, PaneRestoreRequest,
|
||||
SplitAxis, WorkspaceSnapshot,
|
||||
};
|
||||
|
||||
1056
crates/owlen-tui/src/state/search.rs
Normal file
1056
crates/owlen-tui/src/state/search.rs
Normal file
File diff suppressed because it is too large
Load Diff
883
crates/owlen-tui/src/state/workspace.rs
Normal file
883
crates/owlen-tui/src/state/workspace.rs
Normal file
@@ -0,0 +1,883 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use owlen_core::state::AutoScroll;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cardinal direction used for navigating between panes or resizing splits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaneDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ChildSide {
|
||||
First,
|
||||
Second,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PathEntry {
|
||||
axis: SplitAxis,
|
||||
side: ChildSide,
|
||||
}
|
||||
|
||||
/// Identifier assigned to each pane rendered inside a tab.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct PaneId(u64);
|
||||
|
||||
impl PaneId {
|
||||
fn next(counter: &mut u64) -> Self {
|
||||
*counter += 1;
|
||||
PaneId(*counter)
|
||||
}
|
||||
|
||||
pub fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_raw(raw: u64) -> Self {
|
||||
PaneId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifier used to refer to a tab within the workspace.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TabId(u64);
|
||||
|
||||
impl TabId {
|
||||
fn next(counter: &mut u64) -> Self {
|
||||
*counter += 1;
|
||||
TabId(*counter)
|
||||
}
|
||||
|
||||
pub fn raw(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn from_raw(raw: u64) -> Self {
|
||||
TabId(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction used when splitting a pane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SplitAxis {
|
||||
/// Split horizontally to create a pane below the current one.
|
||||
Horizontal,
|
||||
/// Split vertically to create a pane to the right of the current one.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Layout node describing either a leaf pane or a container split.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum LayoutNode {
|
||||
Leaf(PaneId),
|
||||
Split {
|
||||
axis: SplitAxis,
|
||||
ratio: f32,
|
||||
first: Box<LayoutNode>,
|
||||
second: Box<LayoutNode>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LayoutNode {
|
||||
fn replace_leaf(&mut self, target: PaneId, replacement: LayoutNode) -> bool {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => {
|
||||
if *id == target {
|
||||
*self = replacement;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
first.replace_leaf(target, replacement.clone())
|
||||
|| second.replace_leaf(target, replacement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter_leaves<'a>(&'a self, panes: &'a HashMap<PaneId, CodePane>) -> Vec<&'a CodePane> {
|
||||
let mut collected = Vec::new();
|
||||
self.collect_leaves(panes, &mut collected);
|
||||
collected
|
||||
}
|
||||
|
||||
fn collect_leaves<'a>(
|
||||
&'a self,
|
||||
panes: &'a HashMap<PaneId, CodePane>,
|
||||
output: &mut Vec<&'a CodePane>,
|
||||
) {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => {
|
||||
if let Some(pane) = panes.get(id) {
|
||||
output.push(pane);
|
||||
}
|
||||
}
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
first.collect_leaves(panes, output);
|
||||
second.collect_leaves(panes, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to(&self, target: PaneId) -> Option<Vec<PathEntry>> {
|
||||
let mut path = Vec::new();
|
||||
if self.path_to_inner(target, &mut path) {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_inner(&self, target: PaneId, path: &mut Vec<PathEntry>) -> bool {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => *id == target,
|
||||
LayoutNode::Split {
|
||||
axis,
|
||||
first,
|
||||
second,
|
||||
..
|
||||
} => {
|
||||
path.push(PathEntry {
|
||||
axis: *axis,
|
||||
side: ChildSide::First,
|
||||
});
|
||||
if first.path_to_inner(target, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
path.push(PathEntry {
|
||||
axis: *axis,
|
||||
side: ChildSide::Second,
|
||||
});
|
||||
if second.path_to_inner(target, path) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subtree(&self, path: &[PathEntry]) -> Option<&LayoutNode> {
|
||||
let mut node = self;
|
||||
for entry in path {
|
||||
match node {
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
node = match entry.side {
|
||||
ChildSide::First => first.as_ref(),
|
||||
ChildSide::Second => second.as_ref(),
|
||||
};
|
||||
}
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
fn subtree_mut(&mut self, path: &[PathEntry]) -> Option<&mut LayoutNode> {
|
||||
let mut node = self;
|
||||
for entry in path {
|
||||
match node {
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
node = match entry.side {
|
||||
ChildSide::First => first.as_mut(),
|
||||
ChildSide::Second => second.as_mut(),
|
||||
};
|
||||
}
|
||||
LayoutNode::Leaf(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(node)
|
||||
}
|
||||
|
||||
fn extreme_leaf(&self, prefer_second: bool) -> Option<PaneId> {
|
||||
match self {
|
||||
LayoutNode::Leaf(id) => Some(*id),
|
||||
LayoutNode::Split { first, second, .. } => {
|
||||
if prefer_second {
|
||||
second
|
||||
.extreme_leaf(prefer_second)
|
||||
.or_else(|| first.extreme_leaf(prefer_second))
|
||||
} else {
|
||||
first
|
||||
.extreme_leaf(prefer_second)
|
||||
.or_else(|| second.extreme_leaf(prefer_second))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderable pane that holds file contents and scroll state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodePane {
|
||||
pub id: PaneId,
|
||||
pub absolute_path: Option<PathBuf>,
|
||||
pub display_path: Option<String>,
|
||||
pub title: String,
|
||||
pub lines: Vec<String>,
|
||||
pub scroll: AutoScroll,
|
||||
pub viewport_height: usize,
|
||||
pub is_dirty: bool,
|
||||
pub is_staged: bool,
|
||||
}
|
||||
|
||||
impl CodePane {
|
||||
pub fn new(id: PaneId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
absolute_path: None,
|
||||
display_path: None,
|
||||
title: "Untitled".to_string(),
|
||||
lines: Vec::new(),
|
||||
scroll: AutoScroll::default(),
|
||||
viewport_height: 0,
|
||||
is_dirty: false,
|
||||
is_staged: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_contents(
|
||||
&mut self,
|
||||
absolute_path: Option<PathBuf>,
|
||||
display_path: Option<String>,
|
||||
lines: Vec<String>,
|
||||
) {
|
||||
self.absolute_path = absolute_path;
|
||||
self.display_path = display_path;
|
||||
self.title = self
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or_else(|| self.display_path.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
self.lines = lines;
|
||||
self.scroll = AutoScroll::default();
|
||||
self.scroll.content_len = self.lines.len();
|
||||
self.scroll.stick_to_bottom = false;
|
||||
self.scroll.scroll = 0;
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.absolute_path = None;
|
||||
self.display_path = None;
|
||||
self.title = "Untitled".to_string();
|
||||
self.lines.clear();
|
||||
self.scroll = AutoScroll::default();
|
||||
self.viewport_height = 0;
|
||||
self.is_dirty = false;
|
||||
self.is_staged = false;
|
||||
}
|
||||
|
||||
pub fn set_viewport_height(&mut self, height: usize) {
|
||||
self.viewport_height = height;
|
||||
}
|
||||
|
||||
pub fn display_path(&self) -> Option<&str> {
|
||||
self.display_path.as_deref()
|
||||
}
|
||||
|
||||
pub fn absolute_path(&self) -> Option<&Path> {
|
||||
self.absolute_path.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual tab containing a layout tree and panes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorTab {
|
||||
pub id: TabId,
|
||||
pub title: String,
|
||||
pub root: LayoutNode,
|
||||
pub panes: HashMap<PaneId, CodePane>,
|
||||
pub active: PaneId,
|
||||
}
|
||||
|
||||
impl EditorTab {
|
||||
fn new(id: TabId, title: String, pane: CodePane) -> Self {
|
||||
let active = pane.id;
|
||||
let mut panes = HashMap::new();
|
||||
panes.insert(pane.id, pane);
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
root: LayoutNode::Leaf(active),
|
||||
panes,
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_pane(&self) -> Option<&CodePane> {
|
||||
self.panes.get(&self.active)
|
||||
}
|
||||
|
||||
pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> {
|
||||
self.panes.get_mut(&self.active)
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, pane: PaneId) {
|
||||
if self.panes.contains_key(&pane) {
|
||||
self.active = pane;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_title_from_active(&mut self) {
|
||||
if let Some(pane) = self.active_pane() {
|
||||
self.title = pane
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or_else(|| pane.display_path.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn active_path(&self) -> Option<Vec<PathEntry>> {
|
||||
self.root.path_to(self.active)
|
||||
}
|
||||
|
||||
pub fn move_focus(&mut self, direction: PaneDirection) -> bool {
|
||||
let path = match self.active_path() {
|
||||
Some(path) => path,
|
||||
None => return false,
|
||||
};
|
||||
let axis = match direction {
|
||||
PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical,
|
||||
PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal,
|
||||
};
|
||||
|
||||
for (idx, entry) in path.iter().enumerate().rev() {
|
||||
if entry.axis != axis {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (required_side, target_side, prefer_second) = match direction {
|
||||
PaneDirection::Left => (ChildSide::Second, ChildSide::First, true),
|
||||
PaneDirection::Right => (ChildSide::First, ChildSide::Second, false),
|
||||
PaneDirection::Up => (ChildSide::Second, ChildSide::First, true),
|
||||
PaneDirection::Down => (ChildSide::First, ChildSide::Second, false),
|
||||
};
|
||||
|
||||
if entry.side != required_side {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent_path = &path[..idx];
|
||||
let Some(parent) = self.root.subtree(parent_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let LayoutNode::Split { first, second, .. } = parent {
|
||||
let target = match target_side {
|
||||
ChildSide::First => first.as_ref(),
|
||||
ChildSide::Second => second.as_ref(),
|
||||
};
|
||||
if let Some(pane_id) = target.extreme_leaf(prefer_second)
|
||||
&& self.panes.contains_key(&pane_id)
|
||||
{
|
||||
self.active = pane_id;
|
||||
self.update_title_from_active();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option<f32> {
|
||||
let path = self.active_path()?;
|
||||
|
||||
let axis = match direction {
|
||||
PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical,
|
||||
PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal,
|
||||
};
|
||||
|
||||
let (idx, entry) = path
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, entry)| entry.axis == axis)?;
|
||||
|
||||
let parent_path = &path[..idx];
|
||||
let parent = self.root.subtree_mut(parent_path)?;
|
||||
|
||||
let LayoutNode::Split { ratio, .. } = parent else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let sign = match direction {
|
||||
PaneDirection::Left => {
|
||||
if entry.side == ChildSide::First {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
PaneDirection::Right => {
|
||||
if entry.side == ChildSide::First {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
PaneDirection::Up => {
|
||||
if entry.side == ChildSide::First {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
PaneDirection::Down => {
|
||||
if entry.side == ChildSide::First {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut new_ratio = (*ratio + amount * sign).clamp(0.1, 0.9);
|
||||
if (new_ratio - *ratio).abs() < f32::EPSILON {
|
||||
return Some(self.active_share_from(entry.side, new_ratio));
|
||||
}
|
||||
*ratio = new_ratio;
|
||||
new_ratio = new_ratio.clamp(0.1, 0.9);
|
||||
Some(self.active_share_from(entry.side, new_ratio))
|
||||
}
|
||||
|
||||
pub fn snap_active_share(
|
||||
&mut self,
|
||||
direction: PaneDirection,
|
||||
desired_share: f32,
|
||||
) -> Option<f32> {
|
||||
let path = self.active_path()?;
|
||||
|
||||
let axis = match direction {
|
||||
PaneDirection::Left | PaneDirection::Right => SplitAxis::Vertical,
|
||||
PaneDirection::Up | PaneDirection::Down => SplitAxis::Horizontal,
|
||||
};
|
||||
|
||||
let (idx, entry) = path
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, entry)| entry.axis == axis)?;
|
||||
|
||||
let parent_path = &path[..idx];
|
||||
let parent = self.root.subtree_mut(parent_path)?;
|
||||
|
||||
let LayoutNode::Split { ratio, .. } = parent else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut target_ratio = match entry.side {
|
||||
ChildSide::First => desired_share,
|
||||
ChildSide::Second => 1.0 - desired_share,
|
||||
}
|
||||
.clamp(0.1, 0.9);
|
||||
|
||||
if (target_ratio - *ratio).abs() < f32::EPSILON {
|
||||
return Some(self.active_share_from(entry.side, target_ratio));
|
||||
}
|
||||
|
||||
*ratio = target_ratio;
|
||||
target_ratio = target_ratio.clamp(0.1, 0.9);
|
||||
Some(self.active_share_from(entry.side, target_ratio))
|
||||
}
|
||||
|
||||
pub fn active_share(&self) -> Option<f32> {
|
||||
let path = self.active_path()?;
|
||||
let (idx, entry) =
|
||||
path.iter().enumerate().rev().find(|(_, entry)| {
|
||||
matches!(entry.axis, SplitAxis::Horizontal | SplitAxis::Vertical)
|
||||
})?;
|
||||
let parent_path = &path[..idx];
|
||||
let parent = self.root.subtree(parent_path)?;
|
||||
if let LayoutNode::Split { ratio, .. } = parent {
|
||||
Some(self.active_share_from(entry.side, *ratio))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn active_share_from(&self, side: ChildSide, ratio: f32) -> f32 {
|
||||
match side {
|
||||
ChildSide::First => ratio,
|
||||
ChildSide::Second => 1.0 - ratio,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level workspace managing tabs and panes for the code viewer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodeWorkspace {
|
||||
tabs: Vec<EditorTab>,
|
||||
active_tab: usize,
|
||||
next_tab_id: u64,
|
||||
next_pane_id: u64,
|
||||
}
|
||||
|
||||
const WORKSPACE_SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSnapshot {
|
||||
version: u32,
|
||||
active_tab: usize,
|
||||
next_tab_id: u64,
|
||||
next_pane_id: u64,
|
||||
tabs: Vec<TabSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TabSnapshot {
|
||||
id: u64,
|
||||
title: String,
|
||||
active: u64,
|
||||
root: LayoutNode,
|
||||
panes: Vec<PaneSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct PaneSnapshot {
|
||||
id: u64,
|
||||
absolute_path: Option<String>,
|
||||
display_path: Option<String>,
|
||||
is_dirty: bool,
|
||||
is_staged: bool,
|
||||
scroll: ScrollSnapshot,
|
||||
viewport_height: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScrollSnapshot {
|
||||
pub scroll: usize,
|
||||
pub stick_to_bottom: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaneRestoreRequest {
|
||||
pub pane_id: PaneId,
|
||||
pub absolute_path: Option<PathBuf>,
|
||||
pub display_path: Option<String>,
|
||||
pub scroll: ScrollSnapshot,
|
||||
}
|
||||
|
||||
impl Default for CodeWorkspace {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeWorkspace {
|
||||
pub fn new() -> Self {
|
||||
let mut next_tab_id = 0;
|
||||
let mut next_pane_id = 0;
|
||||
let pane_id = PaneId::next(&mut next_pane_id);
|
||||
let first_pane = CodePane::new(pane_id);
|
||||
let tab_id = TabId::next(&mut next_tab_id);
|
||||
let title = format!("Tab {}", tab_id.0);
|
||||
let first_tab = EditorTab::new(tab_id, title, first_pane);
|
||||
Self {
|
||||
tabs: vec![first_tab],
|
||||
active_tab: 0,
|
||||
next_tab_id,
|
||||
next_pane_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tabs(&self) -> &[EditorTab] {
|
||||
&self.tabs
|
||||
}
|
||||
|
||||
pub fn tabs_mut(&mut self) -> &mut [EditorTab] {
|
||||
&mut self.tabs
|
||||
}
|
||||
|
||||
pub fn active_tab_index(&self) -> usize {
|
||||
self.active_tab.min(self.tabs.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
pub fn active_tab(&self) -> Option<&EditorTab> {
|
||||
self.tabs.get(self.active_tab_index())
|
||||
}
|
||||
|
||||
pub fn active_tab_mut(&mut self) -> Option<&mut EditorTab> {
|
||||
let idx = self.active_tab_index();
|
||||
self.tabs.get_mut(idx)
|
||||
}
|
||||
|
||||
pub fn active_pane(&self) -> Option<&CodePane> {
|
||||
self.active_tab().and_then(|tab| tab.active_pane())
|
||||
}
|
||||
|
||||
pub fn active_pane_mut(&mut self) -> Option<&mut CodePane> {
|
||||
self.active_tab_mut().and_then(|tab| tab.active_pane_mut())
|
||||
}
|
||||
|
||||
pub fn set_active_tab(&mut self, index: usize) {
|
||||
if index < self.tabs.len() {
|
||||
self.active_tab = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_tab(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
let mut next_tab_id = self.next_tab_id;
|
||||
let mut next_pane_id = self.next_pane_id;
|
||||
let pane_id = PaneId::next(&mut next_pane_id);
|
||||
let pane = CodePane::new(pane_id);
|
||||
let tab_id = TabId::next(&mut next_tab_id);
|
||||
let title = format!("Tab {}", tab_id.0);
|
||||
let tab = EditorTab::new(tab_id, title, pane);
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = 0;
|
||||
self.next_tab_id = next_tab_id;
|
||||
self.next_pane_id = next_pane_id;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_active_contents(
|
||||
&mut self,
|
||||
absolute: Option<PathBuf>,
|
||||
display: Option<String>,
|
||||
lines: Vec<String>,
|
||||
) {
|
||||
self.ensure_tab();
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
if let Some(pane) = tab.active_pane_mut() {
|
||||
pane.set_contents(absolute, display, lines);
|
||||
}
|
||||
tab.update_title_from_active();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_active_pane(&mut self) {
|
||||
if let Some(tab) = self.active_tab_mut() {
|
||||
if let Some(pane) = tab.active_pane_mut() {
|
||||
pane.clear();
|
||||
}
|
||||
tab.update_title_from_active();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_active_viewport_height(&mut self, height: usize) {
|
||||
if let Some(pane) = self.active_pane_mut() {
|
||||
pane.set_viewport_height(height);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_pane_id(&self) -> Option<PaneId> {
|
||||
self.active_tab().map(|tab| tab.active)
|
||||
}
|
||||
|
||||
pub fn split_active(&mut self, axis: SplitAxis) -> Option<PaneId> {
|
||||
self.ensure_tab();
|
||||
let active_id = self.active_tab()?.active;
|
||||
let new_pane_id = PaneId::next(&mut self.next_pane_id);
|
||||
let replacement = LayoutNode::Split {
|
||||
axis,
|
||||
ratio: 0.5,
|
||||
first: Box::new(LayoutNode::Leaf(active_id)),
|
||||
second: Box::new(LayoutNode::Leaf(new_pane_id)),
|
||||
};
|
||||
|
||||
self.active_tab_mut().and_then(|tab| {
|
||||
if tab.root.replace_leaf(active_id, replacement) {
|
||||
tab.panes.insert(new_pane_id, CodePane::new(new_pane_id));
|
||||
tab.active = new_pane_id;
|
||||
Some(new_pane_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_new_tab(&mut self) -> PaneId {
|
||||
let pane_id = PaneId::next(&mut self.next_pane_id);
|
||||
let pane = CodePane::new(pane_id);
|
||||
let tab_id = TabId::next(&mut self.next_tab_id);
|
||||
let title = format!("Tab {}", tab_id.0);
|
||||
let tab = EditorTab::new(tab_id, title, pane);
|
||||
self.tabs.push(tab);
|
||||
self.active_tab = self.tabs.len().saturating_sub(1);
|
||||
pane_id
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> WorkspaceSnapshot {
|
||||
let tabs = self
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
let panes = tab
|
||||
.panes
|
||||
.values()
|
||||
.map(|pane| PaneSnapshot {
|
||||
id: pane.id.raw(),
|
||||
absolute_path: pane
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_string_lossy().into_owned()),
|
||||
display_path: pane.display_path.clone(),
|
||||
is_dirty: pane.is_dirty,
|
||||
is_staged: pane.is_staged,
|
||||
scroll: ScrollSnapshot {
|
||||
scroll: pane.scroll.scroll,
|
||||
stick_to_bottom: pane.scroll.stick_to_bottom,
|
||||
},
|
||||
viewport_height: pane.viewport_height,
|
||||
})
|
||||
.collect();
|
||||
|
||||
TabSnapshot {
|
||||
id: tab.id.raw(),
|
||||
title: tab.title.clone(),
|
||||
active: tab.active.raw(),
|
||||
root: tab.root.clone(),
|
||||
panes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
WorkspaceSnapshot {
|
||||
version: WORKSPACE_SNAPSHOT_VERSION,
|
||||
active_tab: self.active_tab_index(),
|
||||
next_tab_id: self.next_tab_id,
|
||||
next_pane_id: self.next_pane_id,
|
||||
tabs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_snapshot(&mut self, snapshot: WorkspaceSnapshot) -> Vec<PaneRestoreRequest> {
|
||||
if snapshot.version != WORKSPACE_SNAPSHOT_VERSION {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut restore_requests = Vec::new();
|
||||
let mut tabs = Vec::new();
|
||||
|
||||
for tab_snapshot in snapshot.tabs {
|
||||
let mut panes = HashMap::new();
|
||||
for pane_snapshot in tab_snapshot.panes {
|
||||
let pane_id = PaneId::from_raw(pane_snapshot.id);
|
||||
let mut pane = CodePane::new(pane_id);
|
||||
pane.absolute_path = pane_snapshot.absolute_path.as_ref().map(PathBuf::from);
|
||||
pane.display_path = pane_snapshot.display_path.clone();
|
||||
pane.is_dirty = pane_snapshot.is_dirty;
|
||||
pane.is_staged = pane_snapshot.is_staged;
|
||||
pane.scroll.scroll = pane_snapshot.scroll.scroll;
|
||||
pane.scroll.stick_to_bottom = pane_snapshot.scroll.stick_to_bottom;
|
||||
pane.viewport_height = pane_snapshot.viewport_height;
|
||||
pane.scroll.content_len = pane.lines.len();
|
||||
pane.title = pane
|
||||
.absolute_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
|
||||
.or_else(|| pane.display_path.clone())
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
panes.insert(pane_id, pane);
|
||||
|
||||
if pane_snapshot.absolute_path.is_some() {
|
||||
restore_requests.push(PaneRestoreRequest {
|
||||
pane_id,
|
||||
absolute_path: pane_snapshot.absolute_path.map(PathBuf::from),
|
||||
display_path: pane_snapshot.display_path.clone(),
|
||||
scroll: pane_snapshot.scroll.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if panes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tab_id = TabId::from_raw(tab_snapshot.id);
|
||||
let mut tab = EditorTab {
|
||||
id: tab_id,
|
||||
title: tab_snapshot.title,
|
||||
root: tab_snapshot.root,
|
||||
panes,
|
||||
active: PaneId::from_raw(tab_snapshot.active),
|
||||
};
|
||||
tab.update_title_from_active();
|
||||
tabs.push(tab);
|
||||
}
|
||||
|
||||
if tabs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.tabs = tabs;
|
||||
self.active_tab = snapshot.active_tab.min(self.tabs.len().saturating_sub(1));
|
||||
self.next_tab_id = snapshot.next_tab_id;
|
||||
self.next_pane_id = snapshot.next_pane_id;
|
||||
|
||||
restore_requests
|
||||
}
|
||||
|
||||
pub fn move_focus(&mut self, direction: PaneDirection) -> bool {
|
||||
let active_index = self.active_tab_index();
|
||||
if let Some(tab) = self.tabs.get_mut(active_index) {
|
||||
tab.move_focus(direction)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize_active_step(&mut self, direction: PaneDirection, amount: f32) -> Option<f32> {
|
||||
let active_index = self.active_tab_index();
|
||||
self.tabs
|
||||
.get_mut(active_index)
|
||||
.and_then(|tab| tab.resize_active_step(direction, amount))
|
||||
}
|
||||
|
||||
pub fn snap_active_share(
|
||||
&mut self,
|
||||
direction: PaneDirection,
|
||||
desired_share: f32,
|
||||
) -> Option<f32> {
|
||||
let active_index = self.active_tab_index();
|
||||
self.tabs
|
||||
.get_mut(active_index)
|
||||
.and_then(|tab| tab.snap_active_share(direction, desired_share))
|
||||
}
|
||||
|
||||
pub fn active_share(&self) -> Option<f32> {
|
||||
self.active_tab().and_then(|tab| tab.active_share())
|
||||
}
|
||||
|
||||
pub fn set_pane_contents(
|
||||
&mut self,
|
||||
pane_id: PaneId,
|
||||
absolute: Option<PathBuf>,
|
||||
display: Option<String>,
|
||||
lines: Vec<String>,
|
||||
) -> bool {
|
||||
for tab in &mut self.tabs {
|
||||
if let Some(pane) = tab.panes.get_mut(&pane_id) {
|
||||
pane.set_contents(absolute, display, lines);
|
||||
tab.update_title_from_active();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn restore_scroll(&mut self, pane_id: PaneId, snapshot: &ScrollSnapshot) -> bool {
|
||||
for tab in &mut self.tabs {
|
||||
if let Some(pane) = tab.panes.get_mut(&pane_id) {
|
||||
pane.scroll.scroll = snapshot.scroll;
|
||||
pane.scroll.stick_to_bottom = snapshot.stick_to_bottom;
|
||||
pane.scroll.content_len = pane.lines.len();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user