1use crate::chunks::{
2 ChunkDisplayLine, IndexedChunkMeta, chunk_file_live, collect_chunk_display_lines,
3};
4use crate::colors::*;
5use crate::utils::{
6 apply_heatmap_color_to_token, calculate_token_similarity, find_repo_root, split_into_tokens,
7 syntax_set, theme_set,
8};
9use ck_core::pdf;
10use ck_index::load_index_entry;
11use ratatui::style::{Color, Modifier, Style};
12use ratatui::text::{Line, Span};
13use std::fs;
14use std::path::{Path, PathBuf};
15use syntect::easy::HighlightLines;
16
17pub fn load_preview_lines(
18 path: &Path,
19) -> Result<(Vec<String>, bool, Vec<IndexedChunkMeta>), String> {
20 let resolved_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
21 let repo_root = find_repo_root(&resolved_path);
22 let is_pdf = pdf::is_pdf_file(&resolved_path);
23
24 let (_content, lines) = if is_pdf {
25 let root = repo_root.clone().ok_or_else(|| {
26 "PDF preview unavailable (missing .ck index). Run `ck --index .` first.".to_string()
27 })?;
28
29 let cache_path = pdf::get_content_cache_path(&root, &resolved_path);
30 let content = fs::read_to_string(&cache_path).map_err(|err| {
31 format!(
32 "PDF preview unavailable ({}). Run `ck --index .` to generate cache.",
33 err
34 )
35 })?;
36 let lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
37 (content, lines)
38 } else {
39 let content = fs::read_to_string(&resolved_path)
40 .map_err(|err| format!("Could not read {}: {}", resolved_path.display(), err))?;
41 let lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
42 (content, lines)
43 };
44
45 let chunk_spans = if is_pdf {
47 if let Some(root) = repo_root {
49 load_chunk_spans(&root, &resolved_path).unwrap_or_default()
50 } else {
51 Vec::new()
52 }
53 } else {
54 match chunk_file_live(&resolved_path) {
56 Ok((_, chunks)) => chunks,
57 Err(_) => {
58 if let Some(root) = repo_root {
60 load_chunk_spans(&root, &resolved_path).unwrap_or_default()
61 } else {
62 Vec::new()
63 }
64 }
65 }
66 };
67
68 Ok((lines, is_pdf, chunk_spans))
69}
70
71fn load_chunk_spans(repo_root: &Path, file_path: &Path) -> Result<Vec<IndexedChunkMeta>, String> {
72 let standard_path = file_path
73 .strip_prefix(repo_root)
74 .unwrap_or(file_path)
75 .to_path_buf();
76 let index_dir = repo_root.join(".ck");
77 let sidecar_path = index_dir.join(format!("{}.ck", standard_path.display()));
78
79 if !sidecar_path.exists() {
80 return Ok(Vec::new());
81 }
82
83 let entry = load_index_entry(&sidecar_path)
84 .map_err(|err| format!("Failed to load chunk metadata: {}", err))?;
85 let mut metas: Vec<IndexedChunkMeta> = entry
86 .chunks
87 .iter()
88 .map(|chunk| IndexedChunkMeta {
89 span: chunk.span.clone(),
90 chunk_type: chunk.chunk_type.clone(),
91 breadcrumb: chunk.breadcrumb.clone(),
92 ancestry: chunk.ancestry.clone().unwrap_or_default(),
93 estimated_tokens: chunk.estimated_tokens,
94 byte_length: chunk.byte_length,
95 leading_trivia: chunk.leading_trivia.clone(),
96 trailing_trivia: chunk.trailing_trivia.clone(),
97 })
98 .collect();
99
100 let has_non_module = metas
101 .iter()
102 .any(|meta| meta.chunk_type.as_deref() != Some("module"));
103 if has_non_module {
104 metas.retain(|meta| meta.chunk_type.as_deref() != Some("module"));
105 }
106
107 Ok(metas)
108}
109
110#[allow(clippy::too_many_arguments)]
111pub fn render_heatmap_preview(
112 lines: &[String],
113 context_start: usize,
114 context_end: usize,
115 file_path: &Path,
116 score: f32,
117 match_line: usize,
118 query: &str,
119) -> Vec<Line<'static>> {
120 let mut colored_lines = Vec::new();
121
122 colored_lines.push(Line::from(vec![Span::styled(
124 format!("File: {} | Score: {:.3}\n", file_path.display(), score),
125 Style::default().fg(COLOR_CYAN),
126 )]));
127
128 for (idx, line) in lines[context_start..context_end].iter().enumerate() {
130 let line_num = context_start + idx + 1;
131 let is_match_line = line_num == match_line;
132 let in_chunk_range = line_num >= match_line.saturating_sub(5) && line_num <= match_line + 5;
133
134 let mut line_spans = vec![Span::styled(
135 format!("{:4} | ", line_num),
136 if is_match_line {
137 Style::default()
138 .fg(COLOR_YELLOW)
139 .add_modifier(Modifier::BOLD)
140 } else if in_chunk_range {
141 Style::default().fg(COLOR_CYAN) } else {
143 Style::default().fg(COLOR_DARK_GRAY)
144 },
145 )];
146
147 let tokens = split_into_tokens(line);
149 for token in tokens {
150 let similarity = calculate_token_similarity(&token, query);
151 let color = apply_heatmap_color_to_token(&token, similarity);
152
153 let style = if color == Color::Reset {
154 Style::default().fg(COLOR_WHITE)
155 } else {
156 Style::default().fg(color)
157 };
158
159 line_spans.push(Span::styled(token.to_string(), style));
160 }
161
162 colored_lines.push(Line::from(line_spans));
163 }
164
165 colored_lines
166}
167
168#[allow(clippy::too_many_arguments)]
169pub fn render_syntax_preview(
170 lines: &[String],
171 context_start: usize,
172 context_end: usize,
173 file_path: &PathBuf,
174 score: f32,
175 match_line: usize,
176) -> Vec<Line<'static>> {
177 let mut colored_lines = Vec::new();
178
179 colored_lines.push(Line::from(vec![Span::styled(
181 format!("File: {} | Score: {:.3}\n", file_path.display(), score),
182 Style::default().fg(COLOR_CYAN),
183 )]));
184
185 let ps = syntax_set();
187 let ts = theme_set();
188 let theme = ts
189 .themes
190 .get("base16-ocean.dark")
191 .or_else(|| ts.themes.values().next());
192
193 let syntax = ps
195 .find_syntax_for_file(file_path)
196 .ok()
197 .flatten()
198 .unwrap_or_else(|| ps.find_syntax_plain_text());
199
200 let mut highlighter = match theme {
201 Some(theme) => HighlightLines::new(syntax, theme),
202 None => {
203 for (idx, line) in lines[context_start..context_end].iter().enumerate() {
205 let line_num = context_start + idx + 1;
206 let is_match_line = line_num == match_line;
207 let in_chunk_range =
208 line_num >= match_line.saturating_sub(5) && line_num <= match_line + 5;
209
210 let line_spans = vec![
211 Span::styled(
212 format!("{:4} | ", line_num),
213 if is_match_line {
214 Style::default()
215 .fg(COLOR_YELLOW)
216 .add_modifier(Modifier::BOLD)
217 } else if in_chunk_range {
218 Style::default().fg(COLOR_CYAN)
219 } else {
220 Style::default().fg(COLOR_DARK_GRAY)
221 },
222 ),
223 Span::styled(line.to_string(), Style::default().fg(COLOR_WHITE)),
224 ];
225
226 colored_lines.push(Line::from(line_spans));
227 }
228
229 return colored_lines;
230 }
231 };
232
233 for (idx, line) in lines[context_start..context_end].iter().enumerate() {
235 let line_num = context_start + idx + 1;
236 let is_match_line = line_num == match_line;
237 let in_chunk_range = line_num >= match_line.saturating_sub(5) && line_num <= match_line + 5;
238
239 let mut line_spans = vec![Span::styled(
240 format!("{:4} | ", line_num),
241 if is_match_line {
242 Style::default()
243 .fg(COLOR_YELLOW)
244 .add_modifier(Modifier::BOLD)
245 } else if in_chunk_range {
246 Style::default().fg(COLOR_CYAN) } else {
248 Style::default().fg(COLOR_DARK_GRAY)
249 },
250 )];
251
252 if let Ok(ranges) = highlighter.highlight_line(line, ps) {
254 for (style, text) in ranges {
255 let fg = style.foreground;
256 let color = Color::Rgb(fg.r, fg.g, fg.b);
257 line_spans.push(Span::styled(text.to_string(), Style::default().fg(color)));
258 }
259 } else {
260 line_spans.push(Span::raw(line.to_string()));
261 }
262
263 colored_lines.push(Line::from(line_spans));
264 }
265
266 colored_lines
267}
268
269#[allow(clippy::too_many_arguments)]
270pub fn render_chunks_preview(
271 lines: &[String],
272 context_start: usize,
273 context_end: usize,
274 file_path: &Path,
275 score: f32,
276 match_line: usize,
277 chunk_meta: Option<&IndexedChunkMeta>,
278 is_pdf: bool,
279 all_chunks: &[IndexedChunkMeta],
280 full_file_mode: bool,
281 disable_match_highlighting: bool,
282) -> Vec<Line<'static>> {
283 let mut colored_lines = Vec::new();
284
285 let header = if let Some(meta) = chunk_meta {
286 let span = &meta.span;
287 let chunk_kind = meta.chunk_type.as_deref().unwrap_or("chunk");
288 let breadcrumb_display = meta
289 .breadcrumb
290 .as_deref()
291 .filter(|crumb| !crumb.is_empty())
292 .map(|crumb| format!(" • {}", crumb))
293 .unwrap_or_else(|| {
294 if !meta.ancestry.is_empty() {
295 format!(" • {}", meta.ancestry.join("::"))
296 } else {
297 String::new()
298 }
299 });
300 let token_display = meta
301 .estimated_tokens
302 .map(|tokens| format!(" • ~{} tokens", tokens))
303 .unwrap_or_default();
304
305 format!(
306 "File: {} • Score: {:.3}\n{}{}{} • L{}-{}\n",
307 file_path.display(),
308 score,
309 chunk_kind,
310 breadcrumb_display,
311 token_display,
312 span.line_start,
313 span.line_end
314 )
315 } else if is_pdf {
316 format!(
317 "File: {} • Score: {:.3}
318PDF chunk (approximate)
319",
320 file_path.display(),
321 score
322 )
323 } else {
324 format!(
325 "File: {} • Score: {:.3}
326",
327 file_path.display(),
328 score
329 )
330 };
331
332 colored_lines.push(Line::from(vec![Span::styled(
333 header,
334 Style::default().fg(COLOR_CYAN),
335 )]));
336
337 colored_lines.extend(build_chunk_lines(
338 lines,
339 context_start,
340 context_end,
341 match_line,
342 chunk_meta,
343 all_chunks,
344 full_file_mode,
345 disable_match_highlighting,
346 ));
347
348 colored_lines
349}
350
351#[allow(clippy::too_many_arguments)]
352pub fn build_chunk_lines(
353 lines: &[String],
354 context_start: usize,
355 context_end: usize,
356 match_line: usize,
357 chunk_meta: Option<&IndexedChunkMeta>,
358 all_chunks: &[IndexedChunkMeta],
359 full_file_mode: bool,
360 disable_match_highlighting: bool,
361) -> Vec<Line<'static>> {
362 let max_line_num = lines.len();
364 let line_num_width = max_line_num.to_string().len() + 1; collect_chunk_display_lines(
367 lines,
368 context_start,
369 context_end,
370 if disable_match_highlighting {
371 0
372 } else {
373 match_line
374 },
375 chunk_meta,
376 all_chunks,
377 full_file_mode,
378 )
379 .into_iter()
380 .map(|row| match row {
381 ChunkDisplayLine::Label { prefix, text } => {
382 let mut spans = Vec::new();
383
384 spans.push(Span::styled(
386 " ".repeat(prefix),
387 Style::default().fg(COLOR_DARK_GRAY),
388 ));
389
390 let bar_start = "┌─ ";
392 let bar_end = " ─┐";
393
394 spans.push(Span::styled(
396 bar_start,
397 Style::default()
398 .fg(COLOR_CHUNK_BOUNDARY)
399 .add_modifier(Modifier::BOLD),
400 ));
401
402 spans.push(Span::styled(
404 text,
405 Style::default()
406 .fg(COLOR_CHUNK_TEXT)
407 .bg(COLOR_CHUNK_BOUNDARY)
408 .add_modifier(Modifier::BOLD),
409 ));
410
411 spans.push(Span::styled(
413 bar_end,
414 Style::default()
415 .fg(COLOR_CHUNK_BOUNDARY)
416 .add_modifier(Modifier::BOLD),
417 ));
418
419 Line::from(spans)
420 }
421 ChunkDisplayLine::Content {
422 columns,
423 line_num,
424 text,
425 is_match_line,
426 in_matched_chunk,
427 has_any_chunk,
428 } => {
429 let mut spans = Vec::new();
430
431 if columns.is_empty() {
433 spans.push(Span::styled(" ", Style::default().fg(COLOR_DARK_GRAY)));
434 } else {
435 for column in columns {
436 let mut style = Style::default().fg(if column.is_match {
437 COLOR_CHUNK_HIGHLIGHT } else {
439 COLOR_CHUNK_BOUNDARY });
441 if column.is_match {
442 style = style.add_modifier(Modifier::BOLD);
443 }
444 spans.push(Span::styled(column.ch.to_string(), style));
445 }
446 }
447
448 spans.push(Span::styled(" ", Style::default().fg(COLOR_DARK_GRAY)));
449
450 spans.push(Span::styled(
452 format!("{:width$} | ", line_num, width = line_num_width),
453 if is_match_line {
454 Style::default()
455 .fg(COLOR_YELLOW)
456 .add_modifier(Modifier::BOLD)
457 } else if in_matched_chunk {
458 Style::default()
459 .fg(COLOR_CHUNK_LINE_NUM) .add_modifier(Modifier::BOLD)
461 } else {
462 Style::default().fg(COLOR_GRAY)
463 },
464 ));
465
466 spans.push(Span::styled(
467 text,
468 if in_matched_chunk {
469 Style::default()
470 .fg(COLOR_CHUNK_TEXT) .add_modifier(Modifier::BOLD)
472 } else if has_any_chunk {
473 Style::default().fg(COLOR_WHITE) } else {
475 Style::default().fg(COLOR_DARK_GRAY) },
477 ));
478
479 Line::from(spans)
480 }
481 ChunkDisplayLine::Message(message) => Line::from(vec![Span::styled(
482 message,
483 Style::default()
484 .fg(COLOR_CHUNK_BOUNDARY)
485 .add_modifier(Modifier::ITALIC),
486 )]),
487 })
488 .collect()
489}
490
491#[allow(dead_code)]
492pub fn build_chunk_strings(
493 lines: &[String],
494 context_start: usize,
495 context_end: usize,
496 match_line: usize,
497 chunk_meta: Option<&IndexedChunkMeta>,
498 all_chunks: &[IndexedChunkMeta],
499 full_file_mode: bool,
500) -> Vec<String> {
501 let max_line_num = lines.len();
503 let line_num_width = max_line_num.to_string().len() + 1; collect_chunk_display_lines(
506 lines,
507 context_start,
508 context_end,
509 match_line,
510 chunk_meta,
511 all_chunks,
512 full_file_mode,
513 )
514 .into_iter()
515 .map(|row| match row {
516 ChunkDisplayLine::Label { prefix, text } => {
517 format!("{}{}", " ".repeat(prefix), text)
518 }
519 ChunkDisplayLine::Content {
520 columns,
521 line_num,
522 text,
523 is_match_line,
524 ..
525 } => {
526 let mut line_buf = String::new();
527 if columns.is_empty() {
528 line_buf.push(' ');
529 } else {
530 for column in columns {
531 line_buf.push(column.ch);
532 }
533 }
534 line_buf.push(' ');
535 line_buf.push_str(&format!(
536 "{:width$} | {}",
537 line_num,
538 text,
539 width = line_num_width
540 ));
541 if is_match_line {
542 line_buf.push_str(" <= match");
543 }
544 line_buf
545 }
546 ChunkDisplayLine::Message(message) => message,
547 })
548 .collect()
549}
550
551#[allow(dead_code)]
552pub fn dump_chunk_view_internal(
553 path: &Path,
554 match_line: Option<usize>,
555 full_file_mode: bool,
556) -> Result<Vec<String>, String> {
557 let (lines, is_pdf, chunk_spans) = load_preview_lines(path)?;
558
559 if lines.is_empty() {
560 return Ok(vec![format!("File: {} (empty)", path.display())]);
561 }
562
563 let total_lines = lines.len();
564 let mut line_to_focus = match_line
565 .or_else(|| chunk_spans.first().map(|meta| meta.span.line_start))
566 .unwrap_or(1)
567 .clamp(1, total_lines);
568
569 let chunk_meta = chunk_spans
570 .iter()
571 .filter(|meta| {
572 let span = &meta.span;
573 line_to_focus >= span.line_start && line_to_focus <= span.line_end
574 })
575 .min_by_key(|meta| meta.span.line_end.saturating_sub(meta.span.line_start));
576
577 if chunk_meta.is_none() && chunk_spans.is_empty() {
578 line_to_focus = line_to_focus.clamp(1, total_lines);
579 }
580
581 let mut context_start = if full_file_mode {
582 0
583 } else {
584 line_to_focus
585 .saturating_sub(6)
586 .min(total_lines.saturating_sub(1))
587 };
588 let mut context_end = if full_file_mode {
589 total_lines
590 } else {
591 (line_to_focus + 5).min(total_lines)
592 };
593
594 if !full_file_mode && let Some(meta) = chunk_meta {
595 context_start = meta
596 .span
597 .line_start
598 .saturating_sub(1)
599 .min(total_lines.saturating_sub(1));
600 context_end = meta.span.line_end.min(total_lines);
601 }
602
603 if context_end <= context_start {
604 context_end = (context_start + 1).min(total_lines);
605 }
606
607 let mut output = Vec::new();
608
609 let header_lines: Vec<String> = if let Some(meta) = chunk_meta {
610 let span = &meta.span;
611 let chunk_kind = meta.chunk_type.as_deref().unwrap_or("chunk");
612 let breadcrumb_display = meta
613 .breadcrumb
614 .as_deref()
615 .filter(|crumb| !crumb.is_empty())
616 .map(|crumb| format!(" • {}", crumb))
617 .unwrap_or_else(|| {
618 if !meta.ancestry.is_empty() {
619 format!(" • {}", meta.ancestry.join("::"))
620 } else {
621 String::new()
622 }
623 });
624 let token_display = meta
625 .estimated_tokens
626 .map(|tokens| format!(" • ~{} tokens", tokens))
627 .unwrap_or_default();
628
629 vec![
630 format!("File: {}", path.display()),
631 format!(
632 "{}{}{} • L{}-{}",
633 chunk_kind, breadcrumb_display, token_display, span.line_start, span.line_end
634 ),
635 String::new(),
636 ]
637 } else if is_pdf {
638 vec![
639 format!("File: {}", path.display()),
640 "PDF chunk (approximate)".to_string(),
641 String::new(),
642 ]
643 } else {
644 vec![format!("File: {}", path.display()), String::new()]
645 };
646
647 output.extend(header_lines);
648
649 let body = build_chunk_strings(
650 &lines,
651 context_start,
652 context_end,
653 line_to_focus,
654 chunk_meta,
655 &chunk_spans,
656 full_file_mode,
657 );
658
659 output.extend(body);
660
661 Ok(output)
662}