1use gpui::{
56 AnyElement, App, ClipboardItem, InteractiveElement, IntoElement, ParentElement, RenderOnce,
57 SharedString, Styled, Window, div, prelude::FluentBuilder, px,
58};
59use gpui_kit_semantics::{NodeSpec, Role, Semantic};
60use gpui_kit_theme::{
61 ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, Theme, TypeScale,
62};
63
64use crate::content::markdown::CodeSpan;
65use crate::controls::button::Button;
66use crate::data::{List, ListItem};
67use crate::display::empty::{EmptyKind, EmptyState};
68use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
69use crate::layout::{ScrollArea, ScrollAxis};
70use crate::strings::{ActiveStrings, StringKey};
71
72const DIGIT_WIDTH: f32 = 8.0;
75const GUTTER_GAP: f32 = 12.0;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum LineMark {
83 Added,
84 Removed,
85 Changed,
86 Highlighted,
88 Error,
89}
90
91impl LineMark {
92 pub fn name(self) -> &'static str {
95 match self {
96 Self::Added => "added",
97 Self::Removed => "removed",
98 Self::Changed => "changed",
99 Self::Highlighted => "highlighted",
100 Self::Error => "error",
101 }
102 }
103
104 fn key(self) -> StringKey {
105 match self {
106 Self::Added => StringKey::CodeLineAdded,
107 Self::Removed => StringKey::CodeLineRemoved,
108 Self::Changed => StringKey::CodeLineChanged,
109 Self::Highlighted => StringKey::CodeLineHighlighted,
110 Self::Error => StringKey::CodeLineError,
111 }
112 }
113
114 fn colors(self, theme: &Theme) -> (gpui::Hsla, gpui::Hsla) {
116 let tint = match self {
117 Self::Added => theme.colors.success,
118 Self::Removed => theme.colors.danger,
119 Self::Changed => theme.colors.warning,
120 Self::Highlighted => theme.colors.accent,
121 Self::Error => theme.colors.danger,
122 };
123 (tint, tint.opacity(theme.effects.selected_ring_alpha))
124 }
125
126 fn struck(self) -> bool {
129 matches!(self, Self::Removed)
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct CodeLine {
141 pub number: usize,
142 pub text: SharedString,
143 pub spans: Vec<CodeSpan>,
144 pub mark: Option<LineMark>,
145}
146
147impl CodeLine {
148 pub fn new(number: usize, text: impl Into<SharedString>) -> Self {
149 Self {
150 number,
151 text: text.into(),
152 spans: Vec::new(),
153 mark: None,
154 }
155 }
156
157 pub fn spans(mut self, spans: impl IntoIterator<Item = CodeSpan>) -> Self {
159 self.spans = spans.into_iter().collect();
160 self
161 }
162
163 pub fn mark(mut self, mark: LineMark) -> Self {
164 self.mark = Some(mark);
165 self
166 }
167}
168
169#[derive(IntoElement)]
171pub struct CodeView {
172 ident: Ident,
173 lines: Vec<CodeLine>,
174 language: Option<SharedString>,
177 line_numbers: bool,
178 visible_lines: Option<usize>,
179 copyable: bool,
180}
181
182impl std::fmt::Debug for CodeView {
183 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 formatter
185 .debug_struct("CodeView")
186 .field("ident", &self.ident)
187 .field("lines", &self.lines.len())
188 .field("language", &self.language)
189 .field("visible_lines", &self.visible_lines)
190 .finish()
191 }
192}
193
194impl CodeView {
195 pub fn new(ident: impl Into<Ident>, lines: impl IntoIterator<Item = CodeLine>) -> Self {
196 Self {
197 ident: ident.into(),
198 lines: lines.into_iter().collect(),
199 language: None,
200 line_numbers: true,
201 visible_lines: None,
202 copyable: true,
203 }
204 }
205
206 pub fn from_text(ident: impl Into<Ident>, text: &str) -> Self {
211 Self::new(
212 ident,
213 text.lines()
214 .enumerate()
215 .map(|(index, line)| CodeLine::new(index + 1, line.to_string())),
216 )
217 }
218
219 pub fn language(mut self, language: impl Into<SharedString>) -> Self {
221 self.language = Some(language.into());
222 self
223 }
224
225 pub fn line_numbers(mut self, line_numbers: bool) -> Self {
226 self.line_numbers = line_numbers;
227 self
228 }
229
230 pub fn visible_lines(mut self, lines: usize) -> Self {
234 self.visible_lines = Some(lines);
235 self
236 }
237
238 pub fn copyable(mut self, copyable: bool) -> Self {
240 self.copyable = copyable;
241 self
242 }
243
244 pub fn text(&self) -> String {
246 self.lines
247 .iter()
248 .map(|line| line.text.as_ref())
249 .collect::<Vec<_>>()
250 .join("\n")
251 }
252
253 fn gutter_width(&self) -> f32 {
254 let widest = self
255 .lines
256 .iter()
257 .map(|line| line.number)
258 .max()
259 .unwrap_or(1)
260 .max(1)
261 .to_string()
262 .len();
263 widest as f32 * DIGIT_WIDTH + GUTTER_GAP
264 }
265}
266
267impl Sizable for CodeView {
268 fn control_size(self, _size: ControlSize) -> Self {
273 self
274 }
275}
276
277fn line_element(
279 ident: &Ident,
280 line: &CodeLine,
281 gutter: f32,
282 line_numbers: bool,
283 theme: &Theme,
284 cx: &App,
285) -> AnyElement {
286 let (rail, wash) = line
287 .mark
288 .map(|mark| mark.colors(theme))
289 .unzip_or(theme.colors.hairline, gpui::transparent_black());
290 let struck = line.mark.is_some_and(LineMark::struck);
291
292 let row = div()
293 .row()
294 .items_start()
295 .w_full()
296 .h(px(theme.typography.code.line_height))
297 .when(line.mark.is_some(), |element| element.bg(wash))
298 .when(line_numbers, |element| {
299 element.child(
300 div()
301 .flex_none()
302 .w(px(gutter))
303 .pr(px(GUTTER_GAP / 2.0))
304 .text_align(gpui::TextAlign::Right)
305 .text_color(theme.colors.text_faint)
306 .child(SharedString::from(line.number.to_string())),
307 )
308 })
309 .child(
312 div()
313 .flex_none()
314 .w(px(theme.borders.thick))
315 .h_full()
316 .when(line.mark.is_some(), |element| element.bg(rail)),
317 )
318 .child(
319 div()
323 .row()
324 .items_baseline()
325 .flex_1()
326 .min_w_0()
327 .whitespace_nowrap()
328 .pl(px(GUTTER_GAP / 2.0))
329 .when(struck, |element| element.line_through())
330 .children(code_runs(theme, line.text.as_ref(), &line.spans)),
331 );
332
333 match line.mark {
334 Some(mark) => row
337 .semantic_in(
338 cx,
339 NodeSpec::new(line_id(ident, line.number), Role::Row)
340 .parent(ident.semantic_id())
341 .text(cx.strings().text(mark.key()))
345 .value(mark.name())
346 .invalid(matches!(mark, LineMark::Error)),
347 )
348 .into_any_element(),
349 None => row.into_any_element(),
350 }
351}
352
353fn line_id(ident: &Ident, number: usize) -> SharedString {
359 ident.child(format!("line-{number}")).semantic_id()
360}
361
362pub(crate) fn code_runs(theme: &Theme, text: &str, spans: &[CodeSpan]) -> Vec<AnyElement> {
370 let mut out: Vec<AnyElement> = Vec::new();
371 let mut cut = 0usize;
372 for span in spans {
373 if span.range.start < cut || span.range.start >= span.range.end {
374 continue;
375 }
376 let (Some(before), Some(inside)) = (
377 text.get(cut..span.range.start),
378 text.get(span.range.start..span.range.end),
379 ) else {
380 continue;
381 };
382 if !before.is_empty() {
383 out.push(
384 div()
385 .flex_none()
386 .child(SharedString::from(before.to_string()))
387 .into_any_element(),
388 );
389 }
390 out.push(
391 div()
392 .flex_none()
393 .text_color(span.tone.color(theme))
394 .child(SharedString::from(inside.to_string()))
395 .into_any_element(),
396 );
397 cut = span.range.end;
398 }
399 if let Some(rest) = text.get(cut..)
400 && !rest.is_empty()
401 {
402 out.push(
403 div()
404 .flex_none()
405 .child(SharedString::from(rest.to_string()))
406 .into_any_element(),
407 );
408 }
409 out
410}
411
412trait UnzipOr<A, B> {
415 fn unzip_or(self, first: A, second: B) -> (A, B);
416}
417
418impl<A, B> UnzipOr<A, B> for Option<(A, B)> {
419 fn unzip_or(self, first: A, second: B) -> (A, B) {
420 self.unwrap_or((first, second))
421 }
422}
423
424impl RenderOnce for CodeView {
425 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
426 let theme = cx.theme().clone();
427 let gutter = self.gutter_width();
428 let line_numbers = self.line_numbers;
429 let total = self.lines.len();
430 let body_ident = self.ident.child("lines");
431
432 let copy = self.copyable.then(|| {
433 let clipboard = self.text();
434 Button::new(self.ident.child("copy"))
435 .label(cx.strings().text(StringKey::Copy))
436 .ghost()
437 .control_size(ControlSize::Xs)
438 .semantic_parent(self.ident.semantic_id())
439 .disabled(clipboard.is_empty())
440 .on_click(move |_, cx| {
441 cx.write_to_clipboard(ClipboardItem::new_string(clipboard.clone()));
442 })
443 });
444
445 let body: AnyElement = if total == 0 {
446 EmptyState::new(
447 self.ident.child("empty"),
448 cx.strings().text(StringKey::CodeEmpty),
449 )
450 .kind(EmptyKind::Empty)
451 .into_any_element()
452 } else if let Some(visible) = self.visible_lines {
453 let lines = std::rc::Rc::new(self.lines);
454 let list_ident = body_ident.clone();
455 let theme_for_rows = theme.clone();
456 List::new(body_ident.clone(), total, move |index, _window, cx| {
457 let line = &lines[index];
458 ListItem::new(
459 line_id(&list_ident, line.number),
460 line_element(&list_ident, line, gutter, line_numbers, &theme_for_rows, cx),
461 )
462 })
463 .row_height(theme.typography.code.line_height)
464 .visible_lines(visible)
465 .into_any_element()
466 } else {
467 ScrollArea::new(body_ident.clone())
472 .axis(ScrollAxis::Both)
473 .fit_height()
474 .child(
475 div().column().children(
476 self.lines
477 .iter()
478 .map(|line| {
479 line_element(&body_ident, line, gutter, line_numbers, &theme, cx)
480 })
481 .collect::<Vec<_>>(),
482 ),
483 )
484 .into_any_element()
485 };
486
487 div()
488 .id(self.ident.element_id())
489 .column()
490 .w_full()
491 .gap_token(&theme, Space::Xs)
492 .p_token(&theme, Space::Sm)
493 .radius(&theme, Radius::Card)
494 .frame(&theme, Surface::Raised, Elevation::Raised)
495 .when(self.language.is_some() || copy.is_some(), |element| {
496 element.child(
497 div()
498 .row()
499 .w_full()
500 .justify_between()
501 .type_scale(&theme, TypeScale::Caption)
502 .text_color(theme.colors.text_faint)
503 .child(div().child(self.language.clone().unwrap_or_default()))
504 .children(copy),
505 )
506 })
507 .child(
508 div()
509 .w_full()
510 .font_family(theme.typography.mono.clone())
511 .text_size(px(theme.typography.code.size))
512 .line_height(px(theme.typography.code.line_height))
513 .text_color(theme.colors.text)
514 .child(body),
515 )
516 .semantic_in(
517 cx,
518 NodeSpec::new(self.ident.semantic_id(), Role::Region)
519 .when_language(self.language)
520 .value(total.to_string()),
523 )
524 }
525}
526
527trait LanguageSpec {
529 fn when_language(self, language: Option<SharedString>) -> Self;
530}
531
532impl LanguageSpec for NodeSpec {
533 fn when_language(self, language: Option<SharedString>) -> Self {
534 match language {
535 Some(language) => self.text(language),
536 None => self,
537 }
538 }
539}
540
541trait VisibleLines {
543 fn visible_lines(self, lines: usize) -> Self;
544}
545
546impl VisibleLines for List {
547 fn visible_lines(self, lines: usize) -> Self {
548 self.visible_rows(lines)
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use crate::display::badge::Tone;
556
557 #[test]
558 fn every_mark_publishes_a_name_of_its_own() {
559 let names = [
560 LineMark::Added,
561 LineMark::Removed,
562 LineMark::Changed,
563 LineMark::Highlighted,
564 LineMark::Error,
565 ]
566 .map(LineMark::name);
567 let mut sorted = names.to_vec();
568 sorted.sort_unstable();
569 sorted.dedup();
570 assert_eq!(sorted.len(), names.len());
571 }
572
573 #[test]
574 fn a_span_naming_no_slice_of_the_line_is_skipped() {
575 let theme = Theme::studio_dark();
576 let line = CodeLine::new(1, "let x = 1;").spans([CodeSpan {
577 range: 40..50,
578 tone: Tone::Accent,
579 }]);
580 assert_eq!(code_runs(&theme, line.text.as_ref(), &line.spans).len(), 1);
582 }
583
584 #[test]
585 fn a_view_keeps_the_numbers_it_was_given() {
586 let view = CodeView::new(
587 "review.hunk",
588 [CodeLine::new(400, "a"), CodeLine::new(401, "b")],
589 );
590 assert_eq!(view.lines[0].number, 400);
591 assert_eq!(view.text(), "a\nb");
592 }
593
594 #[test]
595 fn splitting_text_numbers_from_one() {
596 let view = CodeView::from_text("file", "first\nsecond\nthird");
597 assert_eq!(view.lines.len(), 3);
598 assert_eq!(view.lines[2].number, 3);
599 }
600}