Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: fuzzy picker optimization

RivoLink 1977b9fa d0875383

+1097 -168
+407 -46
src/app.rs
··· 10 10 }; 11 11 use ratatui::text::Line; 12 12 use std::{ 13 - collections::VecDeque, 14 13 fs, 15 14 path::PathBuf, 15 + sync::mpsc::{self, Receiver, TryRecvError}, 16 + thread, 16 17 time::{Duration, Instant, SystemTime}, 17 18 }; 18 19 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 20 + 21 + const MAX_FUZZY_PICKER_DIRS_VISITED: usize = 5_000; 22 + const MAX_FUZZY_PICKER_FILES_INDEXED: usize = 10_000; 23 + const MAX_FUZZY_PICKER_INDEX_DURATION: Duration = Duration::from_secs(5); 24 + const IGNORED_FUZZY_PICKER_DIRS: &[&str] = &[ 25 + ".git", 26 + "node_modules", 27 + "target", 28 + ".venv", 29 + "venv", 30 + "vendor", 31 + "var", 32 + "dist", 33 + "build", 34 + ".next", 35 + ".cache", 36 + ]; 19 37 20 38 #[derive(Clone)] 21 39 pub(crate) struct TocEntry { ··· 116 134 } 117 135 118 136 fn file_name_component(path: &str) -> &str { 119 - path.rsplit(std::path::MAIN_SEPARATOR).next().unwrap_or(path) 137 + path.rsplit(std::path::MAIN_SEPARATOR) 138 + .next() 139 + .unwrap_or(path) 120 140 } 121 141 122 142 fn is_dir_like(&self) -> bool { ··· 133 153 match_positions: Vec<Vec<usize>>, 134 154 index: usize, 135 155 query: String, 156 + truncation: Option<PickerIndexTruncation>, 136 157 } 137 158 138 159 #[derive(Clone, Copy, Debug, PartialEq, Eq)] ··· 141 162 Fuzzy, 142 163 } 143 164 165 + #[derive(Clone, Debug, PartialEq, Eq)] 166 + enum PendingPicker { 167 + None, 168 + Browser(PathBuf), 169 + Fuzzy(PathBuf), 170 + } 171 + 172 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 173 + pub(crate) enum PickerIndexTruncation { 174 + Directory, 175 + File, 176 + Time, 177 + } 178 + 179 + struct PickerIndexResult { 180 + entries: Vec<FilePickerEntry>, 181 + truncated: Option<PickerIndexTruncation>, 182 + } 183 + 184 + enum PickerLoadState { 185 + Idle, 186 + Loading { 187 + mode: FilePickerMode, 188 + dir: PathBuf, 189 + started_at: Instant, 190 + receiver: Receiver<std::io::Result<PickerIndexResult>>, 191 + pending_result: Option<std::io::Result<PickerIndexResult>>, 192 + }, 193 + Failed { 194 + mode: FilePickerMode, 195 + dir: PathBuf, 196 + message: String, 197 + }, 198 + } 199 + 144 200 pub(crate) struct ThemePickerState { 145 201 open: bool, 146 202 index: usize, ··· 182 238 status_cache_key: Option<StatusCacheKey>, 183 239 help_open: bool, 184 240 file_picker: FilePickerState, 241 + pending_picker: PendingPicker, 242 + picker_load_state: PickerLoadState, 185 243 theme_picker: ThemePickerState, 186 244 render_width: usize, 187 245 } 188 246 189 247 impl App { 248 + fn min_picker_loading_duration() -> Duration { 249 + Duration::from_millis(500) 250 + } 251 + 190 252 fn is_markdown_path(path: &std::path::Path) -> bool { 191 253 matches!( 192 254 path.extension().and_then(|ext| ext.to_str()), ··· 224 286 entries.extend(dirs); 225 287 entries.extend(files); 226 288 Ok(entries) 289 + } 290 + 291 + fn is_ignored_fuzzy_picker_dir_name(name: &str) -> bool { 292 + IGNORED_FUZZY_PICKER_DIRS.contains(&name) 293 + } 294 + 295 + fn fuzzy_directory_sort_key(root: &std::path::Path, path: &std::path::Path) -> (bool, String) { 296 + let label = path 297 + .strip_prefix(root) 298 + .unwrap_or(path) 299 + .display() 300 + .to_string(); 301 + ( 302 + !label 303 + .split(std::path::MAIN_SEPARATOR) 304 + .next() 305 + .unwrap_or(&label) 306 + .starts_with('.'), 307 + label.to_lowercase(), 308 + ) 227 309 } 228 310 229 311 fn build_fuzzy_file_picker_entries( 230 312 dir: &std::path::Path, 231 - ) -> std::io::Result<Vec<FilePickerEntry>> { 313 + ) -> std::io::Result<PickerIndexResult> { 232 314 let mut entries = Vec::new(); 233 - let mut queue = VecDeque::from([dir.to_path_buf()]); 315 + let mut stack = vec![dir.to_path_buf()]; 316 + let started_at = Instant::now(); 317 + let mut dirs_visited = 0usize; 318 + let mut files_indexed = 0usize; 319 + let mut truncated = None; 234 320 235 - while let Some(current_dir) = queue.pop_front() { 321 + while let Some(current_dir) = stack.pop() { 322 + if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 323 + truncated = Some(PickerIndexTruncation::Time); 324 + break; 325 + } 326 + if dirs_visited >= MAX_FUZZY_PICKER_DIRS_VISITED { 327 + truncated = Some(PickerIndexTruncation::Directory); 328 + break; 329 + } 330 + dirs_visited += 1; 331 + 236 332 let mut dirs = Vec::new(); 237 333 let mut files = Vec::new(); 238 334 239 - for entry in fs::read_dir(&current_dir)? { 240 - let entry = entry?; 335 + let read_dir = match fs::read_dir(&current_dir) { 336 + Ok(read_dir) => read_dir, 337 + Err(err) => { 338 + if current_dir == dir { 339 + return Err(err); 340 + } 341 + continue; 342 + } 343 + }; 344 + 345 + for entry in read_dir { 346 + if started_at.elapsed() >= MAX_FUZZY_PICKER_INDEX_DURATION { 347 + truncated = Some(PickerIndexTruncation::Time); 348 + break; 349 + } 350 + let entry = match entry { 351 + Ok(entry) => entry, 352 + Err(_) => continue, 353 + }; 241 354 let path = entry.path(); 242 355 let file_type = match entry.file_type() { 243 356 Ok(file_type) => file_type, ··· 245 358 }; 246 359 247 360 if file_type.is_dir() { 361 + let name = entry.file_name(); 362 + if Self::is_ignored_fuzzy_picker_dir_name(name.to_string_lossy().as_ref()) { 363 + continue; 364 + } 248 365 dirs.push(path); 249 366 continue; 250 367 } 251 368 252 369 if file_type.is_file() && Self::is_markdown_path(&path) { 370 + if files_indexed >= MAX_FUZZY_PICKER_FILES_INDEXED { 371 + truncated = Some(PickerIndexTruncation::File); 372 + break; 373 + } 253 374 let label = path 254 375 .strip_prefix(dir) 255 376 .unwrap_or(&path) 256 377 .display() 257 378 .to_string(); 258 379 files.push(FilePickerEntry::new(label, path)); 380 + files_indexed += 1; 259 381 } 260 382 } 261 383 262 - files.sort_by(|left, right| Self::fuzzy_entry_sort_key(left).cmp(&Self::fuzzy_entry_sort_key(right))); 263 - dirs.sort_by_key(|path| { 264 - let label = path 265 - .strip_prefix(dir) 266 - .unwrap_or(path) 267 - .display() 268 - .to_string(); 269 - ( 270 - !label 271 - .split(std::path::MAIN_SEPARATOR) 272 - .next() 273 - .unwrap_or(&label) 274 - .starts_with('.'), 275 - label.to_lowercase(), 276 - ) 384 + files.sort_by(|left, right| { 385 + Self::fuzzy_entry_sort_key(left).cmp(&Self::fuzzy_entry_sort_key(right)) 277 386 }); 387 + dirs.sort_by_key(|path| Self::fuzzy_directory_sort_key(dir, path)); 278 388 279 389 entries.extend(files); 280 - queue.extend(dirs); 390 + if truncated.is_some() { 391 + break; 392 + } 393 + dirs.reverse(); 394 + stack.extend(dirs); 281 395 } 282 396 283 - Ok(entries) 397 + Ok(PickerIndexResult { entries, truncated }) 284 398 } 285 399 286 400 fn fuzzy_entry_sort_key(entry: &FilePickerEntry) -> (bool, &str) { ··· 329 443 .windows(2) 330 444 .map(|window| window[1].saturating_sub(window[0]).saturating_sub(1)) 331 445 .sum::<usize>(); 332 - let len_diff = candidate.chars().count().saturating_sub(query.chars().count()); 446 + let len_diff = candidate 447 + .chars() 448 + .count() 449 + .saturating_sub(query.chars().count()); 333 450 let prefix_bonus = usize::from(first == 0).saturating_mul(80); 334 451 let boundary_bonus = 335 452 usize::from(Self::is_match_boundary(candidate, first)).saturating_mul(40); ··· 381 498 } 382 499 383 500 let query = self.file_picker.query.trim().to_lowercase(); 501 + if query.is_empty() { 502 + self.file_picker.filtered = (0..self.file_picker.entries.len()).collect(); 503 + self.file_picker.match_positions = vec![Vec::new(); self.file_picker.filtered.len()]; 504 + self.file_picker.index = self 505 + .file_picker 506 + .index 507 + .min(self.file_picker.filtered.len().saturating_sub(1)); 508 + return; 509 + } 510 + 384 511 let mut filtered = self 385 512 .file_picker 386 513 .entries ··· 449 576 }) 450 577 .collect::<Vec<_>>() 451 578 .join("\n"); 452 - Self::new_with_source(lines, toc, AppConfig { 453 - filename, 454 - source, 455 - debug_input, 456 - watch, 457 - filepath, 458 - last_file_state, 459 - }) 579 + Self::new_with_source( 580 + lines, 581 + toc, 582 + AppConfig { 583 + filename, 584 + source, 585 + debug_input, 586 + watch, 587 + filepath, 588 + last_file_state, 589 + }, 590 + ) 460 591 } 461 592 462 593 pub(crate) fn new_with_source( ··· 512 643 match_positions: Vec::new(), 513 644 index: 0, 514 645 query: String::new(), 646 + truncation: None, 515 647 }, 648 + pending_picker: PendingPicker::None, 649 + picker_load_state: PickerLoadState::Idle, 516 650 theme_picker: ThemePickerState { 517 651 open: false, 518 652 index: theme_preset_index(current_theme_preset()), ··· 818 952 self.open_file_picker_with_mode(dir, FilePickerMode::Browser) 819 953 } 820 954 955 + #[cfg(test)] 821 956 pub(crate) fn open_fuzzy_file_picker(&mut self, dir: PathBuf) -> bool { 822 957 self.open_file_picker_with_mode(dir, FilePickerMode::Fuzzy) 823 958 } 824 959 960 + pub(crate) fn queue_file_picker(&mut self, dir: PathBuf) { 961 + self.pending_picker = PendingPicker::Browser(dir); 962 + } 963 + 964 + pub(crate) fn queue_fuzzy_file_picker(&mut self, dir: PathBuf) { 965 + self.pending_picker = PendingPicker::Fuzzy(dir); 966 + } 967 + 968 + pub(crate) fn has_pending_picker(&self) -> bool { 969 + !matches!(self.pending_picker, PendingPicker::None) 970 + } 971 + 972 + pub(crate) fn start_pending_picker_loading(&mut self) -> bool { 973 + if !self.has_pending_picker() || !matches!(self.picker_load_state, PickerLoadState::Idle) { 974 + return false; 975 + } 976 + 977 + let pending = std::mem::replace(&mut self.pending_picker, PendingPicker::None); 978 + let (mode, dir) = match pending { 979 + PendingPicker::Browser(dir) => (FilePickerMode::Browser, dir), 980 + PendingPicker::Fuzzy(dir) => (FilePickerMode::Fuzzy, dir), 981 + PendingPicker::None => return false, 982 + }; 983 + 984 + let worker_dir = dir.clone(); 985 + let (tx, rx) = mpsc::channel(); 986 + crate::runtime::debug_log( 987 + self.debug_input, 988 + &format!("picker_loading spawn mode={mode:?} dir={}", dir.display()), 989 + ); 990 + thread::spawn(move || { 991 + let result = match mode { 992 + FilePickerMode::Browser => { 993 + Self::build_file_picker_entries(&worker_dir).map(|entries| PickerIndexResult { 994 + entries, 995 + truncated: None, 996 + }) 997 + } 998 + FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&worker_dir), 999 + }; 1000 + let _ = tx.send(result); 1001 + }); 1002 + 1003 + self.picker_load_state = PickerLoadState::Loading { 1004 + mode, 1005 + dir, 1006 + started_at: Instant::now(), 1007 + receiver: rx, 1008 + pending_result: None, 1009 + }; 1010 + true 1011 + } 1012 + 1013 + pub(crate) fn is_picker_loading(&self) -> bool { 1014 + matches!(self.picker_load_state, PickerLoadState::Loading { .. }) 1015 + } 1016 + 1017 + pub(crate) fn is_picker_load_failed(&self) -> bool { 1018 + matches!(self.picker_load_state, PickerLoadState::Failed { .. }) 1019 + } 1020 + 1021 + pub(crate) fn pending_picker_mode(&self) -> Option<FilePickerMode> { 1022 + match &self.picker_load_state { 1023 + PickerLoadState::Loading { mode, .. } | PickerLoadState::Failed { mode, .. } => { 1024 + Some(*mode) 1025 + } 1026 + PickerLoadState::Idle => match self.pending_picker { 1027 + PendingPicker::Browser(..) => Some(FilePickerMode::Browser), 1028 + PendingPicker::Fuzzy(..) => Some(FilePickerMode::Fuzzy), 1029 + PendingPicker::None => None, 1030 + }, 1031 + } 1032 + } 1033 + 1034 + pub(crate) fn pending_picker_dir(&self) -> Option<&std::path::Path> { 1035 + match &self.picker_load_state { 1036 + PickerLoadState::Loading { dir, .. } | PickerLoadState::Failed { dir, .. } => { 1037 + Some(dir.as_path()) 1038 + } 1039 + PickerLoadState::Idle => match &self.pending_picker { 1040 + PendingPicker::Browser(dir) | PendingPicker::Fuzzy(dir) => Some(dir.as_path()), 1041 + PendingPicker::None => None, 1042 + }, 1043 + } 1044 + } 1045 + 1046 + pub(crate) fn picker_load_error(&self) -> Option<&str> { 1047 + match &self.picker_load_state { 1048 + PickerLoadState::Failed { message, .. } => Some(message.as_str()), 1049 + PickerLoadState::Idle | PickerLoadState::Loading { .. } => None, 1050 + } 1051 + } 1052 + 1053 + fn install_loaded_file_picker( 1054 + &mut self, 1055 + dir: PathBuf, 1056 + mode: FilePickerMode, 1057 + result: PickerIndexResult, 1058 + ) -> bool { 1059 + self.file_picker.open = true; 1060 + self.file_picker.mode = mode; 1061 + self.file_picker.dir = dir; 1062 + self.file_picker.entries = result.entries; 1063 + self.file_picker.query.clear(); 1064 + self.file_picker.index = 0; 1065 + self.file_picker.truncation = if mode == FilePickerMode::Fuzzy { 1066 + result.truncated 1067 + } else { 1068 + None 1069 + }; 1070 + self.refresh_file_picker_matches(); 1071 + true 1072 + } 1073 + 1074 + pub(crate) fn poll_picker_loading(&mut self) -> bool { 1075 + let state = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle); 1076 + match state { 1077 + PickerLoadState::Loading { 1078 + mode, 1079 + dir, 1080 + started_at, 1081 + receiver, 1082 + mut pending_result, 1083 + } => { 1084 + if pending_result.is_none() { 1085 + pending_result = match receiver.try_recv() { 1086 + Ok(result) => { 1087 + crate::runtime::debug_log( 1088 + self.debug_input, 1089 + &format!( 1090 + "picker_loading worker_finished mode={mode:?} dir={}", 1091 + dir.display() 1092 + ), 1093 + ); 1094 + Some(result) 1095 + } 1096 + Err(TryRecvError::Empty) => None, 1097 + Err(TryRecvError::Disconnected) => Some(Err(std::io::Error::other( 1098 + "Picker loading worker disconnected", 1099 + ))), 1100 + }; 1101 + } 1102 + 1103 + if started_at.elapsed() < Self::min_picker_loading_duration() { 1104 + self.picker_load_state = PickerLoadState::Loading { 1105 + mode, 1106 + dir, 1107 + started_at, 1108 + receiver, 1109 + pending_result, 1110 + }; 1111 + return false; 1112 + } 1113 + 1114 + match pending_result { 1115 + Some(Ok(result)) => { 1116 + crate::runtime::debug_log( 1117 + self.debug_input, 1118 + &format!( 1119 + "picker_loading install mode={mode:?} dir={} entries={}", 1120 + dir.display(), 1121 + result.entries.len() 1122 + ), 1123 + ); 1124 + self.install_loaded_file_picker(dir, mode, result) 1125 + } 1126 + Some(Err(err)) => { 1127 + crate::runtime::debug_log( 1128 + self.debug_input, 1129 + &format!( 1130 + "picker_loading failed mode={mode:?} dir={} error={}", 1131 + dir.display(), 1132 + err 1133 + ), 1134 + ); 1135 + self.picker_load_state = PickerLoadState::Failed { 1136 + mode, 1137 + dir, 1138 + message: err.to_string(), 1139 + }; 1140 + true 1141 + } 1142 + None => { 1143 + self.picker_load_state = PickerLoadState::Loading { 1144 + mode, 1145 + dir, 1146 + started_at, 1147 + receiver, 1148 + pending_result: None, 1149 + }; 1150 + false 1151 + } 1152 + } 1153 + } 1154 + PickerLoadState::Failed { .. } => { 1155 + self.picker_load_state = state; 1156 + false 1157 + } 1158 + PickerLoadState::Idle => { 1159 + self.picker_load_state = PickerLoadState::Idle; 1160 + false 1161 + } 1162 + } 1163 + } 1164 + 1165 + #[cfg(test)] 1166 + pub(crate) fn age_picker_loading_by(&mut self, duration: Duration) { 1167 + if let PickerLoadState::Loading { 1168 + mode, 1169 + dir, 1170 + started_at, 1171 + receiver, 1172 + pending_result, 1173 + } = std::mem::replace(&mut self.picker_load_state, PickerLoadState::Idle) 1174 + { 1175 + let adjusted = started_at.checked_sub(duration).unwrap_or(started_at); 1176 + self.picker_load_state = PickerLoadState::Loading { 1177 + mode, 1178 + dir, 1179 + started_at: adjusted, 1180 + receiver, 1181 + pending_result, 1182 + }; 1183 + } 1184 + } 1185 + 825 1186 fn open_file_picker_with_mode(&mut self, dir: PathBuf, mode: FilePickerMode) -> bool { 826 - let entries = match mode { 827 - FilePickerMode::Browser => Self::build_file_picker_entries(&dir), 1187 + let result = match mode { 1188 + FilePickerMode::Browser => { 1189 + Self::build_file_picker_entries(&dir).map(|entries| PickerIndexResult { 1190 + entries, 1191 + truncated: None, 1192 + }) 1193 + } 828 1194 FilePickerMode::Fuzzy => Self::build_fuzzy_file_picker_entries(&dir), 829 1195 }; 830 1196 831 - match entries { 832 - Ok(entries) => { 833 - self.file_picker.open = true; 834 - self.file_picker.mode = mode; 835 - self.file_picker.dir = dir; 836 - self.file_picker.entries = entries; 837 - self.file_picker.query.clear(); 838 - self.file_picker.index = 0; 839 - self.refresh_file_picker_matches(); 840 - true 841 - } 1197 + match result { 1198 + Ok(result) => self.install_loaded_file_picker(dir, mode, result), 842 1199 Err(_) => false, 843 1200 } 844 1201 } ··· 881 1238 882 1239 pub(crate) fn file_picker_query(&self) -> &str { 883 1240 &self.file_picker.query 1241 + } 1242 + 1243 + pub(crate) fn file_picker_truncation(&self) -> Option<PickerIndexTruncation> { 1244 + self.file_picker.truncation 884 1245 } 885 1246 886 1247 pub(crate) fn move_file_picker_up(&mut self) {
+57 -7
src/main.rs
··· 31 31 #[cfg(test)] 32 32 pub(crate) use markdown::{display_width, line_plain_text}; 33 33 #[cfg(test)] 34 + pub(crate) use read_stdin_limited as read_stdin_with_limit; 35 + #[cfg(test)] 34 36 pub(crate) use runtime::should_handle_key; 35 37 #[cfg(test)] 36 38 pub(crate) use theme::{parse_theme_preset, theme_preset_label, ThemePreset, THEME_PRESETS}; 37 39 #[cfg(test)] 38 40 pub(crate) use update::{ 39 - asset_name_for_target, expected_asset_download_url, find_expected_checksum, 40 - is_newer_version, validate_download_size, validate_sha256_hex, 41 + asset_name_for_target, expected_asset_download_url, find_expected_checksum, is_newer_version, 42 + validate_download_size, validate_sha256_hex, 41 43 }; 42 - #[cfg(test)] 43 - pub(crate) use read_stdin_limited as read_stdin_with_limit; 44 44 45 45 fn read_stdin_limited<R: Read>(reader: &mut R, max_bytes: usize) -> Result<String> { 46 46 let mut buf = Vec::with_capacity(max_bytes.min(8192)); ··· 85 85 theme, 86 86 .. 87 87 } = options; 88 + runtime::debug_log(debug_input, &format!("main start args={args:?}")); 88 89 set_theme_preset(theme); 89 90 90 91 if debug_input { ··· 135 136 let ss = SyntaxSet::load_defaults_newlines(); 136 137 let ts = ThemeSet::load_defaults(); 137 138 let theme = current_syntect_theme(&ts).clone(); 139 + runtime::debug_log( 140 + debug_input, 141 + &format!( 142 + "main input_ready filename={filename} filepath={} picker={} watch={}", 143 + filepath 144 + .as_ref() 145 + .map(|path| path.display().to_string()) 146 + .unwrap_or_else(|| "<none>".to_string()), 147 + picker, 148 + watch 149 + ), 150 + ); 138 151 139 152 let last_file_state = filepath.as_ref().and_then(read_file_state); 140 153 let last_content_hash = hash_str(&src); ··· 154 167 ); 155 168 app.set_last_content_hash(last_content_hash); 156 169 if let Some(dir) = open_browser_picker_dir { 157 - app.open_file_picker(dir); 170 + app.queue_file_picker(dir); 158 171 } 159 172 if let Some(dir) = open_fuzzy_picker_dir { 160 - app.open_fuzzy_file_picker(dir); 173 + app.queue_fuzzy_file_picker(dir); 161 174 } 175 + runtime::debug_log( 176 + debug_input, 177 + &format!( 178 + "main app_ready pending_picker={} picker_loading={}", 179 + app.has_pending_picker(), 180 + app.is_picker_loading() 181 + ), 182 + ); 162 183 163 184 let mut stdout = io::stdout(); 185 + runtime::debug_log(debug_input, "terminal enter start"); 164 186 let mut session = TerminalSession::enter(&mut stdout)?; 187 + runtime::debug_log(debug_input, "terminal enter done"); 165 188 let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; 166 - let run_result = run(&mut terminal, &mut app, &ss, &ts); 189 + runtime::debug_log(debug_input, "terminal new done"); 190 + terminal.clear()?; 191 + runtime::debug_log(debug_input, "terminal clear done"); 192 + let initial_draw_result = (|| -> Result<()> { 193 + let area = terminal.size()?; 194 + runtime::debug_log( 195 + debug_input, 196 + &format!( 197 + "initial_draw size width={} height={}", 198 + area.width, area.height 199 + ), 200 + ); 201 + runtime::prepare_initial_picker_state(area.width as usize, &mut app, &ss, &ts)?; 202 + runtime::debug_log(debug_input, "initial_draw draw start"); 203 + terminal.draw(|f| render::ui(f, &mut app))?; 204 + runtime::debug_log(debug_input, "initial_draw draw done"); 205 + session.finish_initial_draw(&mut terminal)?; 206 + runtime::debug_log(debug_input, "initial_draw sync end done"); 207 + Ok(()) 208 + })(); 209 + let run_result = match initial_draw_result { 210 + Ok(()) => { 211 + runtime::debug_log(debug_input, "run loop start"); 212 + run(&mut terminal, &mut app, &ss, &ts, true) 213 + } 214 + Err(err) => Err(err), 215 + }; 216 + runtime::debug_log(debug_input, "run loop end"); 167 217 let restore_result = session.restore(&mut terminal); 168 218 finish_with_restore(run_result, restore_result) 169 219 }
+22 -19
src/markdown.rs
··· 312 312 fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { 313 313 let theme = &app_theme().markdown; 314 314 if in_bq { 315 - vec![Span::styled("▏ ", Style::default().fg(theme.blockquote_marker))] 315 + vec![Span::styled( 316 + "▏ ", 317 + Style::default().fg(theme.blockquote_marker), 318 + )] 316 319 } else { 317 320 vec![] 318 321 } ··· 419 422 let token_width = display_width(token); 420 423 if token_is_space { 421 424 let keep_styled_padding = style.bg.is_some(); 422 - if (*body_started || keep_styled_padding) && *current_width + token_width <= max_width 425 + if (*body_started || keep_styled_padding) 426 + && *current_width + token_width <= max_width 423 427 { 424 428 current_prefix.push(Span::styled(std::mem::take(token), style)); 425 429 *current_width += token_width; ··· 552 556 } 553 557 } 554 558 555 - fn trim_paragraph_gap_before_block( 556 - lines: &mut Vec<Line<'static>>, 557 - last_block: LastBlock, 558 - ) { 559 + fn trim_paragraph_gap_before_block(lines: &mut Vec<Line<'static>>, last_block: LastBlock) { 559 560 if last_block == LastBlock::Paragraph 560 - && lines.last().is_some_and(|line| line_plain_text(line).is_empty()) 561 + && lines 562 + .last() 563 + .is_some_and(|line| line_plain_text(line).is_empty()) 561 564 { 562 565 lines.pop(); 563 566 } ··· 684 687 }; 685 688 686 689 if inline.in_strong { 687 - style = style 688 - .fg(theme.strong_text) 689 - .add_modifier(Modifier::BOLD); 690 + style = style.fg(theme.strong_text).add_modifier(Modifier::BOLD); 690 691 } 691 692 if inline.in_em { 692 693 style = style.add_modifier(Modifier::ITALIC); ··· 1095 1096 .copied() 1096 1097 .enumerate() 1097 1098 .take(col_count) 1098 - .map(|(ci, width)| wrap_table_cell(row.get(ci).map(|s| s.as_str()).unwrap_or(""), width)) 1099 + .map(|(ci, width)| { 1100 + wrap_table_cell(row.get(ci).map(|s| s.as_str()).unwrap_or(""), width) 1101 + }) 1099 1102 .collect(); 1100 - let row_height = wrapped_cells.iter().map(|lines| lines.len()).max().unwrap_or(1); 1103 + let row_height = wrapped_cells 1104 + .iter() 1105 + .map(|lines| lines.len()) 1106 + .max() 1107 + .unwrap_or(1); 1101 1108 1102 1109 for line_idx in 0..row_height { 1103 1110 let mut spans = vec![Span::raw(ind), Span::styled("│", border)]; ··· 1223 1230 .iter() 1224 1231 .enumerate() 1225 1232 .filter(|(idx, width)| **width > min_widths[*idx]) 1226 - .max_by_key(|(_, width)| **width) else { 1233 + .max_by_key(|(_, width)| **width) 1234 + else { 1227 1235 break; 1228 1236 }; 1229 1237 col_widths[idx] -= 1; ··· 1347 1355 if table.is_some() && handle_table_event(&mut table, &ev, &mut lines, render_width) { 1348 1356 continue; 1349 1357 } 1350 - if handle_inline_style_event( 1351 - &ev, 1352 - &mut inline, 1353 - &mut spans, 1354 - theme_colors, 1355 - ) { 1358 + if handle_inline_style_event(&ev, &mut inline, &mut spans, theme_colors) { 1356 1359 continue; 1357 1360 } 1358 1361
+142 -23
src/render.rs
··· 8 8 style::{Color, Modifier, Style}, 9 9 text::{Line, Span}, 10 10 widgets::{ 11 - Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, 12 - ScrollbarState, Wrap, 11 + Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, 12 + Wrap, 13 13 }, 14 14 Frame, 15 15 }; ··· 24 24 .constraints([Constraint::Min(0), Constraint::Length(1)]) 25 25 .split(area); 26 26 27 - let (toc_area, content_area): (Option<Rect>, Rect) = 28 - if app.is_toc_visible() && app.has_toc() { 27 + let (toc_area, content_area): (Option<Rect>, Rect) = if app.is_toc_visible() && app.has_toc() { 29 28 let cols = Layout::default() 30 29 .direction(Direction::Horizontal) 31 30 .constraints([Constraint::Length(30), Constraint::Min(0)]) ··· 45 44 46 45 if app.is_help_open() { 47 46 render_help_popup(f); 47 + } else if app.is_picker_loading() || app.is_picker_load_failed() { 48 + render_picker_loading(f, app); 48 49 } else if app.is_file_picker_open() { 49 50 render_file_picker(f, app); 50 51 } else if app.is_theme_picker_open() { ··· 319 320 .fg(theme.markdown.heading_2) 320 321 .add_modifier(Modifier::BOLD); 321 322 let lines = vec![ 322 - Line::from(vec![Span::styled( 323 - version_text().to_string(), 324 - title_style, 325 - )]), 323 + Line::from(vec![Span::styled(version_text().to_string(), title_style)]), 326 324 Line::from(vec![Span::styled( 327 325 "Keyboard shortcuts", 328 326 Style::default().fg(theme.ui.status_shortcut_fg), ··· 351 349 Span::styled("top/bottom", text_style), 352 350 ]), 353 351 Line::from(""), 354 - Line::from(vec![Span::styled( 355 - "Actions", 356 - section_style, 357 - )]), 352 + Line::from(vec![Span::styled("Actions", section_style)]), 358 353 Line::from(vec![ 359 354 Span::styled("r ", key_style), 360 355 Span::styled("reload (watch)", text_style), ··· 374 369 Span::styled("theme picker", text_style), 375 370 ]), 376 371 Line::from(""), 377 - Line::from(vec![Span::styled( 378 - "Esc or ? to close", 379 - footer_style, 380 - )]), 372 + Line::from(vec![Span::styled("Esc or ? to close", footer_style)]), 381 373 ]; 382 374 383 375 f.render_widget(Clear, area); ··· 480 472 let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 481 473 let inner_height = area.height.saturating_sub(2) as usize; 482 474 let header_lines = if app.is_fuzzy_file_picker() { 4 } else { 3 }; 483 - let max_visible_slots = if app.is_fuzzy_file_picker() { 12 } else { 13 }; 475 + let total = app.file_picker_filtered_indices().len(); 476 + let truncation_message = picker_truncation_message(app.file_picker_truncation()); 477 + let max_visible_slots = if app.is_fuzzy_file_picker() { 478 + if truncation_message.is_some() { 479 + 11 480 + } else { 481 + 12 482 + } 483 + } else { 484 + 13 485 + }; 486 + let reserved_footer_lines = if truncation_message.is_some() { 3 } else { 2 }; 484 487 let visible_slots = inner_height 485 - .saturating_sub(header_lines + 1) 488 + .saturating_sub(header_lines + reserved_footer_lines) 486 489 .min(max_visible_slots); 487 - let total = app.file_picker_filtered_indices().len(); 488 490 let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 489 491 0 490 492 } else { ··· 554 556 }; 555 557 let marker = if selected { "▸ " } else { " " }; 556 558 let label_spans = if app.is_fuzzy_file_picker() { 557 - highlighted_picker_label(entry.label(), app.file_picker_match_positions(actual_idx), bg, selected) 559 + highlighted_picker_label( 560 + entry.label(), 561 + app.file_picker_match_positions(actual_idx), 562 + bg, 563 + selected, 564 + ) 558 565 } else { 559 566 vec![Span::styled( 560 567 entry.label().to_string(), ··· 584 591 } 585 592 } 586 593 594 + while lines.len() < inner_height.saturating_sub(reserved_footer_lines) { 595 + lines.push(Line::from("")); 596 + } 597 + 598 + if let Some(message) = truncation_message { 599 + lines.push(Line::from(vec![Span::styled( 600 + "", 601 + Style::default().fg(theme.ui.toc_primary_inactive), 602 + )])); 603 + lines.push(Line::from(vec![Span::styled( 604 + message, 605 + Style::default().fg(theme.markdown.heading_3), 606 + )])); 607 + } else { 608 + lines.push(Line::from("")); 609 + } 610 + 611 + lines.push(Line::from(vec![Span::styled( 612 + if app.is_fuzzy_file_picker() { 613 + "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 614 + } else { 615 + "enter open • backspace up • ctrl+c quit" 616 + }, 617 + footer_style.bg(theme.ui.toc_bg), 618 + )])); 619 + 620 + f.render_widget(Clear, area); 621 + f.render_widget( 622 + Paragraph::new(lines).block( 623 + Block::default() 624 + .title("─ Files ") 625 + .borders(Borders::ALL) 626 + .border_style(Style::default().fg(theme.ui.toc_border)) 627 + .style(Style::default().bg(theme.ui.toc_bg)) 628 + .padding(Padding::new(1, 1, 0, 0)), 629 + ), 630 + area, 631 + ); 632 + } 633 + 634 + fn render_picker_loading(f: &mut Frame, app: &App) { 635 + let theme = app_theme(); 636 + let area = centered_rect(78, 20, f.area()); 637 + let title_style = Style::default() 638 + .fg(theme.markdown.heading_2) 639 + .add_modifier(Modifier::BOLD); 640 + let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 641 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 642 + let is_failed = app.is_picker_load_failed(); 643 + let is_fuzzy = matches!( 644 + app.pending_picker_mode(), 645 + Some(crate::app::FilePickerMode::Fuzzy) 646 + ); 647 + let inner_height = area.height.saturating_sub(2) as usize; 648 + let message = if is_failed { 649 + app.picker_load_error().unwrap_or("Failed to load files") 650 + } else { 651 + "Indexing markdown files..." 652 + }; 653 + 654 + let mut lines = vec![ 655 + Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 656 + Line::from(vec![ 657 + Span::styled("Dir: ", section_style), 658 + Span::styled( 659 + app.pending_picker_dir() 660 + .map(|dir| dir.display().to_string()) 661 + .unwrap_or_else(|| ".".to_string()), 662 + Style::default().fg(theme.ui.toc_primary_inactive), 663 + ), 664 + ]), 665 + ]; 666 + 667 + if is_fuzzy { 668 + lines.push(Line::from(vec![ 669 + Span::styled("Query: ", section_style), 670 + Span::styled( 671 + " type to filter ".to_string(), 672 + Style::default() 673 + .fg(theme.ui.toc_primary_inactive) 674 + .bg(theme.markdown.inline_code_bg), 675 + ), 676 + ])); 677 + } 678 + 679 + lines.push(Line::from("")); 680 + lines.push(Line::from(vec![Span::styled( 681 + message, 682 + Style::default().fg(theme.ui.toc_primary_inactive), 683 + )])); 684 + 587 685 while lines.len() < inner_height.saturating_sub(2) { 588 686 lines.push(Line::from("")); 589 687 } 688 + 590 689 lines.push(Line::from("")); 591 690 lines.push(Line::from(vec![Span::styled( 592 - if app.is_fuzzy_file_picker() { 691 + if is_fuzzy { 593 692 "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 594 693 } else { 595 694 "enter open • backspace up • ctrl+c quit" ··· 611 710 ); 612 711 } 613 712 713 + fn picker_truncation_message( 714 + truncation: Option<crate::app::PickerIndexTruncation>, 715 + ) -> Option<&'static str> { 716 + match truncation { 717 + Some(crate::app::PickerIndexTruncation::Directory) => { 718 + Some("Indexing limited: directory limit reached") 719 + } 720 + Some(crate::app::PickerIndexTruncation::File) => { 721 + Some("Indexing limited: file limit reached") 722 + } 723 + Some(crate::app::PickerIndexTruncation::Time) => { 724 + Some("Indexing limited: time limit reached") 725 + } 726 + None => None, 727 + } 728 + } 729 + 614 730 fn highlighted_picker_label( 615 731 label: &str, 616 732 match_positions: &[usize], ··· 639 755 return vec![Span::styled(label.to_string(), default_style)]; 640 756 } 641 757 642 - let match_set = match_positions.iter().copied().collect::<std::collections::BTreeSet<_>>(); 758 + let match_set = match_positions 759 + .iter() 760 + .copied() 761 + .collect::<std::collections::BTreeSet<_>>(); 643 762 let mut spans = Vec::new(); 644 763 let mut buffer = String::new(); 645 764 let mut current_matched = None; ··· 728 847 } 729 848 730 849 let mut sections = vec![left_section, status_shortcuts_section(app, bar_bg)]; 731 - if !app.is_file_picker_open() { 850 + if !app.is_file_picker_open() && !app.is_picker_loading() { 732 851 sections.push(status_percent_section(pct, bar_bg)); 733 852 } 734 853
+91 -20
src/runtime.rs
··· 9 9 fs::OpenOptions, 10 10 io, 11 11 io::Write, 12 - time::{Duration, Instant}, 12 + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, 13 13 }; 14 14 use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; 15 15 ··· 21 21 if !enabled { 22 22 return; 23 23 } 24 + let timestamp = SystemTime::now() 25 + .duration_since(UNIX_EPOCH) 26 + .map(|duration| duration.as_millis()) 27 + .unwrap_or(0); 24 28 if let Ok(mut file) = OpenOptions::new() 25 29 .create(true) 26 30 .append(true) 27 31 .open("leaf-debug.log") 28 32 { 29 - let _ = writeln!(file, "{message}"); 33 + let _ = writeln!(file, "[{timestamp}] {message}"); 30 34 } 31 35 } 32 36 37 + pub(crate) fn prepare_initial_picker_state( 38 + area_width: usize, 39 + app: &mut App, 40 + ss: &SyntaxSet, 41 + themes: &ThemeSet, 42 + ) -> Result<()> { 43 + debug_log( 44 + app.debug_input_enabled(), 45 + &format!("prepare_initial_picker_state start area_width={area_width}"), 46 + ); 47 + sync_render_width_for_app(area_width, app, ss, themes); 48 + if app.has_pending_picker() && !app.is_picker_loading() { 49 + let _ = app.start_pending_picker_loading(); 50 + } 51 + debug_log( 52 + app.debug_input_enabled(), 53 + &format!( 54 + "prepare_initial_picker_state end picker_loading={} pending_picker={}", 55 + app.is_picker_loading(), 56 + app.has_pending_picker() 57 + ), 58 + ); 59 + Ok(()) 60 + } 61 + 33 62 pub(crate) fn run( 34 63 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 35 64 app: &mut App, 36 65 ss: &SyntaxSet, 37 66 themes: &ThemeSet, 67 + initial_draw_done: bool, 38 68 ) -> Result<()> { 39 69 const WATCH_INTERVAL: Duration = Duration::from_millis(250); 40 70 const FLASH_DURATION: Duration = Duration::from_millis(1500); 41 71 const MOUSE_SCROLL_STEP: usize = 3; 42 72 const RESIZE_DEBOUNCE: Duration = Duration::from_millis(120); 43 - let mut needs_redraw = true; 73 + const PICKER_LOAD_POLL_INTERVAL: Duration = Duration::from_millis(50); 74 + let mut needs_redraw = !initial_draw_done; 44 75 let mut pending_resize: Option<Instant> = None; 45 76 sync_render_width(terminal, app, ss, themes)?; 46 77 47 78 loop { 79 + if app.poll_picker_loading() { 80 + needs_redraw = true; 81 + } 82 + 48 83 if needs_redraw { 49 84 terminal.draw(|f| ui(f, app))?; 50 85 needs_redraw = false; ··· 58 93 let elapsed = started.elapsed(); 59 94 (elapsed < RESIZE_DEBOUNCE).then_some(RESIZE_DEBOUNCE - elapsed) 60 95 }); 61 - let poll_timeout = [if app.is_watch_enabled() { 62 - Some(WATCH_INTERVAL) 63 - } else { 64 - None 65 - }, flash_timeout, resize_timeout] 96 + let poll_timeout = [ 97 + if app.is_watch_enabled() { 98 + Some(WATCH_INTERVAL) 99 + } else { 100 + None 101 + }, 102 + if app.is_picker_loading() { 103 + Some(PICKER_LOAD_POLL_INTERVAL) 104 + } else { 105 + None 106 + }, 107 + flash_timeout, 108 + resize_timeout, 109 + ] 66 110 .into_iter() 67 111 .flatten() 68 112 .min() ··· 100 144 KeyCode::Esc | KeyCode::Char('?') => app.close_help(), 101 145 _ => state_changed = false, 102 146 } 147 + } else if app.is_picker_loading() { 148 + match key.code { 149 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 150 + break; 151 + } 152 + _ => state_changed = false, 153 + } 154 + } else if app.is_picker_load_failed() { 155 + match key.code { 156 + KeyCode::Esc | KeyCode::Enter => break, 157 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 158 + break; 159 + } 160 + _ => state_changed = false, 161 + } 103 162 } else if app.is_file_picker_open() { 104 163 match key.code { 105 164 KeyCode::Char('?') => app.open_help(), ··· 116 175 KeyCode::Down if app.is_fuzzy_file_picker() => { 117 176 app.move_file_picker_down() 118 177 } 119 - KeyCode::Up if app.is_fuzzy_file_picker() => { 120 - app.move_file_picker_up() 121 - } 178 + KeyCode::Up if app.is_fuzzy_file_picker() => app.move_file_picker_up(), 122 179 KeyCode::Esc => { 123 - if app.is_browser_file_picker() || app.file_picker_query().is_empty() { 180 + if app.is_browser_file_picker() 181 + || app.file_picker_query().is_empty() 182 + { 124 183 state_changed = false; 125 184 } else { 126 185 app.clear_file_picker_query(); ··· 212 271 KeyCode::Char('r') if app.is_watch_enabled() => { 213 272 app.request_reload(ss, themes); 214 273 } 215 - KeyCode::Char('f') 216 - if key.modifiers.contains(KeyModifiers::CONTROL) => 217 - { 274 + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { 218 275 app.begin_search() 219 276 } 220 277 KeyCode::Char('/') => app.begin_search(), ··· 300 357 themes: &ThemeSet, 301 358 ) -> Result<bool> { 302 359 let area = terminal.size()?; 360 + Ok(sync_render_width_for_app( 361 + area.width as usize, 362 + app, 363 + ss, 364 + themes, 365 + )) 366 + } 367 + 368 + fn sync_render_width_for_app( 369 + area_width: usize, 370 + app: &mut App, 371 + ss: &SyntaxSet, 372 + themes: &ThemeSet, 373 + ) -> bool { 303 374 let content_width = if app.is_toc_visible() && app.has_toc() { 304 - area.width.saturating_sub(30) 375 + area_width.saturating_sub(30) 305 376 } else { 306 - area.width 377 + area_width 307 378 }; 308 379 let effective_width = content_width 309 - .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 310 - .saturating_sub(SCROLLBAR_WIDTH); 311 - Ok(app.sync_render_width(effective_width as usize, ss, themes)) 380 + .saturating_sub(CONTENT_HORIZONTAL_PADDING as usize * 2) 381 + .saturating_sub(SCROLLBAR_WIDTH as usize); 382 + app.sync_render_width(effective_width, ss, themes) 312 383 }
+53 -8
src/terminal.rs
··· 2 2 use crossterm::{ 3 3 event::{DisableMouseCapture, EnableMouseCapture}, 4 4 execute, 5 - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 5 + terminal::{ 6 + disable_raw_mode, enable_raw_mode, BeginSynchronizedUpdate, EndSynchronizedUpdate, 7 + EnterAlternateScreen, LeaveAlternateScreen, 8 + }, 6 9 }; 7 10 use ratatui::{backend::CrosstermBackend, Terminal}; 8 11 use std::io; ··· 10 13 pub(crate) struct TerminalSession { 11 14 raw_enabled: bool, 12 15 screen_enabled: bool, 16 + synchronized_update: bool, 17 + alternate_screen_enabled: bool, 18 + mouse_capture_enabled: bool, 13 19 } 14 20 15 21 pub(crate) fn cleanup_terminal_state<F, G>( ··· 53 59 let mut session = Self { 54 60 raw_enabled: true, 55 61 screen_enabled: false, 62 + synchronized_update: false, 63 + alternate_screen_enabled: false, 64 + mouse_capture_enabled: false, 56 65 }; 57 - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 66 + execute!(stdout, BeginSynchronizedUpdate)?; 67 + session.synchronized_update = true; 68 + execute!(stdout, EnterAlternateScreen)?; 58 69 session.screen_enabled = true; 70 + session.alternate_screen_enabled = true; 71 + execute!(stdout, EnableMouseCapture)?; 72 + session.mouse_capture_enabled = true; 59 73 Ok(session) 60 74 } 61 75 76 + pub(crate) fn finish_initial_draw( 77 + &mut self, 78 + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 79 + ) -> Result<()> { 80 + if self.synchronized_update { 81 + execute!(terminal.backend_mut(), EndSynchronizedUpdate)?; 82 + self.synchronized_update = false; 83 + } 84 + Ok(()) 85 + } 86 + 62 87 pub(crate) fn restore( 63 88 &mut self, 64 89 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 65 90 ) -> Result<()> { 91 + if self.synchronized_update { 92 + execute!(terminal.backend_mut(), EndSynchronizedUpdate)?; 93 + self.synchronized_update = false; 94 + } 95 + if self.mouse_capture_enabled { 96 + execute!(terminal.backend_mut(), DisableMouseCapture)?; 97 + self.mouse_capture_enabled = false; 98 + } 99 + let alternate_screen_enabled = self.alternate_screen_enabled; 66 100 cleanup_terminal_state( 67 101 &mut self.screen_enabled, 68 102 &mut self.raw_enabled, 69 103 || { 70 - execute!( 71 - terminal.backend_mut(), 72 - LeaveAlternateScreen, 73 - DisableMouseCapture 74 - )?; 104 + if alternate_screen_enabled { 105 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 106 + } 75 107 Ok(()) 76 108 }, 77 109 || { ··· 86 118 87 119 impl Drop for TerminalSession { 88 120 fn drop(&mut self) { 121 + if self.synchronized_update { 122 + let mut stdout = io::stdout(); 123 + let _ = execute!(stdout, EndSynchronizedUpdate); 124 + self.synchronized_update = false; 125 + } 126 + if self.mouse_capture_enabled { 127 + let mut stdout = io::stdout(); 128 + let _ = execute!(stdout, DisableMouseCapture); 129 + self.mouse_capture_enabled = false; 130 + } 131 + let alternate_screen_enabled = self.alternate_screen_enabled; 89 132 let _ = cleanup_terminal_state( 90 133 &mut self.screen_enabled, 91 134 &mut self.raw_enabled, 92 135 || { 93 136 let mut stdout = io::stdout(); 94 - execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?; 137 + if alternate_screen_enabled { 138 + execute!(stdout, LeaveAlternateScreen)?; 139 + } 95 140 Ok(()) 96 141 }, 97 142 || {
+300 -33
src/tests.rs
··· 1 - use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 2 - use crate::update::TestAsset; 3 - use crate::*; 4 1 use crate::app::FileChange; 5 2 use crate::markdown::{ 6 3 hash_str, parse_markdown, parse_markdown_with_width, read_file_state, resolve_syntax, 7 4 }; 5 + use crate::theme::{current_theme_preset, set_theme_preset, theme_preset_index}; 6 + use crate::update::TestAsset; 7 + use crate::*; 8 8 use crossterm::event::KeyEventKind; 9 9 use ratatui::backend::TestBackend; 10 10 use ratatui::{text::Line, widgets::Paragraph, Terminal}; 11 11 use std::{ 12 12 fs, 13 + path::PathBuf, 13 14 sync::{Mutex, MutexGuard}, 14 15 time::{SystemTime, UNIX_EPOCH}, 15 16 }; ··· 71 72 THEME_TEST_MUTEX.lock().unwrap() 72 73 } 73 74 75 + fn unique_temp_dir(prefix: &str) -> PathBuf { 76 + let unique = SystemTime::now() 77 + .duration_since(UNIX_EPOCH) 78 + .unwrap() 79 + .as_nanos(); 80 + std::env::temp_dir().join(format!("{prefix}-{unique}")) 81 + } 82 + 74 83 #[test] 75 84 fn search_matches_across_span_boundaries() { 76 85 let (ss, theme) = test_assets(); ··· 81 90 app.run_search(); 82 91 83 92 assert_eq!(app.search_match_count(), 1); 84 - assert!( 85 - line_plain_text(app.line(app.search_matches()[0]).unwrap()).contains("hello world") 86 - ); 93 + assert!(line_plain_text(app.line(app.search_matches()[0]).unwrap()).contains("hello world")); 87 94 } 88 95 89 96 #[test] ··· 97 104 fn stdin_read_is_rejected_when_over_limit() { 98 105 let mut cursor = std::io::Cursor::new(vec![b'a'; 5]); 99 106 let err = read_stdin_with_limit(&mut cursor, 4).unwrap_err(); 100 - assert!( 101 - err.to_string() 102 - .contains("stdin exceeds the maximum supported size") 103 - ); 107 + assert!(err 108 + .to_string() 109 + .contains("stdin exceeds the maximum supported size")); 104 110 } 105 111 106 112 #[test] ··· 151 157 152 158 #[test] 153 159 fn asset_name_matches_supported_release_targets() { 154 - assert_eq!(asset_name_for_target("macos", "x86_64"), Some("leaf-macos-x86_64")); 155 - assert_eq!(asset_name_for_target("macos", "aarch64"), Some("leaf-macos-arm64")); 156 - assert_eq!(asset_name_for_target("linux", "x86_64"), Some("leaf-linux-x86_64")); 157 - assert_eq!(asset_name_for_target("linux", "aarch64"), Some("leaf-linux-arm64")); 158 - assert_eq!(asset_name_for_target("android", "aarch64"), Some("leaf-android-arm64")); 160 + assert_eq!( 161 + asset_name_for_target("macos", "x86_64"), 162 + Some("leaf-macos-x86_64") 163 + ); 164 + assert_eq!( 165 + asset_name_for_target("macos", "aarch64"), 166 + Some("leaf-macos-arm64") 167 + ); 168 + assert_eq!( 169 + asset_name_for_target("linux", "x86_64"), 170 + Some("leaf-linux-x86_64") 171 + ); 172 + assert_eq!( 173 + asset_name_for_target("linux", "aarch64"), 174 + Some("leaf-linux-arm64") 175 + ); 176 + assert_eq!( 177 + asset_name_for_target("android", "aarch64"), 178 + Some("leaf-android-arm64") 179 + ); 159 180 assert_eq!( 160 181 asset_name_for_target("windows", "x86_64"), 161 182 Some("leaf-windows-x86_64.exe") ··· 229 250 230 251 #[test] 231 252 fn find_expected_checksum_rejects_missing_or_invalid_entries() { 232 - let missing = find_expected_checksum("abcd leaf-linux-x86_64\n", "leaf-macos-arm64") 233 - .unwrap_err(); 253 + let missing = 254 + find_expected_checksum("abcd leaf-linux-x86_64\n", "leaf-macos-arm64").unwrap_err(); 234 255 assert!(missing.to_string().contains("does not contain")); 235 256 236 - let invalid = find_expected_checksum("xyz leaf-linux-x86_64\n", "leaf-linux-x86_64") 237 - .unwrap_err(); 238 - assert!(invalid.to_string().contains("Invalid SHA256 checksum format")); 257 + let invalid = 258 + find_expected_checksum("xyz leaf-linux-x86_64\n", "leaf-linux-x86_64").unwrap_err(); 259 + assert!(invalid 260 + .to_string() 261 + .contains("Invalid SHA256 checksum format")); 239 262 } 240 263 241 264 #[test] ··· 554 577 let (ss, theme) = test_assets(); 555 578 let (lines, _) = parse_markdown("Intro paragraph\n\n- first\n- second\n", &ss, &theme); 556 579 let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 557 - let intro_idx = rendered.iter().position(|line| line == "Intro paragraph").unwrap(); 580 + let intro_idx = rendered 581 + .iter() 582 + .position(|line| line == "Intro paragraph") 583 + .unwrap(); 558 584 559 585 assert_eq!(rendered[intro_idx + 1], "• first"); 560 586 } ··· 570 596 assert!(rendered 571 597 .iter() 572 598 .any(|line| line.starts_with(" ") && line.contains("terminal is narrow"))); 573 - assert!(rendered.iter().any(|line| line.starts_with("8. Eighth item"))); 599 + assert!(rendered 600 + .iter() 601 + .any(|line| line.starts_with("8. Eighth item"))); 574 602 assert!(rendered 575 603 .iter() 576 604 .any(|line| line.starts_with(" ") && !line.starts_with("8. "))); ··· 582 610 let src = "Intro paragraph\n\n```rs\nfn main() {}\n```\n"; 583 611 let (lines, _) = parse_markdown(src, &ss, &theme); 584 612 let rendered: Vec<String> = lines.iter().map(line_plain_text).collect(); 585 - let intro_idx = rendered.iter().position(|line| line == "Intro paragraph").unwrap(); 613 + let intro_idx = rendered 614 + .iter() 615 + .position(|line| line == "Intro paragraph") 616 + .unwrap(); 586 617 587 618 assert!(rendered[intro_idx + 1].starts_with("┌─ rs ")); 588 619 } ··· 734 765 fn resolve_syntax_supports_common_language_aliases() { 735 766 let ss = SyntaxSet::load_defaults_newlines(); 736 767 737 - assert_eq!(resolve_syntax("py", &ss).name, resolve_syntax("python", &ss).name); 738 - assert_eq!(resolve_syntax("cpp", &ss).name, resolve_syntax("c++", &ss).name); 768 + assert_eq!( 769 + resolve_syntax("py", &ss).name, 770 + resolve_syntax("python", &ss).name 771 + ); 772 + assert_eq!( 773 + resolve_syntax("cpp", &ss).name, 774 + resolve_syntax("c++", &ss).name 775 + ); 739 776 assert_eq!(resolve_syntax("json", &ss).name, "JSON"); 740 - assert_eq!(resolve_syntax("ps1", &ss).name, resolve_syntax("powershell", &ss).name); 777 + assert_eq!( 778 + resolve_syntax("ps1", &ss).name, 779 + resolve_syntax("powershell", &ss).name 780 + ); 741 781 } 742 782 743 783 #[test] ··· 858 898 assert!(!labels.contains(&"ignore.txt")); 859 899 860 900 let notes_idx = labels.iter().position(|label| *label == "notes/").unwrap(); 861 - let readme_idx = labels.iter().position(|label| *label == "README.md").unwrap(); 901 + let readme_idx = labels 902 + .iter() 903 + .position(|label| *label == "README.md") 904 + .unwrap(); 862 905 assert!(notes_idx < readme_idx); 863 906 864 907 let _ = fs::remove_dir_all(root); ··· 908 951 } 909 952 910 953 #[test] 911 - fn fuzzy_file_picker_uses_breadth_first_order_with_hidden_first_per_level() { 954 + fn queued_fuzzy_picker_transitions_from_pending_to_loading_to_open() { 955 + let unique = SystemTime::now() 956 + .duration_since(UNIX_EPOCH) 957 + .unwrap() 958 + .as_nanos(); 959 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-queued-{unique}")); 960 + let _ = fs::remove_dir_all(&root); 961 + fs::create_dir_all(root.join("docs")).unwrap(); 962 + fs::write(root.join("README.md"), "# Demo\n").unwrap(); 963 + fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap(); 964 + 965 + let mut app = App::new_with_source( 966 + Vec::new(), 967 + Vec::new(), 968 + AppConfig { 969 + filename: "picker".to_string(), 970 + source: String::new(), 971 + debug_input: false, 972 + watch: false, 973 + filepath: None, 974 + last_file_state: None, 975 + }, 976 + ); 977 + 978 + app.queue_fuzzy_file_picker(root.clone()); 979 + assert!(app.has_pending_picker()); 980 + assert_eq!( 981 + app.pending_picker_mode(), 982 + Some(crate::app::FilePickerMode::Fuzzy) 983 + ); 984 + assert_eq!(app.pending_picker_dir(), Some(root.as_path())); 985 + assert!(!app.is_picker_loading()); 986 + assert!(app.start_pending_picker_loading()); 987 + assert!(app.is_picker_loading()); 988 + app.age_picker_loading_by(std::time::Duration::from_secs(1)); 989 + let mut opened = false; 990 + for _ in 0..50 { 991 + if app.poll_picker_loading() { 992 + opened = app.is_file_picker_open(); 993 + break; 994 + } 995 + std::thread::sleep(std::time::Duration::from_millis(10)); 996 + } 997 + assert!(opened); 998 + assert!(app.is_file_picker_open()); 999 + assert!(app.is_fuzzy_file_picker()); 1000 + assert!(!app.has_pending_picker()); 1001 + assert!(!app.is_picker_loading()); 1002 + 1003 + let labels: Vec<_> = app 1004 + .file_picker_filtered_indices() 1005 + .iter() 1006 + .map(|idx| app.file_picker_entries()[*idx].label()) 1007 + .collect(); 1008 + assert!(labels.contains(&"README.md")); 1009 + assert!(labels.contains(&"docs/guide.md")); 1010 + 1011 + let _ = fs::remove_dir_all(root); 1012 + } 1013 + 1014 + #[test] 1015 + fn fuzzy_file_picker_uses_depth_first_order_with_hidden_first() { 912 1016 let unique = SystemTime::now() 913 1017 .duration_since(UNIX_EPOCH) 914 1018 .unwrap() ··· 956 1060 } 957 1061 958 1062 #[test] 959 - fn fuzzy_file_picker_uses_breadth_first_file_order() { 1063 + fn fuzzy_file_picker_uses_depth_first_file_order() { 960 1064 let unique = SystemTime::now() 961 1065 .duration_since(UNIX_EPOCH) 962 1066 .unwrap() ··· 992 1096 .collect(); 993 1097 assert_eq!( 994 1098 labels, 995 - vec!["z-root.md", "a/a-child.md", "b/b-child.md", "a/deep/a-deep.md"] 1099 + vec![ 1100 + "z-root.md", 1101 + "a/a-child.md", 1102 + "a/deep/a-deep.md", 1103 + "b/b-child.md" 1104 + ] 1105 + ); 1106 + 1107 + let _ = fs::remove_dir_all(root); 1108 + } 1109 + 1110 + #[test] 1111 + fn fuzzy_file_picker_keeps_depth_first_order_when_query_is_empty() { 1112 + let unique = SystemTime::now() 1113 + .duration_since(UNIX_EPOCH) 1114 + .unwrap() 1115 + .as_nanos(); 1116 + let root = std::env::temp_dir().join(format!("leaf-fuzzy-picker-empty-query-{unique}")); 1117 + let _ = fs::remove_dir_all(&root); 1118 + fs::create_dir_all(root.join(".nvm")).unwrap(); 1119 + fs::create_dir_all(root.join("projects")).unwrap(); 1120 + fs::write(root.join(".nvm/README.md"), "# Hidden Readme\n").unwrap(); 1121 + fs::write(root.join(".nvm/ROADMAP.md"), "# Hidden Roadmap\n").unwrap(); 1122 + fs::write(root.join("projects/README.md"), "# Project Readme\n").unwrap(); 1123 + 1124 + let mut app = App::new_with_source( 1125 + Vec::new(), 1126 + Vec::new(), 1127 + AppConfig { 1128 + filename: "picker".to_string(), 1129 + source: String::new(), 1130 + debug_input: false, 1131 + watch: false, 1132 + filepath: None, 1133 + last_file_state: None, 1134 + }, 1135 + ); 1136 + 1137 + assert!(app.open_fuzzy_file_picker(root.clone())); 1138 + 1139 + let labels: Vec<_> = app 1140 + .file_picker_filtered_indices() 1141 + .iter() 1142 + .map(|idx| app.file_picker_entries()[*idx].label()) 1143 + .collect(); 1144 + assert_eq!( 1145 + labels, 1146 + vec![".nvm/README.md", ".nvm/ROADMAP.md", "projects/README.md"] 996 1147 ); 997 1148 998 1149 let _ = fs::remove_dir_all(root); ··· 1363 1514 } 1364 1515 1365 1516 #[test] 1517 + fn fuzzy_file_picker_skips_ignored_technical_directories() { 1518 + let root = unique_temp_dir("leaf-fuzzy-picker-ignore"); 1519 + let _ = fs::remove_dir_all(&root); 1520 + fs::create_dir_all(root.join(".git")).unwrap(); 1521 + fs::create_dir_all(root.join("target")).unwrap(); 1522 + fs::create_dir_all(root.join("vendor")).unwrap(); 1523 + fs::create_dir_all(root.join("var")).unwrap(); 1524 + fs::create_dir_all(root.join(".notes")).unwrap(); 1525 + fs::write(root.join(".git/ignored.md"), "# Ignored\n").unwrap(); 1526 + fs::write(root.join("target/ignored.md"), "# Ignored\n").unwrap(); 1527 + fs::write(root.join("vendor/ignored.md"), "# Ignored\n").unwrap(); 1528 + fs::write(root.join("var/ignored.md"), "# Ignored\n").unwrap(); 1529 + fs::write(root.join(".notes/kept.md"), "# Kept\n").unwrap(); 1530 + 1531 + let mut app = App::new_with_source( 1532 + Vec::new(), 1533 + Vec::new(), 1534 + AppConfig { 1535 + filename: "picker".to_string(), 1536 + source: String::new(), 1537 + debug_input: false, 1538 + watch: false, 1539 + filepath: None, 1540 + last_file_state: None, 1541 + }, 1542 + ); 1543 + 1544 + assert!(app.open_fuzzy_file_picker(root.clone())); 1545 + 1546 + let labels: Vec<_> = app 1547 + .file_picker_filtered_indices() 1548 + .iter() 1549 + .map(|idx| app.file_picker_entries()[*idx].label()) 1550 + .collect(); 1551 + assert_eq!(labels, vec![".notes/kept.md"]); 1552 + assert_eq!(app.file_picker_truncation(), None); 1553 + 1554 + let _ = fs::remove_dir_all(root); 1555 + } 1556 + 1557 + #[test] 1558 + fn fuzzy_file_picker_reports_directory_limit_truncation() { 1559 + let root = unique_temp_dir("leaf-fuzzy-picker-dir-limit"); 1560 + let _ = fs::remove_dir_all(&root); 1561 + for idx in 0..5_050usize { 1562 + let dir = root.join(format!("nested-{idx:04}")); 1563 + fs::create_dir_all(&dir).unwrap(); 1564 + fs::write(dir.join(format!("file-{idx:04}.md")), "# File\n").unwrap(); 1565 + } 1566 + 1567 + let mut app = App::new_with_source( 1568 + Vec::new(), 1569 + Vec::new(), 1570 + AppConfig { 1571 + filename: "picker".to_string(), 1572 + source: String::new(), 1573 + debug_input: false, 1574 + watch: false, 1575 + filepath: None, 1576 + last_file_state: None, 1577 + }, 1578 + ); 1579 + 1580 + assert!(app.open_fuzzy_file_picker(root.clone())); 1581 + assert_eq!( 1582 + app.file_picker_truncation(), 1583 + Some(crate::app::PickerIndexTruncation::Directory) 1584 + ); 1585 + assert!(!app.file_picker_entries().is_empty()); 1586 + 1587 + let _ = fs::remove_dir_all(root); 1588 + } 1589 + 1590 + #[test] 1591 + fn fuzzy_file_picker_reports_file_limit_truncation() { 1592 + let root = unique_temp_dir("leaf-fuzzy-picker-file-limit"); 1593 + let _ = fs::remove_dir_all(&root); 1594 + fs::create_dir_all(&root).unwrap(); 1595 + for idx in 0..10_050usize { 1596 + fs::write(root.join(format!("file-{idx:05}.md")), "# File\n").unwrap(); 1597 + } 1598 + 1599 + let mut app = App::new_with_source( 1600 + Vec::new(), 1601 + Vec::new(), 1602 + AppConfig { 1603 + filename: "picker".to_string(), 1604 + source: String::new(), 1605 + debug_input: false, 1606 + watch: false, 1607 + filepath: None, 1608 + last_file_state: None, 1609 + }, 1610 + ); 1611 + 1612 + assert!(app.open_fuzzy_file_picker(root.clone())); 1613 + assert_eq!( 1614 + app.file_picker_truncation(), 1615 + Some(crate::app::PickerIndexTruncation::File) 1616 + ); 1617 + assert_eq!(app.file_picker_entries().len(), 10_000); 1618 + 1619 + let _ = fs::remove_dir_all(root); 1620 + } 1621 + 1622 + #[test] 1366 1623 fn check_modified_detects_file_metadata_change() { 1367 1624 let (ss, theme) = test_assets(); 1368 1625 let unique = SystemTime::now() ··· 1492 1749 ); 1493 1750 app.set_last_content_hash(hash_str(&src)); 1494 1751 1495 - assert!(matches!(app.check_modified(), Some(FileChange::Metadata(_)))); 1752 + assert!(matches!( 1753 + app.check_modified(), 1754 + Some(FileChange::Metadata(_)) 1755 + )); 1496 1756 1497 1757 let _ = fs::remove_file(path); 1498 1758 } ··· 1518 1778 1519 1779 assert!(app.sync_render_width(10, &ss, &ts)); 1520 1780 assert!(!app.sync_render_width(10, &ss, &ts)); 1521 - assert_eq!(app.total(), parse_markdown_with_width(source, &ss, &theme, 20).0.len()); 1781 + assert_eq!( 1782 + app.total(), 1783 + parse_markdown_with_width(source, &ss, &theme, 20).0.len() 1784 + ); 1522 1785 } 1523 1786 1524 1787 #[test] ··· 1561 1824 .position(|line| line.contains("<code goes here>")) 1562 1825 .expect("missing code line"); 1563 1826 1564 - assert_eq!(header_idx, item_idx + 1, "expected no blank gap before code block"); 1827 + assert_eq!( 1828 + header_idx, 1829 + item_idx + 1, 1830 + "expected no blank gap before code block" 1831 + ); 1565 1832 assert!(rendered[header_idx].starts_with(" ")); 1566 1833 assert!(rendered[code_idx].starts_with(" ")); 1567 1834 }
+25 -12
src/update.rs
··· 63 63 } 64 64 65 65 fn current_asset_name() -> Result<&'static str> { 66 - asset_name_for_target(std::env::consts::OS, std::env::consts::ARCH) 67 - .ok_or_else(|| anyhow::anyhow!("Unsupported platform: {} {}", std::env::consts::OS, std::env::consts::ARCH)) 66 + asset_name_for_target(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| { 67 + anyhow::anyhow!( 68 + "Unsupported platform: {} {}", 69 + std::env::consts::OS, 70 + std::env::consts::ARCH 71 + ) 72 + }) 68 73 } 69 74 70 75 pub(crate) fn asset_name_for_target(os: &str, arch: &str) -> Option<&'static str> { ··· 191 196 Ok(client) 192 197 } 193 198 194 - fn validate_download_response(status: reqwest::StatusCode, content_length: Option<u64>) -> Result<Option<u64>> { 199 + fn validate_download_response( 200 + status: reqwest::StatusCode, 201 + content_length: Option<u64>, 202 + ) -> Result<Option<u64>> { 195 203 if status == reqwest::StatusCode::FORBIDDEN { 196 204 bail!("Release asset download was forbidden or rate-limited"); 197 205 } ··· 252 260 } 253 261 254 262 fn verify_download_checksum(path: &Path, expected_checksum: &str) -> Result<()> { 255 - let bytes = fs::read(path) 256 - .with_context(|| format!("Cannot read downloaded asset for checksum: {}", path.display()))?; 263 + let bytes = fs::read(path).with_context(|| { 264 + format!( 265 + "Cannot read downloaded asset for checksum: {}", 266 + path.display() 267 + ) 268 + })?; 257 269 let actual_checksum = format!("{:x}", Sha256::digest(&bytes)); 258 270 259 271 if actual_checksum != expected_checksum { ··· 275 287 #[cfg(unix)] 276 288 fn replace_binary(current_exe: &Path, downloaded_path: &Path) -> Result<()> { 277 289 let permissions = fs::metadata(current_exe) 278 - .with_context(|| format!("Cannot read current binary metadata: {}", current_exe.display()))? 290 + .with_context(|| { 291 + format!( 292 + "Cannot read current binary metadata: {}", 293 + current_exe.display() 294 + ) 295 + })? 279 296 .permissions(); 280 297 fs::set_permissions(downloaded_path, permissions).with_context(|| { 281 298 format!( ··· 283 300 downloaded_path.display() 284 301 ) 285 302 })?; 286 - fs::rename(downloaded_path, current_exe).with_context(|| { 287 - format!( 288 - "Cannot replace current binary at {}", 289 - current_exe.display() 290 - ) 291 - })?; 303 + fs::rename(downloaded_path, current_exe) 304 + .with_context(|| format!("Cannot replace current binary at {}", current_exe.display()))?; 292 305 Ok(()) 293 306 } 294 307