logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
[package]
name = "csv_preview"
version = "0.1.0"
publish.workspace = true
edition.workspace = true
[lib]
path = "src/csv_preview.rs"
[dependencies]
anyhow.workspace = true
feature_flags.workspace = true
gpui.workspace = true
editor.workspace = true
ui.workspace = true
workspace.workspace = true
log.workspace = true
text.workspace = true
[features]
dev-tools = []
[lints]
workspace = true

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,333 @@
use editor::{Editor, EditorEvent};
use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag};
use gpui::{
AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions,
};
use std::{
collections::HashMap,
time::{Duration, Instant},
};
use crate::table_data_engine::TableDataEngine;
use ui::{
AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState,
TableResizeBehavior, prelude::*,
};
use workspace::{Item, SplitDirection, Workspace};
use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent};
mod parser;
mod renderer;
mod settings;
mod table_data_engine;
mod types;
actions!(csv, [OpenPreview, OpenPreviewToTheSide]);
pub struct TabularDataPreviewFeatureFlag;
impl FeatureFlag for TabularDataPreviewFeatureFlag {
const NAME: &'static str = "tabular-data-preview";
type Value = PresenceFlag;
}
register_feature_flag!(TabularDataPreviewFeatureFlag);
pub struct CsvPreviewView {
pub(crate) engine: TableDataEngine,
pub(crate) focus_handle: FocusHandle,
active_editor_state: EditorState,
pub(crate) table_interaction_state: Entity<TableInteractionState>,
pub(crate) column_widths: ColumnWidths,
pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
pub(crate) settings: CsvPreviewSettings,
/// Performance metrics for debugging and monitoring CSV operations.
pub(crate) performance_metrics: PerformanceMetrics,
pub(crate) list_state: gpui::ListState,
/// Time when the last parsing operation ended, used for smart debouncing
pub(crate) last_parse_end_time: Option<std::time::Instant>,
}
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
CsvPreviewView::register(workspace);
})
.detach()
}
impl CsvPreviewView {
pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
// plus 1 for the row identifier column
let cols = self.engine.contents.headers.cols() + 1;
let line_number_width = self.calculate_row_identifier_column_width();
let mut widths: Vec<AbsoluteLength> = vec![AbsoluteLength::Pixels(px(150.)); cols];
widths[0] = AbsoluteLength::Pixels(px(line_number_width));
let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
resize_behaviors[0] = TableResizeBehavior::None;
self.column_widths.widths.update(cx, |state, _cx| {
if state.cols() != cols {
*state = ResizableColumnsState::new(cols, widths, resize_behaviors);
} else {
state.set_column_configuration(
0,
AbsoluteLength::Pixels(px(line_number_width)),
TableResizeBehavior::None,
);
}
});
}
pub fn register(workspace: &mut Workspace) {
workspace.register_action_renderer(|div, _, _, cx| {
div.when(cx.has_flag::<TabularDataPreviewFeatureFlag>(), |div| {
div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| {
if let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
.filter(|editor| Self::is_csv_file(editor, cx))
{
let csv_preview = Self::new(&editor, cx);
workspace.active_pane().update(cx, |pane, cx| {
let existing = pane
.items_of_type::<CsvPreviewView>()
.find(|view| view.read(cx).active_editor_state.editor == editor);
if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) {
pane.activate_item(idx, true, true, window, cx);
} else {
pane.add_item(Box::new(csv_preview), true, true, None, window, cx);
}
});
cx.notify();
}
}))
.on_action(cx.listener(
|workspace, _: &OpenPreviewToTheSide, window, cx| {
if let Some(editor) = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
.filter(|editor| Self::is_csv_file(editor, cx))
{
let csv_preview = Self::new(&editor, cx);
let pane = workspace
.find_pane_in_direction(SplitDirection::Right, cx)
.unwrap_or_else(|| {
workspace.split_pane(
workspace.active_pane().clone(),
SplitDirection::Right,
window,
cx,
)
});
pane.update(cx, |pane, cx| {
let existing =
pane.items_of_type::<CsvPreviewView>().find(|view| {
view.read(cx).active_editor_state.editor == editor
});
if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) {
pane.activate_item(idx, true, true, window, cx);
} else {
pane.add_item(
Box::new(csv_preview),
false,
false,
None,
window,
cx,
);
}
});
cx.notify();
}
},
))
})
});
}
fn new(editor: &Entity<Editor>, cx: &mut Context<Workspace>) -> Entity<Self> {
let contents = TableLikeContent::default();
let table_interaction_state = cx.new(|cx| {
TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::<
editor::EditorSettingsScrollbarProxy,
>())
});
cx.new(|cx| {
let subscription = cx.subscribe(
editor,
|this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| {
match event {
EditorEvent::Edited { .. } | EditorEvent::DirtyChanged => {
this.parse_csv_from_active_editor(true, cx);
}
_ => {}
};
},
);
let mut view = CsvPreviewView {
focus_handle: cx.focus_handle(),
active_editor_state: EditorState {
editor: editor.clone(),
_subscription: subscription,
},
table_interaction_state,
column_widths: ColumnWidths::new(cx, 1),
parsing_task: None,
performance_metrics: PerformanceMetrics::default(),
list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.))
.measure_all(),
settings: CsvPreviewSettings::default(),
last_parse_end_time: None,
engine: TableDataEngine::default(),
};
view.parse_csv_from_active_editor(false, cx);
view
})
}
pub(crate) fn editor_state(&self) -> &EditorState {
&self.active_editor_state
}
pub(crate) fn apply_sort(&mut self) {
self.performance_metrics.record("Sort", || {
self.engine.apply_sort();
});
}
/// Update ordered indices when ordering or content changes
pub(crate) fn apply_filter_sort(&mut self) {
self.performance_metrics.record("Filter&sort", || {
self.engine.calculate_d2d_mapping();
});
// Update list state with filtered row count
let visible_rows = self.engine.d2d_mapping().visible_row_count();
self.list_state =
gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all();
}
pub fn resolve_active_item_as_csv_editor(
workspace: &Workspace,
cx: &mut Context<Workspace>,
) -> Option<Entity<Editor>> {
let editor = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))?;
Self::is_csv_file(&editor, cx).then_some(editor)
}
fn is_csv_file(editor: &Entity<Editor>, cx: &App) -> bool {
editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.and_then(|buffer| {
buffer
.read(cx)
.file()
.and_then(|file| file.path().extension())
.map(|ext| ext.eq_ignore_ascii_case("csv"))
})
.unwrap_or(false)
}
}
impl Focusable for CsvPreviewView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<()> for CsvPreviewView {}
impl Item for CsvPreviewView {
type Event = ();
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::FileDoc))
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
self.editor_state()
.editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.and_then(|b| {
let file = b.read(cx).file()?;
let local_file = file.as_local()?;
local_file
.abs_path(cx)
.file_name()
.map(|name| format!("Preview {}", name.to_string_lossy()).into())
})
.unwrap_or_else(|| SharedString::from("CSV Preview"))
}
}
#[derive(Debug, Default)]
pub struct PerformanceMetrics {
/// Map of timing metrics with their duration and measurement time.
pub timings: HashMap<&'static str, (Duration, Instant)>,
/// List of display indices that were rendered in the current frame.
pub rendered_indices: Vec<usize>,
}
impl PerformanceMetrics {
pub fn record<F, R>(&mut self, name: &'static str, mut f: F) -> R
where
F: FnMut() -> R,
{
let start_time = Instant::now();
let ret = f();
let duration = start_time.elapsed();
self.timings.insert(name, (duration, Instant::now()));
ret
}
/// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago`
pub fn display(&self) -> String {
let mut metrics = self.timings.iter().collect::<Vec<_>>();
metrics.sort_by_key(|&(name, _)| *name);
metrics
.iter()
.map(|(name, (duration, time))| {
let took = duration.as_secs_f32() * 1000.;
let ago = time.elapsed().as_secs();
format!("{name}: {took:.2}ms {ago}s ago")
})
.collect::<Vec<_>>()
.join("\n")
}
/// Get timing for a specific metric
pub fn get_timing(&self, name: &str) -> Option<Duration> {
self.timings.get(name).map(|(duration, _)| *duration)
}
}
/// Holds state of column widths for a table component in CSV preview.
pub(crate) struct ColumnWidths {
pub widths: Entity<ResizableColumnsState>,
}
impl ColumnWidths {
pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
Self {
widths: cx.new(|_cx| {
ResizableColumnsState::new(
cols,
vec![AbsoluteLength::Pixels(px(150.)); cols],
vec![ui::TableResizeBehavior::Resizable; cols],
)
}),
}
}
}

