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,
|
Command,
|
||||||
SessionBrowser,
|
SessionBrowser,
|
||||||
ThemeBrowser,
|
ThemeBrowser,
|
||||||
|
RepoSearch,
|
||||||
|
SymbolSearch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InputMode {
|
impl fmt::Display for InputMode {
|
||||||
@@ -35,6 +37,8 @@ impl fmt::Display for InputMode {
|
|||||||
InputMode::Command => "Command",
|
InputMode::Command => "Command",
|
||||||
InputMode::SessionBrowser => "Sessions",
|
InputMode::SessionBrowser => "Sessions",
|
||||||
InputMode::ThemeBrowser => "Themes",
|
InputMode::ThemeBrowser => "Themes",
|
||||||
|
InputMode::RepoSearch => "Search",
|
||||||
|
InputMode::SymbolSearch => "Symbols",
|
||||||
};
|
};
|
||||||
f.write_str(label)
|
f.write_str(label)
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,7 @@ impl fmt::Display for InputMode {
|
|||||||
/// Represents which panel is currently focused in the TUI layout.
|
/// Represents which panel is currently focused in the TUI layout.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum FocusedPanel {
|
pub enum FocusedPanel {
|
||||||
|
Files,
|
||||||
Chat,
|
Chat,
|
||||||
Thinking,
|
Thinking,
|
||||||
Input,
|
Input,
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ textwrap = { workspace = true }
|
|||||||
unicode-width = "0.1"
|
unicode-width = "0.1"
|
||||||
unicode-segmentation = "1.11"
|
unicode-segmentation = "1.11"
|
||||||
async-trait = "0.1"
|
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
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
@@ -30,6 +37,7 @@ futures-util = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
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.
|
//! to test in isolation.
|
||||||
|
|
||||||
mod command_palette;
|
mod command_palette;
|
||||||
|
mod file_tree;
|
||||||
|
mod search;
|
||||||
|
mod workspace;
|
||||||
|
|
||||||
pub use command_palette::{CommandPalette, ModelPaletteEntry};
|
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