1use std::{collections::HashMap, path::Path};
2
3use crate::{app::SelectedNote, config::Theme, note_editor::state::NoteEditorState};
4
5#[derive(Clone)]
6pub struct Tab<'a> {
7 pub note: SelectedNote,
8 pub editor: NoteEditorState<'a>,
9}
10
11#[derive(Default, Clone)]
12pub struct Tabs<'a> {
13 tabs: Vec<Tab<'a>>,
14 active: usize,
15}
16
17impl<'a> Tabs<'a> {
18 pub fn is_empty(&self) -> bool {
19 self.tabs.is_empty()
20 }
21
22 pub fn len(&self) -> usize {
23 self.tabs.len()
24 }
25
26 pub fn active_note(&self) -> Option<&SelectedNote> {
27 self.tabs.get(self.active).map(|tab| &tab.note)
28 }
29
30 pub fn active_note_mut(&mut self) -> Option<&mut SelectedNote> {
31 self.tabs.get_mut(self.active).map(|tab| &mut tab.note)
32 }
33
34 pub fn active_editor(&self) -> Option<&NoteEditorState<'a>> {
35 self.tabs.get(self.active).map(|tab| &tab.editor)
36 }
37
38 pub fn active_editor_mut(&mut self) -> Option<&mut NoteEditorState<'a>> {
39 self.tabs.get_mut(self.active).map(|tab| &mut tab.editor)
40 }
41
42 pub fn set_theme(&mut self, theme: &Theme) {
45 for tab in &mut self.tabs {
46 tab.editor.set_theme(theme);
47 }
48 }
49
50 fn index_of(&self, path: &Path) -> Option<usize> {
51 self.tabs.iter().position(|tab| tab.note.path() == path)
52 }
53
54 pub fn open_or_focus(&mut self, path: &Path) -> bool {
55 match self.index_of(path) {
56 Some(index) => {
57 self.active = index;
58 true
59 }
60 None => false,
61 }
62 }
63
64 pub fn open(&mut self, tab: Tab<'a>) {
65 self.tabs.push(tab);
66 self.active = self.tabs.len() - 1;
67 }
68
69 pub fn next(&mut self) {
70 if !self.tabs.is_empty() {
71 self.active = (self.active + 1) % self.tabs.len();
72 }
73 }
74
75 pub fn prev(&mut self) {
76 if !self.tabs.is_empty() {
77 self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
78 }
79 }
80
81 pub fn close_active(&mut self) {
82 if self.active < self.tabs.len() {
83 self.tabs.remove(self.active);
84 self.active = self.active.min(self.tabs.len().saturating_sub(1));
85 }
86 }
87
88 pub fn rename(&mut self, old: &Path, new: &Path, name: &str) {
89 if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.note.path() == old) {
90 tab.note.set_path(new);
91 tab.note.set_name(name);
92 tab.editor.set_filepath(new);
93 tab.editor.set_filename(name);
94 }
95 }
96
97 pub(crate) fn titles(&self) -> Vec<(String, bool, bool)> {
98 let mut counts: HashMap<&str, usize> = HashMap::new();
99 for tab in &self.tabs {
100 *counts.entry(tab.note.name()).or_default() += 1;
101 }
102 self.tabs
103 .iter()
104 .enumerate()
105 .map(|(index, tab)| {
106 let name = tab.note.name();
107 let label = if counts[name] > 1 {
108 tab.note
109 .path()
110 .parent()
111 .and_then(|parent| parent.file_name())
112 .and_then(|dir| dir.to_str())
113 .map(|dir| format!("{dir}/{name}"))
114 .unwrap_or_else(|| name.to_string())
115 } else {
116 name.to_string()
117 };
118 (label, index == self.active, tab.editor.modified())
119 })
120 .collect()
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use std::path::{Path, PathBuf};
127
128 use super::*;
129 use crate::config::Symbols;
130
131 fn tab(name: &str) -> Tab<'static> {
132 tab_in("", name)
133 }
134
135 fn tab_in(dir: &str, name: &str) -> Tab<'static> {
136 let path = PathBuf::from(format!("/vault/{dir}/{name}.md"));
137 let editor = NoteEditorState::new("", name, &path, &Symbols::unicode());
138 Tab {
139 note: SelectedNote::new(name, &path, ""),
140 editor,
141 }
142 }
143
144 #[test]
145 fn open_focuses_new_tab() {
146 let mut tabs = Tabs::default();
147 tabs.open(tab("a"));
148 tabs.open(tab("b"));
149 assert_eq!(tabs.active_note().map(SelectedNote::name), Some("b"));
150 }
151
152 #[test]
153 fn open_or_focus_reuses_open_tab() {
154 let mut tabs = Tabs::default();
155 tabs.open(tab("a"));
156 tabs.open(tab("b"));
157
158 assert!(tabs.open_or_focus(Path::new("/vault/a.md")));
159 assert_eq!(tabs.active_note().map(SelectedNote::name), Some("a"));
160 assert!(!tabs.open_or_focus(Path::new("/vault/c.md")));
161 }
162
163 #[test]
164 fn next_and_prev_wrap_around() {
165 let mut tabs = Tabs::default();
166 tabs.open(tab("a"));
167 tabs.open(tab("b"));
168
169 tabs.next();
170 assert_eq!(tabs.active_note().map(SelectedNote::name), Some("a"));
171 tabs.prev();
172 assert_eq!(tabs.active_note().map(SelectedNote::name), Some("b"));
173 }
174
175 #[test]
176 fn close_active_clamps_focus() {
177 let mut tabs = Tabs::default();
178 tabs.open(tab("a"));
179 tabs.open(tab("b"));
180
181 tabs.close_active();
182 assert_eq!(tabs.active_note().map(SelectedNote::name), Some("a"));
183 tabs.close_active();
184 assert!(tabs.is_empty());
185 assert_eq!(tabs.active_note(), None);
186 }
187
188 #[test]
189 fn same_name_tabs_are_disambiguated_by_parent() {
190 let mut tabs = Tabs::default();
191 tabs.open(tab_in("alpha", "note"));
192 tabs.open(tab_in("beta", "note"));
193 tabs.open(tab_in("gamma", "unique"));
194
195 let labels: Vec<String> = tabs.titles().into_iter().map(|(label, ..)| label).collect();
196 assert_eq!(labels, ["alpha/note", "beta/note", "unique"]);
197 }
198}