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:
2025-10-12 20:18:25 +02:00
parent 55e6b0583d
commit 33ad3797a1
8 changed files with 5876 additions and 193 deletions

View File

@@ -21,6 +21,8 @@ pub enum InputMode {
Command,
SessionBrowser,
ThemeBrowser,
RepoSearch,
SymbolSearch,
}
impl fmt::Display for InputMode {
@@ -35,6 +37,8 @@ impl fmt::Display for InputMode {
InputMode::Command => "Command",
InputMode::SessionBrowser => "Sessions",
InputMode::ThemeBrowser => "Themes",
InputMode::RepoSearch => "Search",
InputMode::SymbolSearch => "Symbols",
};
f.write_str(label)
}
@@ -43,6 +47,7 @@ impl fmt::Display for InputMode {
/// Represents which panel is currently focused in the TUI layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPanel {
Files,
Chat,
Thinking,
Input,

View File

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

View File

@@ -0,0 +1,705 @@
use crate::commands;
use anyhow::{Context, Result};
use globset::{Glob, GlobBuilder, GlobSetBuilder};
use ignore::WalkBuilder;
use pathdiff::diff_paths;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Indicates which matching strategy is applied when filtering the file tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterMode {
Glob,
Fuzzy,
}
/// Git-related decorations rendered alongside a file entry.
#[derive(Debug, Clone)]
pub struct GitDecoration {
pub badge: Option<char>,
pub cleanliness: char,
}
impl GitDecoration {
pub fn clean() -> Self {
Self {
badge: None,
cleanliness: '',
}
}
pub fn staged(badge: Option<char>) -> Self {
Self {
badge,
cleanliness: '',
}
}
pub fn dirty(badge: Option<char>) -> Self {
Self {
badge,
cleanliness: '',
}
}
}
/// Node representing a single entry (file or directory) in the tree.
#[derive(Debug, Clone)]
pub struct FileNode {
pub name: String,
pub path: PathBuf,
pub parent: Option<usize>,
pub children: Vec<usize>,
pub depth: usize,
pub is_dir: bool,
pub is_expanded: bool,
pub is_hidden: bool,
pub git: GitDecoration,
}
impl FileNode {
fn should_default_expand(&self) -> bool {
self.depth < 2
}
}
/// Visible entry metadata returned to the renderer.
#[derive(Debug, Clone)]
pub struct VisibleFileEntry {
pub index: usize,
pub depth: usize,
}
/// Tracks the entire file tree state including filters, selection, and scroll.
#[derive(Debug, Clone)]
pub struct FileTreeState {
root: PathBuf,
repo_name: String,
nodes: Vec<FileNode>,
visible: Vec<VisibleFileEntry>,
cursor: usize,
scroll_top: usize,
viewport_height: usize,
filter_mode: FilterMode,
filter_query: String,
show_hidden: bool,
filter_matches: Vec<bool>,
last_error: Option<String>,
git_branch: Option<String>,
}
impl FileTreeState {
/// Construct a new file tree rooted at the provided path.
pub fn new(root: impl Into<PathBuf>) -> Self {
let mut root_path = root.into();
if let Ok(canonical) = root_path.canonicalize() {
root_path = canonical;
}
let repo_name = root_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| root_path.display().to_string());
let mut state = Self {
root: root_path,
repo_name,
nodes: Vec::new(),
visible: Vec::new(),
cursor: 0,
scroll_top: 0,
viewport_height: 20,
filter_mode: FilterMode::Fuzzy,
filter_query: String::new(),
show_hidden: false,
filter_matches: Vec::new(),
last_error: None,
git_branch: None,
};
if let Err(err) = state.refresh() {
state.nodes.clear();
state.visible.clear();
state.filter_matches.clear();
state.last_error = Some(err.to_string());
}
state
}
/// Rebuild the file tree from disk and recompute visibility.
pub fn refresh(&mut self) -> Result<()> {
let git_map = collect_git_status(&self.root).unwrap_or_default();
self.nodes = build_nodes(&self.root, self.show_hidden, git_map)?;
self.git_branch = current_git_branch(&self.root).unwrap_or(None);
if self.nodes.is_empty() {
self.visible.clear();
self.filter_matches.clear();
self.cursor = 0;
return Ok(());
}
self.ensure_valid_cursor();
self.recompute_filter_cache();
self.rebuild_visible();
Ok(())
}
pub fn repo_name(&self) -> &str {
&self.repo_name
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn is_empty(&self) -> bool {
self.visible.is_empty()
}
pub fn visible_entries(&self) -> &[VisibleFileEntry] {
&self.visible
}
pub fn nodes(&self) -> &[FileNode] {
&self.nodes
}
pub fn selected_index(&self) -> Option<usize> {
self.visible.get(self.cursor).map(|entry| entry.index)
}
pub fn selected_node(&self) -> Option<&FileNode> {
self.selected_index().and_then(|idx| self.nodes.get(idx))
}
pub fn selected_node_mut(&mut self) -> Option<&mut FileNode> {
let idx = self.selected_index()?;
self.nodes.get_mut(idx)
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn scroll_top(&self) -> usize {
self.scroll_top
}
pub fn viewport_height(&self) -> usize {
self.viewport_height
}
pub fn filter_mode(&self) -> FilterMode {
self.filter_mode
}
pub fn filter_query(&self) -> &str {
&self.filter_query
}
pub fn show_hidden(&self) -> bool {
self.show_hidden
}
pub fn git_branch(&self) -> Option<&str> {
self.git_branch.as_deref()
}
pub fn last_error(&self) -> Option<&str> {
self.last_error.as_deref()
}
pub fn set_viewport_height(&mut self, height: usize) {
self.viewport_height = height.max(1);
self.ensure_cursor_in_view();
}
pub fn move_cursor(&mut self, delta: isize) {
if self.visible.is_empty() {
self.cursor = 0;
self.scroll_top = 0;
return;
}
let len = self.visible.len() as isize;
let new_cursor = (self.cursor as isize + delta).clamp(0, len - 1) as usize;
self.cursor = new_cursor;
self.ensure_cursor_in_view();
}
pub fn jump_to_top(&mut self) {
if !self.visible.is_empty() {
self.cursor = 0;
self.scroll_top = 0;
}
}
pub fn jump_to_bottom(&mut self) {
if !self.visible.is_empty() {
self.cursor = self.visible.len().saturating_sub(1);
let viewport = self.viewport_height.max(1);
self.scroll_top = self.visible.len().saturating_sub(viewport);
}
}
pub fn page_down(&mut self) {
let amount = self.viewport_height.max(1) as isize;
self.move_cursor(amount);
}
pub fn page_up(&mut self) {
let amount = -(self.viewport_height.max(1) as isize);
self.move_cursor(amount);
}
pub fn toggle_expand(&mut self) {
if let Some(node) = self.selected_node_mut() {
if !node.is_dir {
return;
}
node.is_expanded = !node.is_expanded;
self.rebuild_visible();
}
}
pub fn set_filter_query(&mut self, query: impl Into<String>) {
self.filter_query = query.into();
self.recompute_filter_cache();
self.rebuild_visible();
}
pub fn clear_filter(&mut self) {
self.filter_query.clear();
self.recompute_filter_cache();
self.rebuild_visible();
}
pub fn toggle_filter_mode(&mut self) {
self.filter_mode = match self.filter_mode {
FilterMode::Glob => FilterMode::Fuzzy,
FilterMode::Fuzzy => FilterMode::Glob,
};
self.recompute_filter_cache();
self.rebuild_visible();
}
pub fn toggle_hidden(&mut self) -> Result<()> {
self.show_hidden = !self.show_hidden;
self.refresh()
}
/// Expand directories along the provided path and position the cursor.
pub fn reveal(&mut self, path: &Path) {
if self.nodes.is_empty() {
return;
}
if let Some(rel) = diff_paths(path, &self.root)
&& let Some(index) = self
.nodes
.iter()
.position(|node| node.path == rel || node.path == path)
{
self.expand_to(index);
if let Some(cursor_pos) = self.visible.iter().position(|entry| entry.index == index) {
self.cursor = cursor_pos;
self.ensure_cursor_in_view();
}
}
}
fn expand_to(&mut self, index: usize) {
let mut current = Some(index);
while let Some(idx) = current {
if let Some(parent) = self.nodes.get(idx).and_then(|node| node.parent) {
if let Some(parent_node) = self.nodes.get_mut(parent) {
parent_node.is_expanded = true;
}
current = Some(parent);
} else {
current = None;
}
}
self.rebuild_visible();
}
fn ensure_valid_cursor(&mut self) {
if self.cursor >= self.visible.len() {
self.cursor = self.visible.len().saturating_sub(1);
}
}
fn ensure_cursor_in_view(&mut self) {
if self.visible.is_empty() {
self.cursor = 0;
self.scroll_top = 0;
return;
}
let viewport = self.viewport_height.max(1);
if self.cursor < self.scroll_top {
self.scroll_top = self.cursor;
} else if self.cursor >= self.scroll_top + viewport {
self.scroll_top = self.cursor + 1 - viewport;
}
}
fn recompute_filter_cache(&mut self) {
let has_filter = !self.filter_query.trim().is_empty();
self.filter_matches = if !has_filter {
vec![true; self.nodes.len()]
} else {
self.nodes
.iter()
.map(|node| match self.filter_mode {
FilterMode::Glob => glob_matches(self.filter_query.trim(), node),
FilterMode::Fuzzy => fuzzy_matches(self.filter_query.trim(), node),
})
.collect()
};
if has_filter {
// Ensure parent directories of matches are preserved.
for idx in (0..self.nodes.len()).rev() {
let children = self.nodes[idx].children.clone();
if !self.filter_matches[idx]
&& children
.iter()
.any(|child| self.filter_matches.get(*child).copied().unwrap_or(false))
{
self.filter_matches[idx] = true;
}
}
}
}
fn rebuild_visible(&mut self) {
self.visible.clear();
if self.nodes.is_empty() {
self.cursor = 0;
self.scroll_top = 0;
return;
}
let has_filter = !self.filter_query.trim().is_empty();
self.walk_visible(0, has_filter);
if self.visible.is_empty() {
// At minimum show the root node.
self.visible.push(VisibleFileEntry {
index: 0,
depth: self.nodes[0].depth,
});
}
let max_index = self.visible.len().saturating_sub(1);
self.cursor = self.cursor.min(max_index);
self.ensure_cursor_in_view();
}
fn walk_visible(&mut self, index: usize, filter_override: bool) {
if !self.filter_matches.get(index).copied().unwrap_or(true) {
return;
}
let (depth, descend, children) = {
let node = match self.nodes.get(index) {
Some(node) => node,
None => return,
};
let descend = if filter_override {
node.is_dir
} else {
node.is_dir && node.is_expanded
};
let children = if node.is_dir {
node.children.clone()
} else {
Vec::new()
};
(node.depth, descend, children)
};
self.visible.push(VisibleFileEntry { index, depth });
if descend {
for child in children {
self.walk_visible(child, filter_override);
}
}
}
}
fn glob_matches(pattern: &str, node: &FileNode) -> bool {
if pattern.is_empty() {
return true;
}
let mut builder = GlobSetBuilder::new();
match GlobBuilder::new(pattern).literal_separator(true).build() {
Ok(glob) => {
builder.add(glob);
if let Ok(set) = builder.build() {
return set.is_match(&node.path) || set.is_match(node.name.as_str());
}
}
Err(_) => {
if let Ok(glob) = Glob::new("**") {
builder.add(glob);
if let Ok(set) = builder.build() {
return set.is_match(&node.path);
}
}
}
}
false
}
fn fuzzy_matches(query: &str, node: &FileNode) -> bool {
if query.is_empty() {
return true;
}
let path_str = node.path.to_string_lossy();
let name = node.name.as_str();
commands::match_score(&path_str, query)
.or_else(|| commands::match_score(name, query))
.is_some()
}
fn build_nodes(
root: &Path,
show_hidden: bool,
git_map: HashMap<PathBuf, GitDecoration>,
) -> Result<Vec<FileNode>> {
let mut builder = WalkBuilder::new(root);
builder.hidden(!show_hidden);
builder.git_global(true);
builder.git_ignore(true);
builder.git_exclude(true);
builder.follow_links(false);
builder.sort_by_file_path(|a, b| a.file_name().cmp(&b.file_name()));
let owlen_ignore = root.join(".owlenignore");
if owlen_ignore.exists() {
builder.add_ignore(&owlen_ignore);
}
let mut nodes: Vec<FileNode> = Vec::new();
let mut index_by_path: HashMap<PathBuf, usize> = HashMap::new();
for result in builder.build() {
let entry = match result {
Ok(value) => value,
Err(err) => {
eprintln!("File tree walk error: {err}");
continue;
}
};
// Skip errors or entries without metadata.
let file_type = match entry.file_type() {
Some(ft) => ft,
None => continue,
};
let depth = entry.depth();
if depth == 0 && !file_type.is_dir() {
continue;
}
let relative = if depth == 0 {
PathBuf::new()
} else {
diff_paths(entry.path(), root).unwrap_or_else(|| entry.path().to_path_buf())
};
let name = if depth == 0 {
root.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| root.display().to_string())
} else {
entry.file_name().to_string_lossy().into_owned()
};
let parent = if depth == 0 {
None
} else {
entry
.path()
.parent()
.and_then(|parent| diff_paths(parent, root))
.and_then(|rel_parent| index_by_path.get(&rel_parent).copied())
};
let git = git_map
.get(&relative)
.cloned()
.unwrap_or_else(GitDecoration::clean);
let mut node = FileNode {
name,
path: relative.clone(),
parent,
children: Vec::new(),
depth,
is_dir: file_type.is_dir(),
is_expanded: false,
is_hidden: is_hidden(entry.file_name()),
git,
};
node.is_expanded = node.should_default_expand();
let index = nodes.len();
if let Some(parent_idx) = parent
&& let Some(parent_node) = nodes.get_mut(parent_idx)
{
parent_node.children.push(index);
}
index_by_path.insert(relative, index);
nodes.push(node);
}
propagate_directory_git_state(&mut nodes);
Ok(nodes)
}
fn is_hidden(name: &OsStr) -> bool {
name.to_string_lossy().starts_with('.')
}
fn propagate_directory_git_state(nodes: &mut [FileNode]) {
for idx in (0..nodes.len()).rev() {
if !nodes[idx].is_dir {
continue;
}
let mut has_dirty = false;
let mut has_staged = false;
for child in nodes[idx].children.clone() {
match nodes.get(child).map(|n| n.git.cleanliness) {
Some('●') => {
has_dirty = true;
break;
}
Some('○') => {
has_staged = true;
}
_ => {}
}
}
nodes[idx].git = if has_dirty {
GitDecoration::dirty(None)
} else if has_staged {
GitDecoration::staged(None)
} else {
GitDecoration::clean()
};
}
}
fn collect_git_status(root: &Path) -> Result<HashMap<PathBuf, GitDecoration>> {
if !root.join(".git").exists() {
return Ok(HashMap::new());
}
let output = Command::new("git")
.arg("-C")
.arg(root)
.arg("status")
.arg("--porcelain")
.output()
.with_context(|| format!("Failed to run git status in {}", root.display()))?;
if !output.status.success() {
return Ok(HashMap::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for line in stdout.lines() {
if line.len() < 3 {
continue;
}
let mut chars = line.chars();
let x = chars.next().unwrap_or(' ');
let y = chars.next().unwrap_or(' ');
if x == '!' || y == '!' {
// ignored entry
continue;
}
let mut path_part = line[3..].trim();
if let Some(idx) = path_part.rfind(" -> ") {
path_part = &path_part[idx + 4..];
}
let path = PathBuf::from(path_part);
if let Some(decoration) = decode_git_status(x, y) {
map.insert(path, decoration);
}
}
Ok(map)
}
fn current_git_branch(root: &Path) -> Result<Option<String>> {
if !root.join(".git").exists() {
return Ok(None);
}
let output = Command::new("git")
.arg("-C")
.arg(root)
.arg("rev-parse")
.arg("--abbrev-ref")
.arg("HEAD")
.output()
.with_context(|| format!("Failed to query git branch in {}", root.display()))?;
if !output.status.success() {
return Ok(None);
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() {
Ok(None)
} else {
Ok(Some(branch))
}
}
fn decode_git_status(x: char, y: char) -> Option<GitDecoration> {
if x == ' ' && y == ' ' {
return Some(GitDecoration::clean());
}
if x == '?' && y == '?' {
return Some(GitDecoration::dirty(Some('A')));
}
let badge = match (x, y) {
('M', _) | (_, 'M') => Some('M'),
('A', _) | (_, 'A') => Some('A'),
('D', _) | (_, 'D') => Some('D'),
('R', _) | (_, 'R') => Some('R'),
('C', _) | (_, 'C') => Some('A'),
('U', _) | (_, 'U') => Some('U'),
_ => None,
};
if y != ' ' {
Some(GitDecoration::dirty(badge))
} else if x != ' ' {
Some(GitDecoration::staged(badge))
} else {
Some(GitDecoration::clean())
}
}

View File

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

File diff suppressed because it is too large Load Diff

View 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