mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
TODO:
- [x] merge main
- [x] nonshrinking `set_excerpts_for_path`
- [x] Test-drive potential problem areas in the app
- [x] prepare cloud side
- [x] test collaboration
- [ ] docstrings
- [ ] ???
## Context
### Background
Currently, a multibuffer consists of an arbitrary list of
anchor-delimited excerpts from individual buffers. Excerpt ranges for a
fixed buffer are permitted to overlap, and can appear in any order in
the multibuffer, possibly separated by excerpts from other buffers.
However, in practice all code that constructs multibuffers does so using
the APIs defined in the `path_key` submodule of the `multi_buffer` crate
(`set_excerpts_for_path` etc.) If you only use these APIs, the resulting
multibuffer will maintain the following invariants:
- All excerpts for the same buffer appear contiguously in the
multibuffer
- Excerpts for the same buffer cannot overlap
- Excerpts for the same buffer appear in order
- The placement of the excerpts for a specific buffer in the multibuffer
are determined by the `PathKey` passed to `set_excerpts_for_path`. There
is exactly one `PathKey` per buffer in the multibuffer
### Purpose of this PR
This PR changes the multibuffer so that the invariants maintained by the
`path_key` APIs *always* hold. It's no longer possible to construct a
multibuffer with overlapping excerpts, etc. The APIs that permitted
this, like `insert_excerpts_with_ids_after`, have been removed in favor
of the `path_key` suite.
The main upshot of this is that given a `text::Anchor` and a
multibuffer, it's possible to efficiently figure out the unique excerpt
that includes that anchor, if any:
```
impl MultiBufferSnapshot {
fn buffer_anchor_to_anchor(&self, anchor: text::Anchor) -> Option<multi_buffer::Anchor>;
}
```
And in the other direction, given a `multi_buffer::Anchor`, we can look
at its `text::Anchor` to locate the excerpt that contains it. That means
we don't need an `ExcerptId` to create or resolve
`multi_buffer::Anchor`, and in fact we can delete `ExcerptId` entirely,
so that excerpts no longer have any identity outside their
`Range<text::Anchor>`.
There are a large number of changes to `editor` and other downstream
crates as a result of removing `ExcerptId` and multibuffer APIs that
assumed it.
### Other changes
There are some other improvements that are not immediate consequences of
that big change, but helped make it smoother. Notably:
- The `buffer_id` field of `text::Anchor` is no longer optional.
`text::Anchor::{MIN, MAX}` have been removed in favor of
`min_for_buffer`, etc.
- `multi_buffer::Anchor` is now a three-variant enum (inlined slightly):
```
enum Anchor {
Min,
Excerpt {
text_anchor: text::Anchor,
path_key_index: PathKeyIndex,
diff_base_anchor: Option<text::Anchor>,
},
Max,
}
```
That means it's no longer possible to unconditionally access the
`text_anchor` field, which is good because most of the places that were
doing that were buggy for min/max! Instead, we have a new API that
correctly resolves min/max to the start of the first excerpt or the end
of the last excerpt:
```
impl MultiBufferSnapshot {
fn anchor_to_buffer_anchor(&self, anchor: multi_buffer::Anchor) -> Option<text::Anchor>;
}
```
- `MultiBufferExcerpt` has been removed in favor of a new
`map_excerpt_ranges` API directly on `MultiBufferSnapshot`.
## Self-Review Checklist
<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
- N/A
---------
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
Co-authored-by: Conrad <conrad@zed.dev>
331 lines
12 KiB
Rust
331 lines
12 KiB
Rust
use editor::{Editor, EditorEvent};
|
|
use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
|
|
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, DefiniteLength, RedistributableColumnsState, 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";
|
|
}
|
|
|
|
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 rows column
|
|
let cols = self.engine.contents.headers.cols() + 1;
|
|
let remaining_col_number = cols.saturating_sub(1);
|
|
let fraction = if remaining_col_number > 0 {
|
|
1. / remaining_col_number as f32
|
|
} else {
|
|
1.
|
|
};
|
|
let mut widths = vec![DefiniteLength::Fraction(fraction); cols];
|
|
let line_number_width = self.calculate_row_identifier_column_width();
|
|
widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into()));
|
|
|
|
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.initial_widths().as_slice() != widths.as_slice()
|
|
|| state.resize_behavior().as_slice() != resize_behaviors.as_slice()
|
|
{
|
|
*state = RedistributableColumnsState::new(cols, widths, resize_behaviors);
|
|
}
|
|
});
|
|
}
|
|
|
|
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.)),
|
|
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(1.));
|
|
}
|
|
|
|
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<RedistributableColumnsState>,
|
|
}
|
|
|
|
impl ColumnWidths {
|
|
pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
|
|
Self {
|
|
widths: cx.new(|_cx| {
|
|
RedistributableColumnsState::new(
|
|
cols,
|
|
vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols],
|
|
vec![ui::TableResizeBehavior::Resizable; cols],
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
}
|