Skip to main content

oracle_lib/ui/
dependency_view.rs

1//! Dependency list and docs view (root crate info or crates.io doc for a dependency).
2
3use 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
18/// View for displaying dependency information (scrollable). No tree chart; list is in the list panel.
19pub 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    /// Number of lines this view would render (for scroll clamping).
59    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        // Header (use owned strings so return type is independent of info lifetime)
113        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        // Description
126        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        // Metadata
135        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        // Links
162        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        // Features
177        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        // Dependencies summary
203        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        // List direct dependencies
235        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
317/// View for a dependency's docs from crates.io (scrollable).
318pub 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
540/// Render "Loading documentation for X..." in the inspector area.
541pub 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
577/// Render "Failed to load docs for X" in the inspector area.
578pub 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}