1use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::Modifier,
7 text::{Line, Span},
8 widgets::{
9 block::BorderType, Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation,
10 ScrollbarState, StatefulWidget, Widget, Wrap,
11 },
12};
13
14use crate::analyzer::{CrateInfo, DependencyKind};
15use crate::crates_io::CrateDocInfo;
16use crate::ui::theme::Theme;
17
18pub struct DependencyView<'a> {
20 crate_info: Option<&'a CrateInfo>,
21 theme: &'a Theme,
22 focused: bool,
23 scroll_offset: usize,
24 show_browser_hint: bool,
25}
26
27impl<'a> DependencyView<'a> {
28 pub fn new(theme: &'a Theme) -> Self {
29 Self {
30 crate_info: None,
31 theme,
32 focused: false,
33 scroll_offset: 0,
34 show_browser_hint: false,
35 }
36 }
37
38 pub fn scroll(mut self, offset: usize) -> Self {
39 self.scroll_offset = offset;
40 self
41 }
42
43 pub fn crate_info(mut self, info: Option<&'a CrateInfo>) -> Self {
44 self.crate_info = info;
45 self
46 }
47
48 pub fn focused(mut self, focused: bool) -> Self {
49 self.focused = focused;
50 self
51 }
52
53 pub fn show_browser_hint(mut self, show: bool) -> Self {
54 self.show_browser_hint = show;
55 self
56 }
57
58 pub fn content_height(&self) -> usize {
60 match self.crate_info {
61 None => 10,
62 Some(info) => self.build_crate_info_lines(info).len(),
63 }
64 }
65
66 fn render_empty(&self, area: Rect, buf: &mut Buffer) {
67 let block = Block::default()
68 .borders(Borders::ALL)
69 .border_type(BorderType::Rounded)
70 .border_style(self.theme.style_border())
71 .title(" ◇ Crates ");
72
73 let inner = block.inner(area);
74 block.render(area, buf);
75
76 let mut help = vec![
77 Line::from(""),
78 Line::from(Span::styled(
79 "This tab lists your project's dependencies. Select the root crate or a dependency to view its info.",
80 self.theme.style_dim(),
81 )),
82 Line::from(""),
83 Line::from(Span::styled(
84 "Open a Cargo project (directory with Cargo.toml) to see:",
85 self.theme.style_muted(),
86 )),
87 Line::from(Span::styled(" • List of dependencies (left)", self.theme.style_muted())),
88 Line::from(Span::styled(" • Root crate metadata or fetched docs from crates.io (right)", self.theme.style_muted())),
89 Line::from(""),
90 Line::from(Span::styled(
91 "Run: oracle /path/to/your/crate",
92 self.theme.style_accent(),
93 )),
94 ];
95 if self.show_browser_hint {
96 help.push(Line::from(""));
97 help.push(Line::from(vec![
98 Span::styled(" [o] ", self.theme.style_accent()),
99 Span::styled("docs.rs ", self.theme.style_dim()),
100 Span::styled(" [c] ", self.theme.style_accent()),
101 Span::styled("crates.io", self.theme.style_dim()),
102 ]));
103 }
104 Paragraph::new(help)
105 .wrap(Wrap { trim: false })
106 .render(inner, buf);
107 }
108
109 fn build_crate_info_lines(&self, info: &CrateInfo) -> Vec<Line<'static>> {
110 let mut lines = Vec::new();
111
112 lines.push(Line::from(vec![
114 Span::styled(
115 info.name.clone(),
116 self.theme
117 .style_accent_bold()
118 .add_modifier(Modifier::UNDERLINED),
119 ),
120 Span::raw(" "),
121 Span::styled(format!("v{}", info.version), self.theme.style_dim()),
122 ]));
123 lines.push(Line::from(""));
124
125 if let Some(ref desc) = info.description {
127 lines.push(Line::from(Span::styled(
128 desc.clone(),
129 self.theme.style_normal(),
130 )));
131 lines.push(Line::from(""));
132 }
133
134 if let Some(ref license) = info.license {
136 lines.push(Line::from(vec![
137 Span::styled("License: ", self.theme.style_dim()),
138 Span::raw(license.clone()),
139 ]));
140 }
141
142 if !info.authors.is_empty() {
143 lines.push(Line::from(vec![
144 Span::styled("Authors: ", self.theme.style_dim()),
145 Span::raw(info.authors.join(", ")),
146 ]));
147 }
148
149 lines.push(Line::from(vec![
150 Span::styled("Edition: ", self.theme.style_dim()),
151 Span::raw(info.edition.clone()),
152 ]));
153
154 if let Some(ref rust_ver) = info.rust_version {
155 lines.push(Line::from(vec![
156 Span::styled("MSRV: ", self.theme.style_dim()),
157 Span::raw(rust_ver.clone()),
158 ]));
159 }
160
161 lines.push(Line::from(""));
163 if let Some(ref repo) = info.repository {
164 lines.push(Line::from(vec![
165 Span::styled("Repository: ", self.theme.style_dim()),
166 Span::styled(repo.clone(), self.theme.style_accent()),
167 ]));
168 }
169 if let Some(ref docs) = info.documentation {
170 lines.push(Line::from(vec![
171 Span::styled("Documentation: ", self.theme.style_dim()),
172 Span::styled(docs.clone(), self.theme.style_accent()),
173 ]));
174 }
175
176 if !info.features.is_empty() {
178 lines.push(Line::from(""));
179 lines.push(Line::from(Span::styled(
180 format!("Features ({}):", info.features.len()),
181 self.theme.style_dim(),
182 )));
183
184 for feature in info.features.iter().take(10) {
185 let is_default = info.default_features.contains(feature);
186 let marker = if is_default { " [default]" } else { "" };
187 lines.push(Line::from(vec![
188 Span::raw(" "),
189 Span::styled(feature.clone(), self.theme.style_string()),
190 Span::styled(marker, self.theme.style_muted()),
191 ]));
192 }
193
194 if info.features.len() > 10 {
195 lines.push(Line::from(Span::styled(
196 format!(" ... and {} more", info.features.len() - 10),
197 self.theme.style_muted(),
198 )));
199 }
200 }
201
202 lines.push(Line::from(""));
204 let normal_deps = info
205 .dependencies
206 .iter()
207 .filter(|d| d.kind == DependencyKind::Normal)
208 .count();
209 let dev_deps = info
210 .dependencies
211 .iter()
212 .filter(|d| d.kind == DependencyKind::Dev)
213 .count();
214 let build_deps = info
215 .dependencies
216 .iter()
217 .filter(|d| d.kind == DependencyKind::Build)
218 .count();
219
220 lines.push(Line::from(Span::styled(
221 "Dependencies:",
222 self.theme.style_dim(),
223 )));
224 lines.push(Line::from(vec![
225 Span::raw(" "),
226 Span::styled(format!("{}", normal_deps), self.theme.style_accent()),
227 Span::raw(" normal, "),
228 Span::styled(format!("{}", dev_deps), self.theme.style_accent()),
229 Span::raw(" dev, "),
230 Span::styled(format!("{}", build_deps), self.theme.style_accent()),
231 Span::raw(" build"),
232 ]));
233
234 lines.push(Line::from(""));
236 for dep in info
237 .dependencies
238 .iter()
239 .filter(|d| d.kind == DependencyKind::Normal)
240 .take(15)
241 {
242 let optional = if dep.optional { " (optional)" } else { "" };
243 lines.push(Line::from(vec![
244 Span::raw(" "),
245 Span::styled(dep.name.clone(), self.theme.style_type()),
246 Span::styled(format!(" {}", dep.version), self.theme.style_muted()),
247 Span::styled(optional, self.theme.style_dim()),
248 ]));
249 }
250
251 lines
252 }
253
254 fn render_crate_info(&self, info: &CrateInfo, area: Rect, buf: &mut Buffer) {
255 let lines = self.build_crate_info_lines(info);
256 let total_lines = lines.len();
257 let inner = Block::default().inner(area);
258 let viewport_height = inner.height as usize;
259 let max_scroll = total_lines.saturating_sub(viewport_height);
260 let scroll_offset = self.scroll_offset.min(max_scroll);
261
262 let block = Block::default()
263 .borders(Borders::ALL)
264 .border_type(BorderType::Rounded)
265 .border_style(if self.focused {
266 self.theme.style_border_focused()
267 } else {
268 self.theme.style_border()
269 })
270 .title(" ◇ Crates ");
271
272 let inner = block.inner(area);
273 block.render(area, buf);
274
275 let visible_lines: Vec<Line> = lines.into_iter().skip(scroll_offset).collect();
276 Paragraph::new(visible_lines)
277 .wrap(Wrap { trim: false })
278 .render(inner, buf);
279
280 if total_lines > inner.height as usize {
281 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
282 .begin_symbol(Some("↑"))
283 .end_symbol(Some("↓"));
284 let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll_offset);
285 StatefulWidget::render(scrollbar, inner, buf, &mut scrollbar_state);
286 }
287
288 if self.show_browser_hint && inner.height > 0 {
289 let hint_y = inner.y + inner.height - 1;
290 let hint_line = Line::from(vec![
291 Span::styled(" [o] ", self.theme.style_accent()),
292 Span::styled("docs.rs ", self.theme.style_dim()),
293 Span::styled(" [c] ", self.theme.style_accent()),
294 Span::styled("crates.io", self.theme.style_dim()),
295 ]);
296 Paragraph::new(hint_line).render(
297 Rect {
298 y: hint_y,
299 height: 1,
300 ..inner
301 },
302 buf,
303 );
304 }
305 }
306}
307
308impl Widget for DependencyView<'_> {
309 fn render(self, area: Rect, buf: &mut Buffer) {
310 match self.crate_info {
311 Some(info) => self.render_crate_info(info, area, buf),
312 None => self.render_empty(area, buf),
313 }
314 }
315}
316
317pub struct DependencyDocView<'a> {
319 doc: &'a CrateDocInfo,
320 theme: &'a Theme,
321 focused: bool,
322 scroll_offset: usize,
323 show_browser_hint: bool,
324}
325
326impl<'a> DependencyDocView<'a> {
327 pub fn new(theme: &'a Theme, doc: &'a CrateDocInfo) -> Self {
328 Self {
329 doc,
330 theme,
331 focused: false,
332 scroll_offset: 0,
333 show_browser_hint: false,
334 }
335 }
336
337 pub fn focused(mut self, focused: bool) -> Self {
338 self.focused = focused;
339 self
340 }
341
342 pub fn scroll(mut self, offset: usize) -> Self {
343 self.scroll_offset = offset;
344 self
345 }
346
347 pub fn show_browser_hint(mut self, show: bool) -> Self {
348 self.show_browser_hint = show;
349 self
350 }
351
352 fn section_title(&self, title: &str) -> Line<'static> {
353 Line::from(vec![
354 Span::styled("▸ ", self.theme.style_accent()),
355 Span::styled(
356 title.to_string(),
357 self.theme.style_accent().add_modifier(Modifier::BOLD),
358 ),
359 Span::styled(" ─────────────", self.theme.style_muted()),
360 ])
361 }
362
363 fn build_lines(&self) -> Vec<Line<'_>> {
364 let mut lines = Vec::new();
365 lines.push(Line::from(vec![
366 Span::styled(
367 self.doc.name.clone(),
368 self.theme
369 .style_accent_bold()
370 .add_modifier(Modifier::UNDERLINED),
371 ),
372 Span::raw(" "),
373 Span::styled(format!("v{}", self.doc.version), self.theme.style_dim()),
374 ]));
375 lines.push(Line::from(""));
376
377 if let Some(ref d) = self.doc.description {
378 lines.push(self.section_title("Description"));
379 lines.push(Line::from(""));
380 let desc = if d.len() > 600 {
381 format!("{}…", &d[..600])
382 } else {
383 d.clone()
384 };
385 for line in desc.lines() {
386 lines.push(Line::from(Span::styled(
387 line.to_string(),
388 self.theme.style_normal(),
389 )));
390 }
391 lines.push(Line::from(""));
392 }
393
394 let has_links = self.doc.documentation.is_some()
395 || self.doc.homepage.is_some()
396 || self.doc.repository.is_some();
397 if has_links {
398 lines.push(self.section_title("Links"));
399 lines.push(Line::from(""));
400 if let Some(ref u) = self.doc.documentation {
401 lines.push(Line::from(vec![
402 Span::styled(" Docs: ", self.theme.style_dim()),
403 Span::styled(u.clone(), self.theme.style_accent()),
404 ]));
405 }
406 if let Some(ref u) = self.doc.homepage {
407 lines.push(Line::from(vec![
408 Span::styled(" Home: ", self.theme.style_dim()),
409 Span::styled(u.clone(), self.theme.style_accent()),
410 ]));
411 }
412 if let Some(ref u) = self.doc.repository {
413 lines.push(Line::from(vec![
414 Span::styled(" Repo: ", self.theme.style_dim()),
415 Span::styled(u.clone(), self.theme.style_accent()),
416 ]));
417 }
418 lines.push(Line::from(""));
419 }
420
421 let is_github_repo = self
422 .doc
423 .repository
424 .as_ref()
425 .map(|r| r.contains("github.com"))
426 .unwrap_or(false);
427 if let Some(ref g) = self.doc.github {
428 lines.push(self.section_title("GitHub"));
429 lines.push(Line::from(""));
430 if let Some(n) = g.stars {
431 lines.push(Line::from(vec![
432 Span::styled(" Stars: ", self.theme.style_dim()),
433 Span::styled(format!("{}", n), self.theme.style_accent()),
434 ]));
435 }
436 if let Some(n) = g.forks {
437 lines.push(Line::from(vec![
438 Span::styled(" Forks: ", self.theme.style_dim()),
439 Span::styled(format!("{}", n), self.theme.style_accent()),
440 ]));
441 }
442 if let Some(ref lang) = g.language {
443 lines.push(Line::from(vec![
444 Span::styled(" Lang: ", self.theme.style_dim()),
445 Span::styled(lang.clone(), self.theme.style_type()),
446 ]));
447 }
448 if let Some(ref updated) = g.updated_at {
449 let short =
450 if updated.len() >= 10 && updated.as_bytes().get(10).copied() == Some(b'T') {
451 updated[..10].to_string()
452 } else {
453 updated.clone()
454 };
455 lines.push(Line::from(vec![
456 Span::styled(" Updated:", self.theme.style_dim()),
457 Span::styled(format!(" {}", short), self.theme.style_muted()),
458 ]));
459 }
460 if let Some(n) = g.open_issues_count {
461 if n > 0 {
462 lines.push(Line::from(vec![
463 Span::styled(" Issues: ", self.theme.style_dim()),
464 Span::styled(format!("{} open", n), self.theme.style_warning()),
465 ]));
466 }
467 }
468 lines.push(Line::from(""));
469 } else if is_github_repo {
470 lines.push(self.section_title("GitHub"));
471 lines.push(Line::from(""));
472 lines.push(Line::from(vec![
473 Span::styled(" ", self.theme.style_dim()),
474 Span::styled(
475 "Unavailable (rate limit or set GITHUB_TOKEN for more)",
476 self.theme.style_muted(),
477 ),
478 ]));
479 lines.push(Line::from(""));
480 }
481 lines
482 }
483}
484
485impl Widget for DependencyDocView<'_> {
486 fn render(self, area: Rect, buf: &mut Buffer) {
487 let lines = self.build_lines();
488 let total_lines = lines.len();
489 let inner = Block::default().inner(area);
490 let viewport_height = inner.height as usize;
491 let max_scroll = total_lines.saturating_sub(viewport_height);
492 let scroll_offset = self.scroll_offset.min(max_scroll);
493
494 let block = Block::default()
495 .borders(Borders::ALL)
496 .border_type(BorderType::Rounded)
497 .border_style(if self.focused {
498 self.theme.style_border_focused()
499 } else {
500 self.theme.style_border()
501 })
502 .title(format!(" ◇ {} (docs) ", self.doc.name));
503
504 let inner = block.inner(area);
505 block.render(area, buf);
506
507 let visible: Vec<Line> = lines.into_iter().skip(scroll_offset).collect();
508 Paragraph::new(visible)
509 .wrap(Wrap { trim: false })
510 .render(inner, buf);
511
512 if total_lines > inner.height as usize {
513 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
514 .begin_symbol(Some("↑"))
515 .end_symbol(Some("↓"));
516 let mut state = ScrollbarState::new(total_lines).position(scroll_offset);
517 StatefulWidget::render(scrollbar, inner, buf, &mut state);
518 }
519
520 if self.show_browser_hint && inner.height > 0 {
521 let hint_y = inner.y + inner.height - 1;
522 let hint_line = Line::from(vec![
523 Span::styled(" [o] ", self.theme.style_accent()),
524 Span::styled("docs.rs ", self.theme.style_dim()),
525 Span::styled(" [c] ", self.theme.style_accent()),
526 Span::styled("crates.io", self.theme.style_dim()),
527 ]);
528 Paragraph::new(hint_line).render(
529 Rect {
530 y: hint_y,
531 height: 1,
532 ..inner
533 },
534 buf,
535 );
536 }
537 }
538}
539
540pub fn render_doc_loading(theme: &Theme, area: Rect, buf: &mut Buffer, crate_name: &str) {
542 let block = Block::default()
543 .borders(Borders::ALL)
544 .border_type(BorderType::Rounded)
545 .border_style(theme.style_border())
546 .title(format!(" ◇ {} ", crate_name));
547
548 let inner = block.inner(area);
549 block.render(area, buf);
550 let text = vec![
551 Line::from(""),
552 Line::from(Span::styled(
553 format!("Loading documentation for {} from crates.io…", crate_name),
554 theme.style_dim(),
555 )),
556 ];
557 Paragraph::new(text).render(inner, buf);
558 if inner.height > 0 {
559 let hint_y = inner.y + inner.height - 1;
560 let hint_line = Line::from(vec![
561 Span::styled(" [o] ", theme.style_accent()),
562 Span::styled("docs.rs ", theme.style_dim()),
563 Span::styled(" [c] ", theme.style_accent()),
564 Span::styled("crates.io", theme.style_dim()),
565 ]);
566 Paragraph::new(hint_line).render(
567 Rect {
568 y: hint_y,
569 height: 1,
570 ..inner
571 },
572 buf,
573 );
574 }
575}
576
577pub fn render_doc_failed(theme: &Theme, area: Rect, buf: &mut Buffer, crate_name: &str) {
579 let block = Block::default()
580 .borders(Borders::ALL)
581 .border_type(BorderType::Rounded)
582 .border_style(theme.style_border())
583 .title(format!(" ◇ {} ", crate_name));
584
585 let inner = block.inner(area);
586 block.render(area, buf);
587 let text = vec![
588 Line::from(""),
589 Line::from(Span::styled(
590 format!("Could not load documentation for {}.", crate_name),
591 theme.style_muted(),
592 )),
593 Line::from(Span::styled(
594 "Check network or try again later.",
595 theme.style_dim(),
596 )),
597 ];
598 Paragraph::new(text).render(inner, buf);
599 if inner.height > 0 {
600 let hint_y = inner.y + inner.height - 1;
601 let hint_line = Line::from(vec![
602 Span::styled(" [o] ", theme.style_accent()),
603 Span::styled("docs.rs ", theme.style_dim()),
604 Span::styled(" [c] ", theme.style_accent()),
605 Span::styled("crates.io", theme.style_dim()),
606 ]);
607 Paragraph::new(hint_line).render(
608 Rect {
609 y: hint_y,
610 height: 1,
611 ..inner
612 },
613 buf,
614 );
615 }
616}