use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::picker_action::AppAction;
use git2::{BranchType, ErrorCode, ObjectType};
use hjkl_buffer::Buffer;
use hjkl_engine::{BufferEdit, Editor, Host, Options};
use super::{App, BufferSlot, DiskState, STATUS_LINE_HEIGHT};
use crate::host::TuiHost;
use crate::picker_sources::{FileSourceWithOpen, RgSourceWithOpen};
use crate::syntax::BufferId;
const BUFFER_PREVIEW_WINDOW_RADIUS: usize = 250;
fn snapshot_buffer_window(buf: &hjkl_buffer::Buffer) -> (String, usize, usize) {
let cursor_row = buf.cursor().row;
let total = buf.row_count();
let start = cursor_row.saturating_sub(BUFFER_PREVIEW_WINDOW_RADIUS);
let end = (cursor_row + BUFFER_PREVIEW_WINDOW_RADIUS).min(total);
let mut content = String::with_capacity((end - start).saturating_mul(80));
for r in start..end {
if let Some(line) = buf.line(r) {
content.push_str(line);
content.push('\n');
}
}
(content, cursor_row - start, start)
}
impl App {
pub(crate) fn open_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(FileSourceWithOpen::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_buffer_picker(&mut self) {
let source = Box::new(crate::picker::BufferSource::new(
&self.slots,
|s| {
s.filename
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("[No Name]")
.to_owned()
},
|s| s.dirty,
|s| snapshot_buffer_window(s.editor.buffer()).0,
|s| s.filename.clone(),
|s| snapshot_buffer_window(s.editor.buffer()).1,
|s| snapshot_buffer_window(s.editor.buffer()).2,
));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_grep_picker(&mut self, pattern: Option<&str>) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(RgSourceWithOpen::new(cwd));
self.picker = Some(match pattern {
Some(p) if !p.is_empty() => crate::picker::Picker::new_with_query(source, p),
_ => crate::picker::Picker::new(source),
});
}
pub(crate) fn open_git_log_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(crate::picker_git::GitLogPicker::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_git_branch_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(crate::picker_git::GitBranchPicker::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_git_file_history_picker(&mut self) {
let filename = match self.active().filename.clone() {
Some(p) => p,
None => {
self.status_message = Some("git: current buffer has no path".into());
return;
}
};
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let abs = if filename.is_absolute() {
filename.clone()
} else {
cwd.join(&filename)
};
let repo = match git2::Repository::discover(&abs) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a git repo".into());
return;
}
};
let workdir = match repo.workdir() {
Some(w) => w.to_path_buf(),
None => {
self.status_message = Some("git: bare repo — no workdir".into());
return;
}
};
let rel_path = match abs.strip_prefix(&workdir) {
Ok(r) => r.to_path_buf(),
Err(_) => {
self.status_message =
Some("git: current buffer is outside the repo workdir".into());
return;
}
};
let source = Box::new(crate::picker_git::GitFileHistoryPicker::new(
workdir, rel_path,
));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_git_tags_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(crate::picker_git::GitTagsPicker::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_git_remotes_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(crate::picker_git::GitRemotesPicker::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_git_stash_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(crate::picker_git::GitStashPicker::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn open_git_status_picker(&mut self) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let source = Box::new(crate::picker_git::GitStatusPicker::new(cwd));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn handle_picker_key(&mut self, key: crossterm::event::KeyEvent) {
let event = match self.picker.as_mut() {
Some(p) => p.handle_key(key),
None => return,
};
match event {
crate::picker::PickerEvent::None => {}
crate::picker::PickerEvent::Cancel => {
self.picker = None;
}
crate::picker::PickerEvent::Select(action) => {
self.picker = None;
self.dispatch_picker_action(action);
}
}
}
pub(crate) fn dispatch_picker_action(&mut self, action: crate::picker::PickerAction) {
let boxed = match action {
crate::picker::PickerAction::Custom(b) => b,
crate::picker::PickerAction::None => return,
};
let app_action = match boxed.downcast::<AppAction>() {
Ok(a) => *a,
Err(_) => {
self.status_message = Some("picker: unknown action type".into());
return;
}
};
match app_action {
AppAction::OpenPath(path) => {
let s = path.to_string_lossy().to_string();
self.do_edit(&s, false);
}
AppAction::SwitchSlot(idx) => {
if idx < self.slots.len() {
self.switch_to(idx);
}
}
AppAction::OpenPathAtLine(path, line) => {
let s = path.to_string_lossy().to_string();
self.do_edit(&s, false);
if line > 0 {
self.active_mut().editor.goto_line(line as usize);
let vp = self.active_mut().editor.host_mut().viewport_mut();
let top = (line as usize).saturating_sub(5);
vp.top_row = top;
}
}
AppAction::ShowCommit(sha) => self.do_show_commit(&sha),
AppAction::CheckoutBranch(name) => self.do_checkout_branch(&name),
AppAction::CheckoutTag(name) => self.do_checkout_tag(&name),
AppAction::FetchRemote(name) => self.do_fetch_remote(&name),
AppAction::StashApply(idx) => self.do_stash_apply(idx),
AppAction::StashPop(idx) => self.do_stash_pop(idx),
AppAction::StashDrop(idx) => self.do_stash_drop(idx),
AppAction::JumpToRowCol(row, col) => {
self.active_mut().editor.jump_cursor(row, col);
self.sync_viewport_from_editor();
}
AppAction::ApplyCodeAction(idx) => {
if idx < self.pending_code_actions.len() {
let action = self.pending_code_actions.remove(idx);
self.pending_code_actions.clear();
self.apply_code_action_or_command(action);
} else {
self.status_message = Some("E: code action index out of range".into());
}
}
AppAction::AnvilInstall(name) => {
self.anvil_install(&name.clone());
}
AppAction::AnvilUninstall(name) => {
self.anvil_uninstall(&name.clone());
}
AppAction::AnvilUpdate(name) => {
self.anvil_update(&name.clone());
}
AppAction::AnvilNoOp(_name) => {
}
}
}
pub(crate) fn open_anvil_picker(&mut self) {
let registry = match self.anvil_registry.as_ref() {
Some(r) => r,
None => {
self.status_message = Some("anvil: registry not available".into());
return;
}
};
let source = Box::new(crate::picker_sources::AnvilPickerSource::from_registry(
registry,
));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn do_checkout_branch(&mut self, name: &str) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let repo = match git2::Repository::discover(&cwd) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
let local_result = repo.find_branch(name, BranchType::Local);
let (branch, is_remote) = match local_result {
Ok(b) => (b, false),
Err(ref e) if e.code() == ErrorCode::NotFound => {
match repo.find_branch(name, BranchType::Remote) {
Ok(b) => (b, true),
Err(_) => {
self.status_message = Some(format!("git: branch '{name}' not found"));
return;
}
}
}
Err(e) => {
self.status_message = Some(format!("git: {e}"));
return;
}
};
let target_obj = match branch.get().peel(git2::ObjectType::Commit) {
Ok(o) => o,
Err(e) => {
self.status_message = Some(format!("git: {e}"));
return;
}
};
let target_oid = target_obj.id();
let tree = match branch.get().peel_to_tree() {
Ok(t) => t,
Err(e) => {
self.status_message = Some(format!("git: {e}"));
return;
}
};
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
let touched: std::collections::HashSet<String> = match head_tree.as_ref() {
Some(ht) => match repo.diff_tree_to_tree(Some(ht), Some(&tree), None) {
Ok(diff) => {
let mut set = std::collections::HashSet::new();
diff.foreach(
&mut |delta, _| {
if let Some(p) = delta.new_file().path().and_then(|p| p.to_str()) {
set.insert(p.to_string());
}
if let Some(p) = delta.old_file().path().and_then(|p| p.to_str()) {
set.insert(p.to_string());
}
true
},
None,
None,
None,
)
.ok();
set
}
Err(_) => std::collections::HashSet::new(),
},
None => std::collections::HashSet::new(),
};
let mut so = git2::StatusOptions::new();
so.include_untracked(false).include_ignored(false);
let dirty: Vec<String> = match repo.statuses(Some(&mut so)) {
Ok(statuses) => statuses
.iter()
.filter(|s| !s.status().is_empty())
.filter_map(|s| s.path().map(|p| p.to_string()))
.filter(|p| touched.contains(p))
.collect(),
Err(_) => Vec::new(),
};
if !dirty.is_empty() {
let preview: Vec<&str> = dirty.iter().take(3).map(String::as_str).collect();
let suffix = if dirty.len() > 3 {
format!(", +{} more", dirty.len() - 3)
} else {
String::new()
};
self.status_message = Some(format!(
"git: uncommitted changes in {}{} — stash or commit first",
preview.join(", "),
suffix,
));
return;
}
let mut cb = git2::build::CheckoutBuilder::new();
cb.safe();
if let Err(e) = repo.checkout_tree(tree.as_object(), Some(&mut cb)) {
self.status_message = Some(format!("git: checkout failed: {e}"));
return;
}
if is_remote {
if let Err(e) = repo.set_head_detached(target_oid) {
self.status_message = Some(format!("git: {e}"));
return;
}
} else {
let refname = format!("refs/heads/{name}");
if let Err(e) = repo.set_head(&refname) {
self.status_message = Some(format!("git: {e}"));
return;
}
}
self.status_message = Some(format!("checked out {name}"));
self.checktime_all();
}
pub(crate) fn do_checkout_tag(&mut self, name: &str) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let repo = match git2::Repository::discover(&cwd) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
let refname = format!("refs/tags/{name}");
let tag_ref = match repo.find_reference(&refname) {
Ok(r) => r,
Err(_) => {
self.status_message = Some(format!("git: tag '{name}' not found"));
return;
}
};
let target_obj = match tag_ref.peel(ObjectType::Commit) {
Ok(o) => o,
Err(e) => {
self.status_message = Some(format!("git: {e}"));
return;
}
};
let target_oid = target_obj.id();
let tree = match target_obj.peel_to_tree() {
Ok(t) => t,
Err(e) => {
self.status_message = Some(format!("git: {e}"));
return;
}
};
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
let touched: std::collections::HashSet<String> = match head_tree.as_ref() {
Some(ht) => match repo.diff_tree_to_tree(Some(ht), Some(&tree), None) {
Ok(diff) => {
let mut set = std::collections::HashSet::new();
diff.foreach(
&mut |delta, _| {
if let Some(p) = delta.new_file().path().and_then(|p| p.to_str()) {
set.insert(p.to_string());
}
if let Some(p) = delta.old_file().path().and_then(|p| p.to_str()) {
set.insert(p.to_string());
}
true
},
None,
None,
None,
)
.ok();
set
}
Err(_) => std::collections::HashSet::new(),
},
None => std::collections::HashSet::new(),
};
let mut so = git2::StatusOptions::new();
so.include_untracked(false).include_ignored(false);
let dirty: Vec<String> = match repo.statuses(Some(&mut so)) {
Ok(statuses) => statuses
.iter()
.filter(|s| !s.status().is_empty())
.filter_map(|s| s.path().map(|p| p.to_string()))
.filter(|p| touched.contains(p))
.collect(),
Err(_) => Vec::new(),
};
if !dirty.is_empty() {
let preview: Vec<&str> = dirty.iter().take(3).map(String::as_str).collect();
let suffix = if dirty.len() > 3 {
format!(", +{} more", dirty.len() - 3)
} else {
String::new()
};
self.status_message = Some(format!(
"git: uncommitted changes in {}{} — stash or commit first",
preview.join(", "),
suffix,
));
return;
}
let mut cb = git2::build::CheckoutBuilder::new();
cb.safe();
if let Err(e) = repo.checkout_tree(tree.as_object(), Some(&mut cb)) {
self.status_message = Some(format!("git: checkout failed: {e}"));
return;
}
if let Err(e) = repo.set_head_detached(target_oid) {
self.status_message = Some(format!("git: {e}"));
return;
}
self.status_message = Some(format!("checked out tag {name} (detached HEAD)"));
self.checktime_all();
}
pub(crate) fn do_fetch_remote(&mut self, name: &str) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let repo = match git2::Repository::discover(&cwd) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
let mut remote = match repo.find_remote(name) {
Ok(r) => r,
Err(_) => {
self.status_message = Some(format!("git: remote '{name}' not found"));
return;
}
};
match remote.fetch(&[] as &[&str], None, None) {
Ok(()) => {
self.status_message = Some(format!("fetched {name}"));
}
Err(e) => {
self.status_message = Some(format!("git: fetch {name} failed — {e}"));
}
}
}
pub(crate) fn do_stash_apply(&mut self, idx: usize) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut repo = match git2::Repository::discover(&cwd) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
let mut opts = git2::StashApplyOptions::new();
match repo.stash_apply(idx, Some(&mut opts)) {
Ok(()) => {
self.status_message = Some(format!("applied stash@{{{idx}}}"));
self.checktime_all();
}
Err(e) => {
self.status_message = Some(format!("stash apply conflict — {e}"));
}
}
}
pub(crate) fn do_stash_pop(&mut self, idx: usize) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut repo = match git2::Repository::discover(&cwd) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
let mut opts = git2::StashApplyOptions::new();
match repo.stash_pop(idx, Some(&mut opts)) {
Ok(()) => {
self.status_message = Some(format!("popped stash@{{{idx}}}"));
self.checktime_all();
}
Err(e) => {
self.status_message = Some(format!("stash pop conflict — {e}"));
}
}
}
pub(crate) fn do_stash_drop(&mut self, idx: usize) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut repo = match git2::Repository::discover(&cwd) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
match repo.stash_drop(idx) {
Ok(()) => {
self.status_message = Some(format!("dropped stash@{{{idx}}}"));
}
Err(e) => {
self.status_message = Some(format!("git: stash drop failed — {e}"));
}
}
}
pub(crate) fn do_show_commit(&mut self, sha: &str) {
let repo = match git2::Repository::discover(
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
) {
Ok(r) => r,
Err(_) => {
self.status_message = Some("git: not in a repo".into());
return;
}
};
let oid = match git2::Oid::from_str(sha) {
Ok(o) => o,
Err(e) => {
self.status_message = Some(format!("git: bad sha: {e}"));
return;
}
};
let commit = match repo.find_commit(oid) {
Ok(c) => c,
Err(e) => {
self.status_message = Some(format!("git: {e}"));
return;
}
};
let content = crate::picker_git::render_commit(&repo, &commit);
let short_sha = &sha[..7.min(sha.len())];
match build_scratch_slot(
&mut self.syntax,
self.next_buffer_id,
&content,
&self.config,
) {
Ok(slot) => {
self.next_buffer_id += 1;
self.slots.push(slot);
let new_idx = self.slots.len() - 1;
self.switch_to(new_idx);
self.status_message = Some(format!("showing commit {short_sha}"));
}
Err(e) => {
self.status_message = Some(e);
}
}
}
}
fn build_scratch_slot(
syntax: &mut crate::syntax::SyntaxLayer,
buffer_id: BufferId,
content: &str,
config: &crate::config::Config,
) -> Result<BufferSlot, String> {
let mut buffer = Buffer::new();
let content = content.strip_suffix('\n').unwrap_or(content);
BufferEdit::replace_all(&mut buffer, content);
let host = TuiHost::new();
let opts = Options {
expandtab: config.editor.expandtab,
tabstop: config.editor.tab_width as u32,
shiftwidth: config.editor.tab_width as u32,
softtabstop: config.editor.tab_width as u32,
readonly: true,
..Options::default()
};
let mut editor = Editor::new(buffer, host, opts);
if let Ok(size) = crossterm::terminal::size() {
let vp = editor.host_mut().viewport_mut();
vp.width = size.0;
vp.height = size.1.saturating_sub(STATUS_LINE_HEIGHT);
}
let _ = editor.take_content_edits();
let _ = editor.take_content_reset();
let (vp_top, vp_height) = {
let vp = editor.host().viewport();
(vp.top_row, vp.height as usize)
};
if let Some(out) = syntax.preview_render(buffer_id, editor.buffer(), vp_top, vp_height) {
editor.install_ratatui_syntax_spans(out.spans);
}
let initial_dg = editor.buffer().dirty_gen();
let (key, signs) = if let Some(out) = syntax.wait_for_initial_result(Duration::from_millis(150))
{
let k = out.key;
editor.install_ratatui_syntax_spans(out.spans);
(Some(k), out.signs)
} else {
(Some((initial_dg, vp_top, vp_height)), Vec::new())
};
let mut slot = BufferSlot {
buffer_id,
editor,
filename: None,
dirty: false,
is_new_file: false,
is_untracked: false,
diag_signs: signs,
diag_signs_lsp: Vec::new(),
lsp_diags: Vec::new(),
last_lsp_dirty_gen: None,
git_signs: Vec::new(),
last_git_dirty_gen: None,
last_git_refresh_at: Instant::now(),
last_recompute_at: Instant::now() - Duration::from_secs(1),
last_recompute_key: key,
saved_hash: 0,
saved_len: 0,
disk_mtime: None,
disk_len: None,
disk_state: DiskState::Synced,
};
slot.snapshot_saved();
Ok(slot)
}