View File

@@ -0,0 +1,510 @@
use crate::{
CsvPreviewView,
types::TableLikeContent,
types::{LineNumber, TableCell},
};
use editor::Editor;
use gpui::{AppContext, Context, Entity, Subscription, Task};
use std::time::{Duration, Instant};
use text::BufferSnapshot;
use ui::{SharedString, table_row::TableRow};
pub(crate) const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200);
pub(crate) struct EditorState {
pub editor: Entity<Editor>,
pub _subscription: Subscription,
}
impl CsvPreviewView {
pub(crate) fn parse_csv_from_active_editor(
&mut self,
wait_for_debounce: bool,
cx: &mut Context<Self>,
) {
let editor = self.active_editor_state.editor.clone();
self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx));
}
fn parse_csv_in_background(
&mut self,
wait_for_debounce: bool,
editor: Entity<Editor>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
cx.spawn(async move |view, cx| {
if wait_for_debounce {
// Smart debouncing: check if cooldown period has already passed
let now = Instant::now();
let should_wait = view.update(cx, |view, _| {
if let Some(last_end) = view.last_parse_end_time {
let cooldown_until = last_end + REPARSE_DEBOUNCE;
if now < cooldown_until {
Some(cooldown_until - now)
} else {
None // Cooldown already passed, parse immediately
}
} else {
None // First parse, no debounce
}
})?;
if let Some(wait_duration) = should_wait {
cx.background_executor().timer(wait_duration).await;
}
}
let buffer_snapshot = view.update(cx, |_, cx| {
editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.map(|b| b.read(cx).text_snapshot())
})?;
let Some(buffer_snapshot) = buffer_snapshot else {
return Ok(());
};
let instant = Instant::now();
let parsed_csv = cx
.background_spawn(async move { from_buffer(&buffer_snapshot) })
.await;
let parse_duration = instant.elapsed();
let parse_end_time: Instant = Instant::now();
log::debug!("Parsed CSV in {}ms", parse_duration.as_millis());
view.update(cx, move |view, cx| {
view.performance_metrics
.timings
.insert("Parsing", (parse_duration, Instant::now()));
log::debug!("Parsed {} rows", parsed_csv.rows.len());
view.engine.contents = parsed_csv;
view.sync_column_widths(cx);
view.last_parse_end_time = Some(parse_end_time);
view.apply_filter_sort();
cx.notify();
})
})
}
}
pub fn from_buffer(buffer_snapshot: &BufferSnapshot) -> TableLikeContent {
let text = buffer_snapshot.text();
if text.trim().is_empty() {
return TableLikeContent::default();
}
let (parsed_cells_with_positions, line_numbers) = parse_csv_with_positions(&text);
if parsed_cells_with_positions.is_empty() {
return TableLikeContent::default();
}
let raw_headers = parsed_cells_with_positions[0].clone();
// Calculating the longest row, as CSV might have less headers than max row width
let Some(max_number_of_cols) = parsed_cells_with_positions.iter().map(|r| r.len()).max() else {
return TableLikeContent::default();
};
// Convert to TableCell objects with buffer positions
let headers = create_table_row(&buffer_snapshot, max_number_of_cols, raw_headers);
let rows = parsed_cells_with_positions
.into_iter()
.skip(1)
.map(|row| create_table_row(&buffer_snapshot, max_number_of_cols, row))
.collect();
let row_line_numbers = line_numbers.into_iter().skip(1).collect();
TableLikeContent {
headers,
rows,
line_numbers: row_line_numbers,
number_of_cols: max_number_of_cols,
}
}
/// Parse CSV and track byte positions for each cell
fn parse_csv_with_positions(
text: &str,
) -> (
Vec<Vec<(SharedString, std::ops::Range<usize>)>>,
Vec<LineNumber>,
) {
let mut rows = Vec::new();
let mut line_numbers = Vec::new();
let mut current_row: Vec<(SharedString, std::ops::Range<usize>)> = Vec::new();
let mut current_field = String::new();
let mut field_start_offset = 0;
let mut current_offset = 0;
let mut in_quotes = false;
let mut current_line = 1; // 1-based line numbering
let mut row_start_line = 1;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
let char_byte_len = ch.len_utf8();
match ch {
'"' => {
if in_quotes {
if chars.peek() == Some(&'"') {
// Escaped quote
chars.next();
current_field.push('"');
current_offset += 1; // Skip the second quote
} else {
// End of quoted field
in_quotes = false;
}
} else {
// Start of quoted field
in_quotes = true;
if current_field.is_empty() {
// Include the opening quote in the range
field_start_offset = current_offset;
}
}
}
',' if !in_quotes => {
// Field separator
let field_end_offset = current_offset;
if current_field.is_empty() && !in_quotes {
field_start_offset = current_offset;
}
current_row.push((
current_field.clone().into(),
field_start_offset..field_end_offset,
));
current_field.clear();
field_start_offset = current_offset + char_byte_len;
}
'\n' => {
current_line += 1;
if !in_quotes {
// Row separator (only when not inside quotes)
let field_end_offset = current_offset;
if current_field.is_empty() && current_row.is_empty() {
field_start_offset = 0;
}
current_row.push((
current_field.clone().into(),
field_start_offset..field_end_offset,
));
current_field.clear();
// Only add non-empty rows
if !current_row.is_empty()
&& !current_row.iter().all(|(field, _)| field.trim().is_empty())
{
rows.push(current_row);
// Add line number info for this row
let line_info = if row_start_line == current_line - 1 {
LineNumber::Line(row_start_line)
} else {
LineNumber::LineRange(row_start_line, current_line - 1)
};
line_numbers.push(line_info);
}
current_row = Vec::new();
row_start_line = current_line;
field_start_offset = current_offset + char_byte_len;
} else {
// Newline inside quotes - preserve it
current_field.push(ch);
}
}
'\r' => {
if chars.peek() == Some(&'\n') {
// Handle Windows line endings (\r\n): account for \r byte, let \n be handled next
current_offset += char_byte_len;
continue;
} else {
// Standalone \r
current_line += 1;
if !in_quotes {
// Row separator (only when not inside quotes)
let field_end_offset = current_offset;
current_row.push((
current_field.clone().into(),
field_start_offset..field_end_offset,
));
current_field.clear();
// Only add non-empty rows
if !current_row.is_empty()
&& !current_row.iter().all(|(field, _)| field.trim().is_empty())
{
rows.push(current_row);
// Add line number info for this row
let line_info = if row_start_line == current_line - 1 {
LineNumber::Line(row_start_line)
} else {
LineNumber::LineRange(row_start_line, current_line - 1)
};
line_numbers.push(line_info);
}
current_row = Vec::new();
row_start_line = current_line;
field_start_offset = current_offset + char_byte_len;
} else {
// \r inside quotes - preserve it
current_field.push(ch);
}
}
}
_ => {
if current_field.is_empty() && !in_quotes {
field_start_offset = current_offset;
}
current_field.push(ch);
}
}
current_offset += char_byte_len;
}
// Add the last field and row if not empty
if !current_field.is_empty() || !current_row.is_empty() {
let field_end_offset = current_offset;
current_row.push((
current_field.clone().into(),
field_start_offset..field_end_offset,
));
}
if !current_row.is_empty() && !current_row.iter().all(|(field, _)| field.trim().is_empty()) {
rows.push(current_row);
// Add line number info for the last row
let line_info = if row_start_line == current_line {
LineNumber::Line(row_start_line)
} else {
LineNumber::LineRange(row_start_line, current_line)
};
line_numbers.push(line_info);
}
(rows, line_numbers)
}
fn create_table_row(
buffer_snapshot: &BufferSnapshot,
max_number_of_cols: usize,
row: Vec<(SharedString, std::ops::Range<usize>)>,
) -> TableRow<TableCell> {
let mut raw_row = row
.into_iter()
.map(|(content, range)| {
TableCell::from_buffer_position(content, range.start, range.end, &buffer_snapshot)
})
.collect::<Vec<_>>();
let append_elements = max_number_of_cols - raw_row.len();
if append_elements > 0 {
for _ in 0..append_elements {
raw_row.push(TableCell::Virtual);
}
}
TableRow::from_vec(raw_row, max_number_of_cols)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_csv_parsing_basic() {
let csv_data = "Name,Age,City\nJohn,30,New York\nJane,25,Los Angeles";
let parsed = TableLikeContent::from_str(csv_data.to_string());
assert_eq!(parsed.headers.cols(), 3);
assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name");
assert_eq!(parsed.headers[1].display_value().unwrap().as_ref(), "Age");
assert_eq!(parsed.headers[2].display_value().unwrap().as_ref(), "City");
assert_eq!(parsed.rows.len(), 2);
assert_eq!(parsed.rows[0][0].display_value().unwrap().as_ref(), "John");
assert_eq!(parsed.rows[0][1].display_value().unwrap().as_ref(), "30");
assert_eq!(
parsed.rows[0][2].display_value().unwrap().as_ref(),
"New York"
);
}
#[test]
fn test_csv_parsing_with_quotes() {
let csv_data = r#"Name,Description
"John Doe","A person with ""special"" characters"
Jane,"Simple name""#;
let parsed = TableLikeContent::from_str(csv_data.to_string());
assert_eq!(parsed.headers.cols(), 2);
assert_eq!(parsed.rows.len(), 2);
assert_eq!(
parsed.rows[0][1].display_value().unwrap().as_ref(),
r#"A person with "special" characters"#
);
}
#[test]
fn test_csv_parsing_with_newlines_in_quotes() {
let csv_data = "Name,Description,Status\n\"John\nDoe\",\"A person with\nmultiple lines\",Active\n\"Jane Smith\",\"Simple\",\"Also\nActive\"";
let parsed = TableLikeContent::from_str(csv_data.to_string());
assert_eq!(parsed.headers.cols(), 3);
assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name");
assert_eq!(
parsed.headers[1].display_value().unwrap().as_ref(),
"Description"
);
assert_eq!(
parsed.headers[2].display_value().unwrap().as_ref(),
"Status"
);
assert_eq!(parsed.rows.len(), 2);
assert_eq!(
parsed.rows[0][0].display_value().unwrap().as_ref(),
"John\nDoe"
);
assert_eq!(
parsed.rows[0][1].display_value().unwrap().as_ref(),
"A person with\nmultiple lines"
);
assert_eq!(
parsed.rows[0][2].display_value().unwrap().as_ref(),
"Active"
);
assert_eq!(
parsed.rows[1][0].display_value().unwrap().as_ref(),
"Jane Smith"
);
assert_eq!(
parsed.rows[1][1].display_value().unwrap().as_ref(),
"Simple"
);
assert_eq!(
parsed.rows[1][2].display_value().unwrap().as_ref(),
"Also\nActive"
);
// Check line numbers
assert_eq!(parsed.line_numbers.len(), 2);
match &parsed.line_numbers[0] {
LineNumber::LineRange(start, end) => {
assert_eq!(start, &2);
assert_eq!(end, &4);
}
_ => panic!("Expected LineRange for multiline row"),
}
match &parsed.line_numbers[1] {
LineNumber::LineRange(start, end) => {
assert_eq!(start, &5);
assert_eq!(end, &6);
}
_ => panic!("Expected LineRange for second multiline row"),
}
}
#[test]
fn test_empty_csv() {
let parsed = TableLikeContent::from_str("".to_string());
assert_eq!(parsed.headers.cols(), 0);
assert!(parsed.rows.is_empty());
}
#[test]
fn test_csv_parsing_quote_offset_handling() {
let csv_data = r#"first,"se,cond",third"#;
let (parsed_cells, _) = parse_csv_with_positions(csv_data);
assert_eq!(parsed_cells.len(), 1); // One row
assert_eq!(parsed_cells[0].len(), 3); // Three cells
// first: 0..5 (no quotes)
let (content1, range1) = &parsed_cells[0][0];
assert_eq!(content1.as_ref(), "first");
assert_eq!(*range1, 0..5);
// "se,cond": 6..15 (includes quotes in range, content without quotes)
let (content2, range2) = &parsed_cells[0][1];
assert_eq!(content2.as_ref(), "se,cond");
assert_eq!(*range2, 6..15);
// third: 16..21 (no quotes)
let (content3, range3) = &parsed_cells[0][2];
assert_eq!(content3.as_ref(), "third");
assert_eq!(*range3, 16..21);
}
#[test]
fn test_csv_parsing_complex_quotes() {
let csv_data = r#"id,"name with spaces","description, with commas",status
1,"John Doe","A person with ""quotes"" and, commas",active
2,"Jane Smith","Simple description",inactive"#;
let (parsed_cells, _) = parse_csv_with_positions(csv_data);
assert_eq!(parsed_cells.len(), 3); // header + 2 rows
// Check header row
let header_row = &parsed_cells[0];
assert_eq!(header_row.len(), 4);
// id: 0..2
assert_eq!(header_row[0].0.as_ref(), "id");
assert_eq!(header_row[0].1, 0..2);
// "name with spaces": 3..21 (includes quotes)
assert_eq!(header_row[1].0.as_ref(), "name with spaces");
assert_eq!(header_row[1].1, 3..21);
// "description, with commas": 22..48 (includes quotes)
assert_eq!(header_row[2].0.as_ref(), "description, with commas");
assert_eq!(header_row[2].1, 22..48);
// status: 49..55
assert_eq!(header_row[3].0.as_ref(), "status");
assert_eq!(header_row[3].1, 49..55);
// Check first data row
let first_row = &parsed_cells[1];
assert_eq!(first_row.len(), 4);
// 1: 56..57
assert_eq!(first_row[0].0.as_ref(), "1");
assert_eq!(first_row[0].1, 56..57);
// "John Doe": 58..68 (includes quotes)
assert_eq!(first_row[1].0.as_ref(), "John Doe");
assert_eq!(first_row[1].1, 58..68);
// Content should be stripped of quotes but include escaped quotes
assert_eq!(
first_row[2].0.as_ref(),
r#"A person with "quotes" and, commas"#
);
// The range should include the outer quotes: 69..107
assert_eq!(first_row[2].1, 69..107);
// active: 108..114
assert_eq!(first_row[3].0.as_ref(), "active");
assert_eq!(first_row[3].1, 108..114);
}
}
impl TableLikeContent {
#[cfg(test)]
pub fn from_str(text: String) -> Self {
use text::{Buffer, BufferId, ReplicaId};
let buffer_id = BufferId::new(1).unwrap();
let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, text);
let snapshot = buffer.snapshot();
from_buffer(snapshot)
}
}

