1use crate::file_index::FileEntry;
2use crate::view::filterable_list::FilterableList;
3use crate::request::RequestId;
4use crate::view::list_view::ListView;
5use crate::view::selection::SelectionState;
6use crate::theme::Theme;
7use ratatui::style::Style;
8use ratatui::text::Line;
9use ratatui::widgets::{Block, Borders};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CommandEntry {
13 pub name: String,
14 pub description: String,
15 pub has_input: bool,
16 pub hint: Option<String>,
17 pub builtin: bool,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum CompletionEntry {
22 Command(CommandEntry),
23 File(FileEntry),
24}
25
26#[derive(Debug, Clone)]
28pub struct CompletionOverlay {
29 trigger: char,
30 entries: FilterableList<CompletionEntry>,
31 empty_message: &'static str,
32 pending_index: Option<RequestId>,
34}
35
36impl CompletionOverlay {
37 pub fn command(entries: Vec<CommandEntry>) -> Self {
38 Self::new('/', entries.into_iter().map(CompletionEntry::Command).collect(), "no matching commands")
39 }
40
41 pub fn file(request_id: RequestId) -> Self {
44 let mut overlay = Self::new('@', Vec::new(), "indexing files…");
45 overlay.pending_index = Some(request_id);
46 overlay
47 }
48
49 pub fn set_files(&mut self, request_id: RequestId, files: Vec<FileEntry>) {
52 if self.pending_index != Some(request_id) {
53 return;
54 }
55 self.pending_index = None;
56 self.empty_message = "no matching files";
57 let query = self.entries.query().to_string();
58 self.entries =
59 FilterableList::new(files.into_iter().map(CompletionEntry::File).collect(), CompletionEntry::match_key);
60 self.entries.set_query(query);
61 }
62
63 pub fn trigger(&self) -> char {
66 self.trigger
67 }
68
69 pub fn query(&self) -> &str {
70 self.entries.query()
71 }
72
73 pub fn set_query(&mut self, query: String) {
74 self.entries.set_query(query);
75 }
76
77 pub fn entries_mut(&mut self) -> &mut FilterableList<CompletionEntry> {
78 &mut self.entries
79 }
80
81 pub fn selected_command(&self) -> Option<CommandEntry> {
82 match self.entries.selected_entry()? {
83 CompletionEntry::Command(command) => Some(command.clone()),
84 CompletionEntry::File(_) => None,
85 }
86 }
87
88 pub fn selected_file(&self) -> Option<FileEntry> {
89 match self.entries.selected_entry()? {
90 CompletionEntry::File(file) => Some(file.clone()),
91 CompletionEntry::Command(_) => None,
92 }
93 }
94
95 pub fn row_count(&self, max_rows: usize) -> usize {
98 1 + self.entries.filtered_len().clamp(1, max_rows.max(1))
99 }
100
101 pub fn view<'a>(&'a mut self, theme: &'a Theme) -> (ListView<'a>, &'a mut SelectionState) {
102 let empty_message = self.empty_message;
103 let (view, selection) = self
104 .entries
105 .view(theme, |entry| Line::styled(format!(" {}", entry.label()), Style::new().fg(theme.text_secondary)));
106 let view = view
107 .empty_message(empty_message)
108 .block(Block::new().borders(Borders::TOP).border_style(Style::new().fg(theme.muted)));
109 (view, selection)
110 }
111
112 fn new(trigger: char, entries: Vec<CompletionEntry>, empty_message: &'static str) -> Self {
113 Self {
114 trigger,
115 entries: FilterableList::new(entries, CompletionEntry::match_key),
116 empty_message,
117 pending_index: None,
118 }
119 }
120}
121
122impl CompletionEntry {
123 fn label(&self) -> String {
124 match self {
125 Self::Command(command) => {
126 let hint = command.hint.as_deref().map_or_else(String::new, |hint| format!(" [{hint}]"));
127 format!("/{:<16} {}{hint}", command.name, command.description)
128 }
129 Self::File(file) => file.display_name.clone(),
130 }
131 }
132
133 fn match_key(&self) -> String {
134 match self {
135 Self::Command(command) => format!("{} {}", command.name, command.description),
136 Self::File(file) => file.display_name.clone(),
137 }
138 }
139}