use std::fs;
use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::COPILOT_SUBCOMMANDS;
use crate::buffer_ref::BufferRef;
use crate::config::COMMAND_BINDS;
use crate::finder::{ExplorerState, Finder, FuzzyKind, IgnoreOpts};
use crate::lsp::{CodeAction, Location};
#[derive(Default)]
pub struct LineInput {
buf: String,
cursor: usize,
}
impl LineInput {
pub fn new() -> Self {
Self::default()
}
pub fn as_str(&self) -> &str {
&self.buf
}
pub fn cursor_cell_col(&self) -> usize {
let cut_byte = self.byte_idx(self.cursor);
crate::text_width::str_cell_width(&self.buf[..cut_byte])
}
fn char_len(&self) -> usize {
self.buf.chars().count()
}
fn byte_idx(&self, char_idx: usize) -> usize {
self.buf
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.buf.len())
}
pub fn insert(&mut self, c: char) {
let byte = self.byte_idx(self.cursor);
self.buf.insert(byte, c);
self.cursor += 1;
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let end = self.byte_idx(self.cursor);
let start = self.byte_idx(self.cursor - 1);
self.buf.replace_range(start..end, "");
self.cursor -= 1;
}
pub fn delete(&mut self) {
if self.cursor >= self.char_len() {
return;
}
let start = self.byte_idx(self.cursor);
let end = self.byte_idx(self.cursor + 1);
self.buf.replace_range(start..end, "");
}
pub fn left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn right(&mut self) {
if self.cursor < self.char_len() {
self.cursor += 1;
}
}
pub fn home(&mut self) {
self.cursor = 0;
}
pub fn end(&mut self) {
self.cursor = self.char_len();
}
pub fn into_string(self) -> String {
self.buf
}
}
pub(crate) fn apply_line_key(input: &mut LineInput, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left => input.left(),
KeyCode::Right => input.right(),
KeyCode::Home => input.home(),
KeyCode::End => input.end(),
KeyCode::Backspace => input.backspace(),
KeyCode::Delete => input.delete(),
KeyCode::Char('b') if ctrl => input.left(),
KeyCode::Char('f') if ctrl => input.right(),
KeyCode::Char('a') if ctrl => input.home(),
KeyCode::Char('e') if ctrl => input.end(),
KeyCode::Char(c) if !ctrl => input.insert(c),
_ => {}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CompletionKind {
CommandName,
Path,
}
pub struct CompletionState {
pub kind: CompletionKind,
pub prefix: String,
pub head_chars: usize,
pub matches: Vec<String>,
pub selected: usize,
}
pub struct CommandPrompt {
pub input: LineInput,
pub completion: Option<CompletionState>,
}
impl CommandPrompt {
fn new() -> Self {
Self {
input: LineInput::new(),
completion: None,
}
}
fn tab(&mut self, step: i32, root: &Path) {
if self.completion.is_none() {
let Some(state) = build_completion(self.input.as_str(), root) else {
return;
};
self.completion = Some(state);
} else if let Some(c) = self.completion.as_mut() {
let len = c.matches.len() as i32;
let next = (c.selected as i32 + step).rem_euclid(len);
c.selected = next as usize;
}
if let Some(c) = &self.completion {
let head: String = self.input.as_str().chars().take(c.head_chars).collect();
let new = format!("{}{}", head, c.matches[c.selected]);
self.input = LineInput::new();
for ch in new.chars() {
self.input.insert(ch);
}
}
}
}
fn build_completion(input: &str, root: &Path) -> Option<CompletionState> {
match input.find(' ') {
None => {
let prefix = input.to_string();
let matches: Vec<String> = COMMAND_BINDS
.iter()
.flat_map(|b| b.all_names())
.chain(std::iter::once("copilot"))
.filter(|n| n.starts_with(&prefix))
.map(|n| n.to_string())
.collect();
if matches.is_empty() {
return None;
}
Some(CompletionState {
kind: CompletionKind::CommandName,
prefix,
head_chars: 0,
matches,
selected: 0,
})
}
Some(sp_byte) => {
let cmd = &input[..sp_byte];
if cmd == "copilot" {
let partial = &input[sp_byte + 1..];
if partial.contains(' ') {
return None;
}
let matches: Vec<String> = COPILOT_SUBCOMMANDS
.iter()
.flat_map(|s| std::iter::once(s.name).chain(s.aliases.iter().copied()))
.filter(|n| n.starts_with(partial))
.map(|n| n.to_string())
.collect();
if matches.is_empty() {
return None;
}
return Some(CompletionState {
kind: CompletionKind::CommandName,
prefix: partial.to_string(),
head_chars: sp_byte + 1,
matches,
selected: 0,
});
}
let bind = COMMAND_BINDS
.iter()
.find(|b| b.name == cmd || b.aliases.contains(&cmd))?;
if !bind.takes_path {
return None;
}
let partial = &input[sp_byte + 1..];
if partial.contains(' ') {
return None;
}
let matches = path_candidates(partial, root);
if matches.is_empty() {
return None;
}
let head_chars = cmd.chars().count() + 1;
Some(CompletionState {
kind: CompletionKind::Path,
prefix: partial.to_string(),
head_chars,
matches,
selected: 0,
})
}
}
}
fn path_candidates(partial: &str, root: &Path) -> Vec<String> {
let (dir_str, base_prefix) = match partial.rfind('/') {
Some(i) => (&partial[..=i], &partial[i + 1..]),
None => ("", partial),
};
let listing_dir: PathBuf = if dir_str.is_empty() {
root.to_path_buf()
} else {
let p = Path::new(dir_str);
if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
}
};
let Ok(rd) = fs::read_dir(&listing_dir) else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with('.') && !base_prefix.starts_with('.') {
continue;
}
if !name.starts_with(base_prefix) {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let mut s = String::with_capacity(dir_str.len() + name.len() + 1);
s.push_str(dir_str);
s.push_str(name);
if is_dir {
s.push('/');
}
out.push(s);
}
out.sort_by(|a, b| {
let a_dir = a.ends_with('/');
let b_dir = b.ends_with('/');
b_dir.cmp(&a_dir).then_with(|| a.cmp(b))
});
out.truncate(200);
out
}
pub enum Prompt {
None,
Command(CommandPrompt),
Search {
forward: bool,
query: LineInput,
},
Fuzzy(Finder),
Rename(LineInput),
CodeActionMenu {
actions: Vec<CodeAction>,
selected: usize,
},
Hover {
content: String,
scroll: usize,
},
LspStatus {
content: String,
scroll: usize,
},
CopilotSignin {
code: String,
url: String,
},
Explorer(ExplorerState),
}
impl Prompt {
pub fn is_open(&self) -> bool {
!matches!(self, Prompt::None)
}
}
pub enum PromptOutcome {
Nothing,
Cancelled,
RunCommand(String),
Search {
forward: bool,
query: String,
},
OpenRelativeFile(String),
GotoLine(usize),
JumpToLocation(Location),
OpenBuffer(BufferRef),
SubmitRename(String),
SelectCodeAction(CodeAction),
}
pub struct PromptController {
pub state: Prompt,
locations: Vec<Location>,
buffer_paths: Vec<BufferRef>,
}
impl PromptController {
pub fn new() -> Self {
Self {
state: Prompt::None,
locations: Vec::new(),
buffer_paths: Vec::new(),
}
}
pub fn is_open(&self) -> bool {
self.state.is_open()
}
pub fn locations(&self) -> &[Location] {
&self.locations
}
pub fn open_command(&mut self) {
self.state = Prompt::Command(CommandPrompt::new());
}
pub fn open_search(&mut self, forward: bool) {
self.state = Prompt::Search {
forward,
query: LineInput::new(),
};
}
pub fn open_files(&mut self, startup_cwd: &Path, ignore: IgnoreOpts) {
self.state = Prompt::Fuzzy(Finder::files(startup_cwd, ignore));
}
pub fn open_explorer(&mut self, startup_cwd: &Path, ignore: IgnoreOpts, compact: bool) {
self.state = Prompt::Explorer(ExplorerState::new(startup_cwd, ignore, compact));
}
pub fn open_lines(&mut self, lines: &[String]) {
self.state = Prompt::Fuzzy(Finder::lines(lines));
}
pub fn open_locations(&mut self, items: Vec<String>, locations: Vec<Location>) {
self.locations = locations;
self.state = Prompt::Fuzzy(Finder::locations(items));
}
pub fn open_diagnostics(
&mut self,
items: Vec<String>,
locations: Vec<Location>,
workspace: bool,
) {
self.locations = locations;
self.state = Prompt::Fuzzy(Finder::diagnostics(items, workspace));
}
pub fn open_workspace_search(
&mut self,
items: Vec<String>,
file_lines: Vec<Vec<String>>,
locations: Vec<Location>,
) {
self.locations = locations;
self.state = Prompt::Fuzzy(Finder::workspace_search(items, file_lines));
}
pub fn open_buffers(&mut self, items: Vec<String>, refs: Vec<BufferRef>) {
self.buffer_paths = refs;
self.state = Prompt::Fuzzy(Finder::buffers(items));
}
pub fn buffer_paths(&self) -> &[BufferRef] {
&self.buffer_paths
}
pub fn open_rename(&mut self) {
self.state = Prompt::Rename(LineInput::new());
}
pub fn open_code_actions(&mut self, actions: Vec<CodeAction>) {
self.state = Prompt::CodeActionMenu {
actions,
selected: 0,
};
}
pub fn open_hover(&mut self, content: String) {
self.state = Prompt::Hover { content, scroll: 0 };
}
pub fn open_lsp_status(&mut self, content: String) {
self.state = Prompt::LspStatus { content, scroll: 0 };
}
pub fn open_copilot_signin(&mut self, code: String, url: String) {
self.state = Prompt::CopilotSignin { code, url };
}
pub fn handle_key(&mut self, key: KeyEvent, root: &Path) -> PromptOutcome {
let ctrl_c =
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c');
if key.code == KeyCode::Esc || ctrl_c {
self.close();
return PromptOutcome::Cancelled;
}
if key.code == KeyCode::Enter {
if let Prompt::Explorer(state) = &mut self.state {
let Some(node) = state.selection() else {
return PromptOutcome::Nothing;
};
if node.is_dir {
state.toggle_selected();
state.refilter();
return PromptOutcome::Nothing;
}
let rel = node.rel_path.clone();
self.close();
return PromptOutcome::OpenRelativeFile(rel);
}
return self.submit();
}
match &mut self.state {
Prompt::None => PromptOutcome::Nothing,
Prompt::Command(cp) => {
match key.code {
KeyCode::Tab => cp.tab(1, root),
KeyCode::BackTab => cp.tab(-1, root),
_ => {
cp.completion = None;
apply_line_key(&mut cp.input, key);
}
}
PromptOutcome::Nothing
}
Prompt::Rename(input) => {
apply_line_key(input, key);
PromptOutcome::Nothing
}
Prompt::Search { query, .. } => {
apply_line_key(query, key);
PromptOutcome::Nothing
}
Prompt::Fuzzy(finder) => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Up => finder.prev(),
KeyCode::Down => finder.next(),
KeyCode::Char('n') if ctrl => finder.next(),
KeyCode::Char('p') if ctrl => finder.prev(),
_ => finder.apply_line_key(key),
}
PromptOutcome::Nothing
}
Prompt::CodeActionMenu { actions, selected } => {
let last = actions.len().saturating_sub(1);
match key.code {
KeyCode::Up => *selected = selected.saturating_sub(1),
KeyCode::Char('k') => *selected = selected.saturating_sub(1),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*selected = selected.saturating_sub(1)
}
KeyCode::Down => *selected = (*selected + 1).min(last),
KeyCode::Char('j') => *selected = (*selected + 1).min(last),
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
*selected = (*selected + 1).min(last)
}
_ => {}
}
PromptOutcome::Nothing
}
Prompt::Explorer(state) => {
state.apply_key(key);
PromptOutcome::Nothing
}
Prompt::Hover { scroll, .. } | Prompt::LspStatus { scroll, .. } => {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
*scroll = scroll.saturating_add(1);
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(5);
}
KeyCode::PageDown => {
*scroll = scroll.saturating_add(5);
}
_ => {
self.close();
return PromptOutcome::Cancelled;
}
}
PromptOutcome::Nothing
}
Prompt::CopilotSignin { .. } => {
self.close();
PromptOutcome::Cancelled
}
}
}
fn close(&mut self) {
self.state = Prompt::None;
self.locations.clear();
self.buffer_paths.clear();
}
fn submit(&mut self) -> PromptOutcome {
let prompt = std::mem::replace(&mut self.state, Prompt::None);
match prompt {
Prompt::None => PromptOutcome::Nothing,
Prompt::Command(cp) => PromptOutcome::RunCommand(cp.input.as_str().trim().to_string()),
Prompt::Search { forward, query } => PromptOutcome::Search {
forward,
query: query.into_string(),
},
Prompt::Rename(new_name) => PromptOutcome::SubmitRename(new_name.into_string()),
Prompt::Fuzzy(finder) => self.submit_fuzzy(finder),
Prompt::Explorer(_) => PromptOutcome::Nothing,
Prompt::CodeActionMenu {
mut actions,
selected,
} => {
if selected < actions.len() {
PromptOutcome::SelectCodeAction(actions.swap_remove(selected))
} else {
PromptOutcome::Nothing
}
}
Prompt::Hover { .. } | Prompt::LspStatus { .. } | Prompt::CopilotSignin { .. } => {
PromptOutcome::Cancelled
}
}
}
fn submit_fuzzy(&mut self, finder: Finder) -> PromptOutcome {
let Some(sel) = finder.selection() else {
self.locations.clear();
return PromptOutcome::Nothing;
};
match finder.kind {
FuzzyKind::Files { .. } => {
PromptOutcome::OpenRelativeFile(finder.items[sel.idx].clone())
}
FuzzyKind::Lines => PromptOutcome::GotoLine(sel.idx),
FuzzyKind::Locations | FuzzyKind::Diagnostics { .. } => {
let loc = self.locations.get(sel.idx).cloned();
self.locations.clear();
match loc {
Some(loc) => PromptOutcome::JumpToLocation(loc),
None => PromptOutcome::Nothing,
}
}
FuzzyKind::WorkspaceSearch => {
let target_line = sel.line_hits.first().copied().unwrap_or(0) as u32;
let target_col = sel.match_col;
let loc = self.locations.get(sel.idx).cloned().map(|mut l| {
l.range.start.line = target_line;
l.range.start.character = target_col;
l.range.end.line = target_line;
l.range.end.character = target_col;
l
});
self.locations.clear();
match loc {
Some(loc) => PromptOutcome::JumpToLocation(loc),
None => PromptOutcome::Nothing,
}
}
FuzzyKind::Buffers => {
let r = self.buffer_paths.get(sel.idx).cloned();
self.buffer_paths.clear();
match r {
Some(r) => PromptOutcome::OpenBuffer(r),
None => PromptOutcome::Nothing,
}
}
}
}
}
impl Default for PromptController {
fn default() -> Self {
Self::new()
}
}