View File

@@ -0,0 +1,8 @@
#[cfg(feature = "dev-tools")]
mod performance_metrics_overlay;
mod preview_view;
mod render_table;
mod row_identifiers;
mod settings;
mod table_cell;
mod table_header;

View File

@@ -0,0 +1,82 @@
//! Performance metrics overlay for CSV preview debugging.
//!
//! Provides a semi-transparent overlay in the bottom-right corner showing
//! CSV parsing performance metrics for developer experience.
use ui::{ActiveTheme, Context, IntoElement, ParentElement, Styled, StyledTypography, div};
use crate::{CsvPreviewView, PerformanceMetrics};
impl CsvPreviewView {
/// Renders a semi-transparent performance metrics overlay in the bottom-right corner.
///
/// Shows CSV parsing duration for debugging and performance monitoring.
/// The overlay is positioned absolutely and styled with reduced opacity.
pub(crate) fn render_performance_metrics_overlay(
&mut self,
cx: &mut Context<Self>,
) -> impl IntoElement {
let theme = cx.theme();
let children = div()
.absolute()
.top_24()
.right_4()
.px_3()
.py_2()
.bg(theme.colors().editor_background)
.border_1()
.border_color(theme.colors().border)
.rounded_md()
.opacity(0.75)
.text_xs()
.font_buffer(cx)
.text_color(theme.colors().text_muted)
.flex()
.flex_col()
.gap_1()
.child("Performance metrics:")
.children(
format_performance_metrics(&self.performance_metrics)
.into_iter()
.map(|line| div().child(line)),
);
// Clear rendered indices to prepare for next frame
self.performance_metrics.rendered_indices.clear();
children
}
}
fn format_performance_metrics(metrics: &PerformanceMetrics) -> Vec<String> {
let mut lines = Vec::new();
// Add timing metrics using the display method
let timing_display = metrics.display();
if !timing_display.is_empty() {
lines.extend(timing_display.lines().map(|line| format!("- {}", line)));
} else {
lines.push("- No timing data yet".to_string());
}
// Add rendered indices information
if metrics.rendered_indices.is_empty() {
lines.push("- Rendered: none".to_string());
} else {
lines.push(format!(
"- Rendered: {} rows",
metrics.rendered_indices.len()
));
if metrics.rendered_indices.len() <= 20 {
// Show indices if not too many
lines.push(format!(" {:?}", metrics.rendered_indices));
} else {
// Show first/last few if too many
let first_few = &metrics.rendered_indices[..5];
let last_few = &metrics.rendered_indices[metrics.rendered_indices.len() - 5..];
lines.push(format!(" {:?}\n..{:?}", first_few, last_few));
}
}
lines
}

View File

@@ -0,0 +1,65 @@
use std::time::Instant;
use ui::{div, prelude::*};
use crate::CsvPreviewView;
impl Render for CsvPreviewView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let render_prep_start = Instant::now();
let table_with_settings = v_flex()
.size_full()
.p_4()
.bg(theme.colors().editor_background)
.track_focus(&self.focus_handle)
.child(self.render_settings_panel(window, cx))
.child({
if self.engine.contents.number_of_cols == 0 {
div()
.flex()
.items_center()
.justify_center()
.h_32()
.text_ui(cx)
.font_buffer(cx)
.text_color(cx.theme().colors().text_muted)
.child("No CSV content to display")
.into_any_element()
} else {
self.create_table(&self.column_widths.widths, cx)
}
});
let render_prep_duration = render_prep_start.elapsed();
self.performance_metrics.timings.insert(
"render_prep",
(render_prep_duration, std::time::Instant::now()),
);
let div = div()
.relative()
.w_full()
.h_full()
.child(table_with_settings);
#[cfg(feature = "dev-tools")]
let show_perf_metrics_overlay = self.settings.show_perf_metrics_overlay;
#[cfg(feature = "dev-tools")]
let div = div.when(show_perf_metrics_overlay, |div| {
div.child(self.render_performance_metrics_overlay(cx))
});
#[cfg(feature = "dev-tools")]
if !show_perf_metrics_overlay {
self.performance_metrics.rendered_indices.clear();
}
#[cfg(not(feature = "dev-tools"))]
self.performance_metrics.rendered_indices.clear();
div
}
}

View File

@@ -0,0 +1,165 @@
use crate::types::TableCell;
use gpui::{AnyElement, Entity};
use std::ops::Range;
use ui::{ColumnWidthConfig, ResizableColumnsState, Table, UncheckedTableRow, div, prelude::*};
use crate::{
CsvPreviewView,
settings::RowRenderMechanism,
types::{AnyColumn, DisplayCellId, DisplayRow},
};
impl CsvPreviewView {
/// Creates a new table.
/// Column number is derived from the `ResizableColumnsState` entity.
pub(crate) fn create_table(
&self,
current_widths: &Entity<ResizableColumnsState>,
cx: &mut Context<Self>,
) -> AnyElement {
self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx)
}
fn create_table_inner(
&self,
row_count: usize,
current_widths: &Entity<ResizableColumnsState>,
cx: &mut Context<Self>,
) -> AnyElement {
let cols = current_widths.read(cx).cols();
// Create headers array with interactive elements
let mut headers = Vec::with_capacity(cols);
headers.push(self.create_row_identifier_header(cx));
// Add the actual CSV headers with sort buttons
for i in 0..(cols - 1) {
let header_text = self
.engine
.contents
.headers
.get(AnyColumn(i))
.and_then(|h| h.display_value().cloned())
.unwrap_or_else(|| format!("Col {}", i + 1).into());
headers.push(self.create_header_element_with_sort_button(
header_text,
cx,
AnyColumn::from(i),
));
}
Table::new(cols)
.interactable(&self.table_interaction_state)
.striped()
.width_config(ColumnWidthConfig::Resizable(current_widths.clone()))
.header(headers)
.disable_base_style()
.map(|table| {
let row_identifier_text_color = cx.theme().colors().editor_line_number;
match self.settings.rendering_with {
RowRenderMechanism::VariableList => {
table.variable_row_height_list(row_count, self.list_state.clone(), {
cx.processor(move |this, display_row: usize, _window, cx| {
this.performance_metrics.rendered_indices.push(display_row);
let display_row = DisplayRow(display_row);
Self::render_single_table_row(
this,
cols,
display_row,
row_identifier_text_color,
cx,
)
.unwrap_or_else(|| panic!("Expected to render a table row"))
})
})
}
RowRenderMechanism::UniformList => {
table.uniform_list("csv-table", row_count, {
cx.processor(move |this, range: Range<usize>, _window, cx| {
// Record all display indices in the range for performance metrics
this.performance_metrics
.rendered_indices
.extend(range.clone());
range
.filter_map(|display_index| {
Self::render_single_table_row(
this,
cols,
DisplayRow(display_index),
row_identifier_text_color,
cx,
)
})
.collect()
})
})
}
}
})
.into_any_element()
}
/// Render a single table row
///
/// Used both by UniformList and VariableRowHeightList
fn render_single_table_row(
this: &CsvPreviewView,
cols: usize,
display_row: DisplayRow,
row_identifier_text_color: gpui::Hsla,
cx: &Context<CsvPreviewView>,
) -> Option<UncheckedTableRow<AnyElement>> {
// Get the actual row index from our sorted indices
let data_row = this.engine.d2d_mapping().get_data_row(display_row)?;
let row = this.engine.contents.get_row(data_row)?;
let mut elements = Vec::with_capacity(cols);
elements.push(this.create_row_identifier_cell(display_row, data_row, cx)?);
// Remaining columns: actual CSV data
for col in (0..this.engine.contents.number_of_cols).map(AnyColumn) {
let table_cell = row.expect_get(col);
// TODO: Introduce `<null>` cell type
let cell_content = table_cell.display_value().cloned().unwrap_or_default();
let display_cell_id = DisplayCellId::new(display_row, col);
let cell = div().size_full().whitespace_nowrap().text_ellipsis().child(
CsvPreviewView::create_selectable_cell(
display_cell_id,
cell_content,
this.settings.vertical_alignment,
cx,
),
);
elements.push(
div()
.size_full()
.when(this.settings.show_debug_info, |parent| {
parent.child(div().text_color(row_identifier_text_color).child(
match table_cell {
TableCell::Real { position: pos, .. } => {
let slv = pos.start.timestamp().value;
let so = pos.start.offset;
let elv = pos.end.timestamp().value;
let eo = pos.end.offset;
format!("Pos {so}(L{slv})-{eo}(L{elv})")
}
TableCell::Virtual => "Virtual cell".into(),
},
))
})
.text_ui(cx)
.child(cell)
.into_any_element(),
);
}
Some(elements)
}
}

View File

@@ -0,0 +1,184 @@
use ui::{
ActiveTheme as _, AnyElement, Button, ButtonCommon as _, ButtonSize, ButtonStyle,
Clickable as _, Context, ElementId, IntoElement as _, ParentElement as _, SharedString,
Styled as _, StyledTypography as _, Tooltip, div,
};
use crate::{
CsvPreviewView,
settings::RowIdentifiers,
types::{DataRow, DisplayRow, LineNumber},
};
pub enum RowIdentDisplayMode {
/// E.g
/// ```text
/// 1
/// ...
/// 5
/// ```
Vertical,
/// E.g.
/// ```text
/// 1-5
/// ```
Horizontal,
}
impl LineNumber {
pub fn display_string(&self, mode: RowIdentDisplayMode) -> String {
match *self {
LineNumber::Line(line) => line.to_string(),
LineNumber::LineRange(start, end) => match mode {
RowIdentDisplayMode::Vertical => {
if start + 1 == end {
format!("{start}\n{end}")
} else {
format!("{start}\n...\n{end}")
}
}
RowIdentDisplayMode::Horizontal => {
format!("{start}-{end}")
}
},
}
}
}
impl CsvPreviewView {
/// Calculate the optimal width for the row identifier column (line numbers or row numbers).
///
/// This ensures the column is wide enough to display the largest identifier comfortably,
/// but not wastefully wide for small files.
pub(crate) fn calculate_row_identifier_column_width(&self) -> f32 {
match self.settings.numbering_type {
RowIdentifiers::SrcLines => self.calculate_line_number_width(),
RowIdentifiers::RowNum => self.calculate_row_number_width(),
}
}
/// Calculate width needed for line numbers (can be multi-line)
fn calculate_line_number_width(&self) -> f32 {
// Find the maximum line number that could be displayed
let max_line_number = self
.engine
.contents
.line_numbers
.iter()
.map(|ln| match ln {
LineNumber::Line(n) => *n,
LineNumber::LineRange(_, end) => *end,
})
.max()
.unwrap_or_default();
let digit_count = if max_line_number == 0 {
1
} else {
(max_line_number as f32).log10().floor() as usize + 1
};
// if !self.settings.multiline_cells_enabled {
// // Uses horizontal line numbers layout like `123-456`. Needs twice the size
// digit_count *= 2;
// }
let char_width_px = 9.0; // TODO: get real width of the characters
let base_width = (digit_count as f32) * char_width_px;
let padding = 20.0;
let min_width = 60.0;
(base_width + padding).max(min_width)
}
/// Calculate width needed for sequential row numbers
fn calculate_row_number_width(&self) -> f32 {
let max_row_number = self.engine.contents.rows.len();
let digit_count = if max_row_number == 0 {
1
} else {
(max_row_number as f32).log10().floor() as usize + 1
};
let char_width_px = 9.0; // TODO: get real width of the characters
let base_width = (digit_count as f32) * char_width_px;
let padding = 20.0;
let min_width = 60.0;
(base_width + padding).max(min_width)
}
pub(crate) fn create_row_identifier_header(
&self,
cx: &mut Context<'_, CsvPreviewView>,
) -> AnyElement {
// First column: row identifier (clickable to toggle between Lines and Rows)
let row_identifier_text = match self.settings.numbering_type {
RowIdentifiers::SrcLines => "Lines",
RowIdentifiers::RowNum => "Rows",
};
let view = cx.entity();
let value = div()
.font_buffer(cx)
.child(
Button::new(
ElementId::Name("row-identifier-toggle".into()),
row_identifier_text,
)
.style(ButtonStyle::Subtle)
.size(ButtonSize::Compact)
.tooltip(Tooltip::text(
"Toggle between: file line numbers or sequential row numbers",
))
.on_click(move |_event, _window, cx| {
view.update(cx, |this, cx| {
this.settings.numbering_type = match this.settings.numbering_type {
RowIdentifiers::SrcLines => RowIdentifiers::RowNum,
RowIdentifiers::RowNum => RowIdentifiers::SrcLines,
};
this.sync_column_widths(cx);
cx.notify();
});
}),
)
.into_any_element();
value
}
pub(crate) fn create_row_identifier_cell(
&self,
display_row: DisplayRow,
data_row: DataRow,
cx: &Context<'_, CsvPreviewView>,
) -> Option<AnyElement> {
let row_identifier: SharedString = match self.settings.numbering_type {
RowIdentifiers::SrcLines => self
.engine
.contents
.line_numbers
.get(*data_row)?
.display_string(if self.settings.multiline_cells_enabled {
RowIdentDisplayMode::Vertical
} else {
RowIdentDisplayMode::Horizontal
})
.into(),
RowIdentifiers::RowNum => (*display_row + 1).to_string().into(),
};
let value = div()
.flex()
.px_1()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.h_full()
.text_ui(cx)
// Row identifiers are always centered
.items_center()
.justify_end()
.font_buffer(cx)
.child(row_identifier)
.into_any_element();
Some(value)
}
}

View File

@@ -0,0 +1,182 @@
use ui::{
ActiveTheme as _, AnyElement, ButtonSize, Context, ContextMenu, DropdownMenu, ElementId,
IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex,
};
use crate::{CsvPreviewView, settings::VerticalAlignment};
///// Settings related /////
impl CsvPreviewView {
/// Render settings panel above the table
pub(crate) fn render_settings_panel(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let current_alignment_text = match self.settings.vertical_alignment {
VerticalAlignment::Top => "Top",
VerticalAlignment::Center => "Center",
};
let view = cx.entity();
let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
menu.entry("Top", None, {
let view = view.clone();
move |_window, cx| {
view.update(cx, |this, cx| {
this.settings.vertical_alignment = VerticalAlignment::Top;
cx.notify();
});
}
})
.entry("Center", None, {
let view = view.clone();
move |_window, cx| {
view.update(cx, |this, cx| {
this.settings.vertical_alignment = VerticalAlignment::Center;
cx.notify();
});
}
})
});
let panel = h_flex()
.gap_4()
.p_2()
.bg(cx.theme().colors().surface_background)
.border_b_1()
.border_color(cx.theme().colors().border)
.flex_wrap()
.child(
h_flex()
.gap_2()
.items_center()
.child(
div()
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child("Text Alignment:"),
)
.child(
DropdownMenu::new(
ElementId::Name("vertical-alignment-dropdown".into()),
current_alignment_text,
alignment_dropdown_menu,
)
.trigger_size(ButtonSize::Compact)
.trigger_tooltip(Tooltip::text(
"Choose vertical text alignment within cells",
)),
),
);
#[cfg(feature = "dev-tools")]
let panel = panel.child(
h_flex()
.gap_2()
.items_center()
.child(
div()
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child("Dev-only:"),
)
.child(create_dev_only_popover_menu(cx)),
);
panel.into_any_element()
}
}
#[cfg(feature = "dev-tools")]
fn create_dev_only_popover_menu(
cx: &mut Context<'_, CsvPreviewView>,
) -> ui::PopoverMenu<ContextMenu> {
use crate::settings::RowRenderMechanism;
use ui::{IconButton, IconName, IconPosition, IconSize, PopoverMenu};
PopoverMenu::new("debug-options-menu")
.trigger_with_tooltip(
IconButton::new("debug-options-trigger", IconName::Settings).icon_size(IconSize::Small),
Tooltip::text(
"Dev-only section used for debugging purposes.\nWill be removed on public release of CSV feature"
),
)
.menu({
let view_entity = cx.entity();
move |window, cx| {
let view = view_entity.read(cx);
let settings = view.settings.clone();
Some(ContextMenu::build(window, cx, |menu, _, _| {
menu.header("Rendering Mode")
.toggleable_entry(
"Variable Height",
settings.rendering_with == RowRenderMechanism::VariableList,
IconPosition::Start,
None,
{
let view_entity = view_entity.clone();
move |_w, cx| {
view_entity.update(cx, |view, cx| {
view.settings.rendering_with =
RowRenderMechanism::VariableList;
view.settings.multiline_cells_enabled = true;
cx.notify();
})
}
},
)
.toggleable_entry(
"Uniform Height",
settings.rendering_with == RowRenderMechanism::UniformList,
IconPosition::Start,
None,
{
let view_entity = view_entity.clone();
move |_w, cx| {
view_entity.update(cx, |view, cx| {
view.settings.rendering_with =
RowRenderMechanism::UniformList;
view.settings.multiline_cells_enabled = false;
cx.notify();
})
}
},
)
.separator()
.toggleable_entry(
"Show perf metrics",
settings.show_perf_metrics_overlay,
IconPosition::Start,
None,
{
let view_entity = view_entity.clone();
move |_w, cx| {
view_entity.update(cx, |view, cx| {
view.settings.show_perf_metrics_overlay =
!view.settings.show_perf_metrics_overlay;
cx.notify();
})
}
},
)
.toggleable_entry(
"Show cell positions",
settings.show_debug_info,
IconPosition::Start,
None,
{
let view_entity = view_entity.clone();
move |_, cx| {
view_entity.update(cx, |view, cx| {
view.settings.show_debug_info =
!view.settings.show_debug_info;
cx.notify();
})
}
},
)
}))
}
})
}

View File

@@ -0,0 +1,55 @@
//! Table Cell Rendering
use gpui::{AnyElement, ElementId};
use ui::{SharedString, Tooltip, div, prelude::*};
use crate::{CsvPreviewView, settings::VerticalAlignment, types::DisplayCellId};
impl CsvPreviewView {
/// Create selectable table cell with mouse event handlers.
pub fn create_selectable_cell(
display_cell_id: DisplayCellId,
cell_content: SharedString,
vertical_alignment: VerticalAlignment,
cx: &Context<CsvPreviewView>,
) -> AnyElement {
create_table_cell(display_cell_id, cell_content, vertical_alignment, cx)
// Mouse events handlers will be here
.into_any_element()
}
}
/// Create styled table cell div element.
fn create_table_cell(
display_cell_id: DisplayCellId,
cell_content: SharedString,
vertical_alignment: VerticalAlignment,
cx: &Context<'_, CsvPreviewView>,
) -> gpui::Stateful<Div> {
div()
.id(ElementId::Name(
format!(
"csv-display-cell-{}-{}",
*display_cell_id.row, *display_cell_id.col
)
.into(),
))
.cursor_pointer()
.flex()
.h_full()
.px_1()
.bg(cx.theme().colors().editor_background)
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.map(|div| match vertical_alignment {
VerticalAlignment::Top => div.items_start(),
VerticalAlignment::Center => div.items_center(),
})
.map(|div| match vertical_alignment {
VerticalAlignment::Top => div.content_start(),
VerticalAlignment::Center => div.content_center(),
})
.font_buffer(cx)
.tooltip(Tooltip::text(cell_content.clone()))
.child(div().child(cell_content))
}

View File

@@ -0,0 +1,90 @@
use gpui::ElementId;
use ui::{Tooltip, prelude::*};
use crate::{
CsvPreviewView,
table_data_engine::sorting_by_column::{AppliedSorting, SortDirection},
types::AnyColumn,
};
impl CsvPreviewView {
/// Create header for data, which is orderable with text on the left and sort button on the right
pub(crate) fn create_header_element_with_sort_button(
&self,
header_text: SharedString,
cx: &mut Context<'_, CsvPreviewView>,
col_idx: AnyColumn,
) -> AnyElement {
// CSV data columns: text + filter/sort buttons
h_flex()
.justify_between()
.items_center()
.w_full()
.font_buffer(cx)
.child(div().child(header_text))
.child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx)))
.into_any_element()
}
fn create_sort_button(
&self,
cx: &mut Context<'_, CsvPreviewView>,
col_idx: AnyColumn,
) -> Button {
let sort_btn = Button::new(
ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64),
match self.engine.applied_sorting {
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
SortDirection::Asc => "",
SortDirection::Desc => "",
},
_ => "", // Unsorted/available for sorting
},
)
.size(ButtonSize::Compact)
.style(
if self
.engine
.applied_sorting
.is_some_and(|o| o.col_idx == col_idx)
{
ButtonStyle::Filled
} else {
ButtonStyle::Subtle
},
)
.tooltip(Tooltip::text(match self.engine.applied_sorting {
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
SortDirection::Asc => "Sorted A-Z. Click to sort Z-A",
SortDirection::Desc => "Sorted Z-A. Click to disable sorting",
},
_ => "Not sorted. Click to sort A-Z",
}))
.on_click(cx.listener(move |this, _event, _window, cx| {
let new_sorting = match this.engine.applied_sorting {
Some(ordering) if ordering.col_idx == col_idx => {
// Same column clicked - cycle through states
match ordering.direction {
SortDirection::Asc => Some(AppliedSorting {
col_idx,
direction: SortDirection::Desc,
}),
SortDirection::Desc => None, // Clear sorting
}
}
_ => {
// Different column or no sorting - start with ascending
Some(AppliedSorting {
col_idx,
direction: SortDirection::Asc,
})
}
};
this.engine.applied_sorting = new_sorting;
this.apply_sort();
cx.notify();
}));
sort_btn
}
}

View File

@@ -0,0 +1,38 @@
#[derive(Default, Clone, Copy, PartialEq)]
pub enum RowRenderMechanism {
/// More correct for multiline content, but slower.
#[allow(dead_code)] // Will be used when settings ui is added
VariableList,
/// Default behaviour for now while resizable columns are being stabilized.
#[default]
UniformList,
}
#[derive(Default, Clone, Copy)]
pub enum VerticalAlignment {
/// Align text to the top of cells
#[default]
Top,
/// Center text vertically in cells
Center,
}
#[derive(Default, Clone, Copy)]
pub enum RowIdentifiers {
/// Show original line numbers from CSV file
#[default]
SrcLines,
/// Show sequential row numbers starting from 1
RowNum,
}
#[derive(Clone, Default)]
pub(crate) struct CsvPreviewSettings {
pub(crate) rendering_with: RowRenderMechanism,
pub(crate) vertical_alignment: VerticalAlignment,
pub(crate) numbering_type: RowIdentifiers,
pub(crate) show_debug_info: bool,
#[cfg(feature = "dev-tools")]
pub(crate) show_perf_metrics_overlay: bool,
pub(crate) multiline_cells_enabled: bool,
}

View File

@@ -0,0 +1,90 @@
//! This module defines core operations and config of tabular data view (CSV table)
//! It operates in 2 coordinate systems:
//! - `DataCellId` - indices of src data cells
//! - `DisplayCellId` - indices of data after applied transformations like sorting/filtering, which is used to render cell on the screen
//!
//! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles.
use std::{collections::HashMap, sync::Arc};
use ui::table_row::TableRow;
use crate::{
table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows},
types::{DataRow, DisplayRow, TableCell, TableLikeContent},
};
pub mod sorting_by_column;
#[derive(Default)]
pub(crate) struct TableDataEngine {
pub applied_sorting: Option<AppliedSorting>,
d2d_mapping: DisplayToDataMapping,
pub contents: TableLikeContent,
}
impl TableDataEngine {
pub(crate) fn d2d_mapping(&self) -> &DisplayToDataMapping {
&self.d2d_mapping
}
pub(crate) fn apply_sort(&mut self) {
self.d2d_mapping
.apply_sorting(self.applied_sorting, &self.contents.rows);
self.d2d_mapping.merge_mappings();
}
/// Applies sorting and filtering to the data and produces display to data mapping
pub(crate) fn calculate_d2d_mapping(&mut self) {
self.d2d_mapping
.apply_sorting(self.applied_sorting, &self.contents.rows);
self.d2d_mapping.merge_mappings();
}
}
/// Relation of Display (rendered) rows to Data (src) rows with applied transformations
/// Transformations applied:
/// - sorting by column
#[derive(Debug, Default)]
pub struct DisplayToDataMapping {
/// All rows sorted, regardless of applied filtering. Applied every time sorting changes
pub sorted_rows: Vec<DataRow>,
/// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows`
pub mapping: Arc<HashMap<DisplayRow, DataRow>>,
}
impl DisplayToDataMapping {
/// Get the data row for a given display row
pub fn get_data_row(&self, display_row: DisplayRow) -> Option<DataRow> {
self.mapping.get(&display_row).copied()
}
/// Get the number of filtered rows
pub fn visible_row_count(&self) -> usize {
self.mapping.len()
}
/// Computes sorting
fn apply_sorting(&mut self, sorting: Option<AppliedSorting>, rows: &[TableRow<TableCell>]) {
let data_rows: Vec<DataRow> = (0..rows.len()).map(DataRow).collect();
let sorted_rows = if let Some(sorting) = sorting {
sort_data_rows(&rows, data_rows, sorting)
} else {
data_rows
};
self.sorted_rows = sorted_rows;
}
/// Take pre-computed sorting and filtering results, and apply them to the mapping
fn merge_mappings(&mut self) {
self.mapping = Arc::new(
self.sorted_rows
.iter()
.enumerate()
.map(|(display, data)| (DisplayRow(display), *data))
.collect(),
);
}
}

View File

@@ -0,0 +1,49 @@
use ui::table_row::TableRow;
use crate::types::{AnyColumn, DataRow, TableCell};
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum SortDirection {
Asc,
Desc,
}
/// Config or currently active sorting
#[derive(Debug, Clone, Copy)]
pub struct AppliedSorting {
/// 0-based column index
pub col_idx: AnyColumn,
/// Direction of sorting (asc/desc)
pub direction: SortDirection,
}
pub fn sort_data_rows(
content_rows: &[TableRow<TableCell>],
mut data_row_ids: Vec<DataRow>,
sorting: AppliedSorting,
) -> Vec<DataRow> {
data_row_ids.sort_by(|&a, &b| {
let row_a = &content_rows[*a];
let row_b = &content_rows[*b];
// TODO: Decide how to handle nulls (on top or on bottom)
let val_a = row_a
.get(sorting.col_idx)
.and_then(|tc| tc.display_value())
.map(|tc| tc.as_str())
.unwrap_or("");
let val_b = row_b
.get(sorting.col_idx)
.and_then(|tc| tc.display_value())
.map(|tc| tc.as_str())
.unwrap_or("");
let cmp = val_a.cmp(val_b);
match sorting.direction {
SortDirection::Asc => cmp,
SortDirection::Desc => cmp.reverse(),
}
});
data_row_ids
}

View File

@@ -0,0 +1,17 @@
use std::fmt::Debug;
pub use coordinates::*;
mod coordinates;
pub use table_cell::*;
mod table_cell;
pub use table_like_content::*;
mod table_like_content;
/// Line number information for CSV rows
#[derive(Debug, Clone, Copy)]
pub enum LineNumber {
/// Single line row
Line(usize),
/// Multi-line row spanning from start to end line. Incluisive
LineRange(usize, usize),
}

View File

@@ -0,0 +1,127 @@
//! Type definitions for CSV table coordinates and cell identifiers.
//!
//! Provides newtypes for self-documenting coordinate systems:
//! - Display coordinates: Visual positions in rendered table
//! - Data coordinates: Original CSV data positions
use std::ops::Deref;
///// Rows /////
/// Visual row position in rendered table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DisplayRow(pub usize);
impl DisplayRow {
/// Create a new display row
pub fn new(row: usize) -> Self {
Self(row)
}
/// Get the inner row value
pub fn get(self) -> usize {
self.0
}
}
impl Deref for DisplayRow {
type Target = usize;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Original CSV row position.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DataRow(pub usize);
impl DataRow {
/// Create a new data row
pub fn new(row: usize) -> Self {
Self(row)
}
}
impl Deref for DataRow {
type Target = usize;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<usize> for DisplayRow {
fn from(row: usize) -> Self {
DisplayRow::new(row)
}
}
impl From<usize> for DataRow {
fn from(row: usize) -> Self {
DataRow::new(row)
}
}
///// Columns /////
/// Data column position in CSV table. 0-based
///
/// Currently represents both display and data coordinate systems since
/// column reordering is not yet implemented. When column reordering is added,
/// this will need to be split into `DisplayColumn` and `DataColumn` types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AnyColumn(pub usize);
impl AnyColumn {
/// Create a new column ID
pub fn new(col: usize) -> Self {
Self(col)
}
/// Get the inner column value
pub fn get(self) -> usize {
self.0
}
}
impl Deref for AnyColumn {
type Target = usize;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<usize> for AnyColumn {
fn from(col: usize) -> Self {
AnyColumn::new(col)
}
}
impl From<AnyColumn> for usize {
fn from(value: AnyColumn) -> Self {
*value
}
}
///// Cells /////
/// Visual cell position in rendered table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DisplayCellId {
pub row: DisplayRow,
pub col: AnyColumn,
}
impl DisplayCellId {
/// Create a new display cell ID
pub fn new(row: impl Into<DisplayRow>, col: impl Into<AnyColumn>) -> Self {
Self {
row: row.into(),
col: col.into(),
}
}
/// Returns (row, column)
pub fn to_raw(&self) -> (usize, usize) {
(self.row.0, self.col.0)
}
}

View File

@@ -0,0 +1,54 @@
use text::Anchor;
use ui::SharedString;
/// Position of a cell within the source CSV buffer
#[derive(Clone, Debug)]
pub struct CellContentSpan {
/// Start anchor of the cell content in the source buffer
pub start: Anchor,
/// End anchor of the cell content in the source buffer
pub end: Anchor,
}
/// A table cell with its content and position in the source buffer
#[derive(Clone, Debug)]
pub enum TableCell {
/// Cell existing in the CSV
Real {
/// Position of this cell in the source buffer
position: CellContentSpan,
/// Cached display value (for performance)
cached_value: SharedString,
},
/// Virtual cell, created to pad malformed row
Virtual,
}
impl TableCell {
/// Create a TableCell with buffer position tracking
pub fn from_buffer_position(
content: SharedString,
start_offset: usize,
end_offset: usize,
buffer_snapshot: &text::BufferSnapshot,
) -> Self {
let start_anchor = buffer_snapshot.anchor_before(start_offset);
let end_anchor = buffer_snapshot.anchor_after(end_offset);
Self::Real {
position: CellContentSpan {
start: start_anchor,
end: end_anchor,
},
cached_value: content,
}
}
/// Get the display value for this cell
pub fn display_value(&self) -> Option<&SharedString> {
match self {
TableCell::Real { cached_value, .. } => Some(cached_value),
TableCell::Virtual => None,
}
}
}

View File

@@ -0,0 +1,32 @@
use ui::table_row::TableRow;
use crate::types::{DataRow, LineNumber, TableCell};
/// Generic container struct of table-like data (CSV, TSV, etc)
#[derive(Clone)]
pub struct TableLikeContent {
/// Number of data columns.
/// Defines table width used to validate `TableRow` on creation
pub number_of_cols: usize,
pub headers: TableRow<TableCell>,
pub rows: Vec<TableRow<TableCell>>,
/// Follows the same indices as `rows`
pub line_numbers: Vec<LineNumber>,
}
impl Default for TableLikeContent {
fn default() -> Self {
Self {
number_of_cols: 0,
headers: TableRow::<TableCell>::from_vec(vec![], 0),
rows: vec![],
line_numbers: vec![],
}
}
}
impl TableLikeContent {
pub(crate) fn get_row(&self, data_row: DataRow) -> Option<&TableRow<TableCell>> {
self.rows.get(*data_row)
}
}