Compare commits
10 Commits
restore-hu
...
kb/shaky-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd0274b97 | ||
|
|
d7e41f74fb | ||
|
|
e05dcecac4 | ||
|
|
32600f255a | ||
|
|
a7e07010e5 | ||
|
|
ea34cc5324 | ||
|
|
a7d43063d4 | ||
|
|
8001877df2 | ||
|
|
b603372f44 | ||
|
|
7427924405 |
@@ -1178,6 +1178,10 @@
|
||||
"remove_trailing_whitespace_on_save": true,
|
||||
// Whether to start a new line with a comment when a previous line is a comment as well.
|
||||
"extend_comment_on_newline": true,
|
||||
// Whether to continue markdown lists when pressing enter.
|
||||
"extend_list_on_newline": true,
|
||||
// Whether to indent list items when pressing tab after a list marker.
|
||||
"indent_list_on_tab": true,
|
||||
// Removes any lines containing only whitespace at the end of the file and
|
||||
// ensures just one newline at the end.
|
||||
"ensure_final_newline_on_save": true,
|
||||
|
||||
@@ -216,7 +216,20 @@ impl ActionLog {
|
||||
loop {
|
||||
futures::select_biased! {
|
||||
buffer_update = buffer_updates.next() => {
|
||||
if let Some((author, buffer_snapshot)) = buffer_update {
|
||||
if let Some((mut author, mut buffer_snapshot)) = buffer_update {
|
||||
// TODO kb `buffer.edit(` made by agent input below fires off this code path again
|
||||
// as we react on buffer edits and send them under "user" edits here again and again.
|
||||
// Below is a stub to deduplicate things, but this should be done on the editor level
|
||||
|
||||
// Drain any pending updates and keep only the latest snapshot.
|
||||
// This coalesces rapid edits to avoid repeatedly recalculating diffs.
|
||||
// while let Ok(Some((next_author, next_snapshot))) = buffer_updates.try_next() {
|
||||
// // If any update was from Agent, treat the coalesced update as Agent
|
||||
// if matches!(next_author, ChangeAuthor::Agent) {
|
||||
// author = ChangeAuthor::Agent;
|
||||
// }
|
||||
// buffer_snapshot = next_snapshot;
|
||||
// }
|
||||
Self::track_edits(&this, &buffer, author, buffer_snapshot, cx).await?;
|
||||
} else {
|
||||
break;
|
||||
@@ -246,39 +259,50 @@ impl ActionLog {
|
||||
.get_mut(buffer)
|
||||
.context("buffer not tracked")?;
|
||||
|
||||
let rebase = cx.background_spawn({
|
||||
let mut base_text = tracked_buffer.diff_base.clone();
|
||||
let old_snapshot = tracked_buffer.snapshot.clone();
|
||||
let new_snapshot = buffer_snapshot.clone();
|
||||
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
|
||||
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
||||
async move {
|
||||
if let ChangeAuthor::User = author {
|
||||
apply_non_conflicting_edits(
|
||||
&unreviewed_edits,
|
||||
edits,
|
||||
&mut base_text,
|
||||
new_snapshot.as_rope(),
|
||||
);
|
||||
let old_snapshot = tracked_buffer.snapshot.clone();
|
||||
let new_snapshot = buffer_snapshot.clone();
|
||||
|
||||
if !new_snapshot.version().changed_since(old_snapshot.version()) {
|
||||
Ok(None)
|
||||
} else {
|
||||
let rebase = cx.background_spawn({
|
||||
let mut base_text = tracked_buffer.diff_base.clone();
|
||||
|
||||
let unreviewed_edits = tracked_buffer.unreviewed_edits.clone();
|
||||
let edits = diff_snapshots(&old_snapshot, &new_snapshot);
|
||||
async move {
|
||||
if let ChangeAuthor::User = author {
|
||||
apply_non_conflicting_edits(
|
||||
&unreviewed_edits,
|
||||
edits,
|
||||
&mut base_text,
|
||||
new_snapshot.as_rope(),
|
||||
);
|
||||
}
|
||||
|
||||
(Arc::new(base_text.to_string()), base_text)
|
||||
}
|
||||
});
|
||||
|
||||
(Arc::new(base_text.to_string()), base_text)
|
||||
}
|
||||
});
|
||||
|
||||
anyhow::Ok(rebase)
|
||||
anyhow::Ok(Some(rebase))
|
||||
}
|
||||
})??;
|
||||
let (new_base_text, new_diff_base) = rebase.await;
|
||||
|
||||
Self::update_diff(
|
||||
this,
|
||||
buffer,
|
||||
buffer_snapshot,
|
||||
new_base_text,
|
||||
new_diff_base,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
if let Some(rebase) = rebase {
|
||||
let (new_base_text, new_diff_base) = rebase.await;
|
||||
|
||||
Self::update_diff(
|
||||
this,
|
||||
buffer,
|
||||
buffer_snapshot,
|
||||
new_base_text,
|
||||
new_diff_base,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn keep_committed_edits(
|
||||
|
||||
@@ -1483,8 +1483,18 @@ impl AgentDiff {
|
||||
};
|
||||
|
||||
let multibuffer = editor.read(cx).buffer().clone();
|
||||
let new_diff = diff_handle.update(cx, |original_diff, cx| {
|
||||
cx.new(|cx| buffer_diff::BufferDiff::new(original_diff.base_text(), cx))
|
||||
});
|
||||
multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.add_diff(diff_handle.clone(), cx);
|
||||
// TODO kb is there a better way?
|
||||
// This will force real buffer and agent panel's one to calculate diffs independently.
|
||||
// Buffer's calculation will be non-instant (debounced by rapid edits) and theoretically may be different
|
||||
// (as the agent one could be optimized for streaming)
|
||||
|
||||
multibuffer.add_diff(new_diff, cx);
|
||||
// If we keep the diff handle shared, real buffer will flicker if the line wrap is enabled and the agent edits multiple lines.
|
||||
// multibuffer.add_diff(diff_handle.clone(), cx);
|
||||
});
|
||||
|
||||
let reviewing_state = EditorState::Reviewing;
|
||||
|
||||
@@ -163,6 +163,7 @@ use project::{
|
||||
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings},
|
||||
};
|
||||
use rand::seq::SliceRandom;
|
||||
use regex::Regex;
|
||||
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
|
||||
use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
|
||||
use selections_collection::{MutableSelectionsCollection, SelectionsCollection};
|
||||
@@ -189,7 +190,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
|
||||
use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _};
|
||||
use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _};
|
||||
use theme::{
|
||||
AccentColors, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings,
|
||||
observe_buffer_font_size_adjustment,
|
||||
@@ -4787,82 +4788,146 @@ impl Editor {
|
||||
let end = selection.end;
|
||||
let selection_is_empty = start == end;
|
||||
let language_scope = buffer.language_scope_at(start);
|
||||
let (comment_delimiter, doc_delimiter, newline_formatting) =
|
||||
if let Some(language) = &language_scope {
|
||||
let mut newline_formatting =
|
||||
NewlineFormatting::new(&buffer, start..end, language);
|
||||
let (delimiter, newline_config) = if let Some(language) = &language_scope {
|
||||
let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
|
||||
&buffer,
|
||||
start..end,
|
||||
language,
|
||||
)
|
||||
|| NewlineConfig::insert_extra_newline_tree_sitter(
|
||||
&buffer,
|
||||
start..end,
|
||||
);
|
||||
|
||||
// Comment extension on newline is allowed only for cursor selections
|
||||
let comment_delimiter = maybe!({
|
||||
if !selection_is_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !multi_buffer.language_settings(cx).extend_comment_on_newline
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
return comment_delimiter_for_newline(
|
||||
&start_point,
|
||||
&buffer,
|
||||
language,
|
||||
);
|
||||
});
|
||||
|
||||
let doc_delimiter = maybe!({
|
||||
if !selection_is_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !multi_buffer.language_settings(cx).extend_comment_on_newline
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
return documentation_delimiter_for_newline(
|
||||
&start_point,
|
||||
&buffer,
|
||||
language,
|
||||
&mut newline_formatting,
|
||||
);
|
||||
});
|
||||
|
||||
(comment_delimiter, doc_delimiter, newline_formatting)
|
||||
} else {
|
||||
(None, None, NewlineFormatting::default())
|
||||
let mut newline_config = NewlineConfig::Newline {
|
||||
additional_indent: IndentSize::spaces(0),
|
||||
extra_line_additional_indent: if needs_extra_newline {
|
||||
Some(IndentSize::spaces(0))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
prevent_auto_indent: false,
|
||||
};
|
||||
|
||||
let prevent_auto_indent = doc_delimiter.is_some();
|
||||
let delimiter = comment_delimiter.or(doc_delimiter);
|
||||
let comment_delimiter = maybe!({
|
||||
if !selection_is_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
let capacity_for_delimiter =
|
||||
delimiter.as_deref().map(str::len).unwrap_or_default();
|
||||
let mut new_text = String::with_capacity(
|
||||
1 + capacity_for_delimiter
|
||||
+ existing_indent.len as usize
|
||||
+ newline_formatting.indent_on_newline.len as usize
|
||||
+ newline_formatting.indent_on_extra_newline.len as usize,
|
||||
);
|
||||
new_text.push('\n');
|
||||
new_text.extend(existing_indent.chars());
|
||||
new_text.extend(newline_formatting.indent_on_newline.chars());
|
||||
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(delimiter) = &delimiter {
|
||||
new_text.push_str(delimiter);
|
||||
}
|
||||
return comment_delimiter_for_newline(
|
||||
&start_point,
|
||||
&buffer,
|
||||
language,
|
||||
);
|
||||
});
|
||||
|
||||
if newline_formatting.insert_extra_newline {
|
||||
new_text.push('\n');
|
||||
new_text.extend(existing_indent.chars());
|
||||
new_text.extend(newline_formatting.indent_on_extra_newline.chars());
|
||||
}
|
||||
let doc_delimiter = maybe!({
|
||||
if !selection_is_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !multi_buffer.language_settings(cx).extend_comment_on_newline {
|
||||
return None;
|
||||
}
|
||||
|
||||
return documentation_delimiter_for_newline(
|
||||
&start_point,
|
||||
&buffer,
|
||||
language,
|
||||
&mut newline_config,
|
||||
);
|
||||
});
|
||||
|
||||
let list_delimiter = maybe!({
|
||||
if !selection_is_empty {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !multi_buffer.language_settings(cx).extend_list_on_newline {
|
||||
return None;
|
||||
}
|
||||
|
||||
return list_delimiter_for_newline(
|
||||
&start_point,
|
||||
&buffer,
|
||||
language,
|
||||
&mut newline_config,
|
||||
);
|
||||
});
|
||||
|
||||
(
|
||||
comment_delimiter.or(doc_delimiter).or(list_delimiter),
|
||||
newline_config,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
None,
|
||||
NewlineConfig::Newline {
|
||||
additional_indent: IndentSize::spaces(0),
|
||||
extra_line_additional_indent: None,
|
||||
prevent_auto_indent: false,
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let (edit_start, new_text, prevent_auto_indent) = match &newline_config {
|
||||
NewlineConfig::ClearCurrentLine => {
|
||||
let row_start =
|
||||
buffer.point_to_offset(Point::new(start_point.row, 0));
|
||||
(row_start, String::new(), false)
|
||||
}
|
||||
NewlineConfig::UnindentCurrentLine { continuation } => {
|
||||
let row_start =
|
||||
buffer.point_to_offset(Point::new(start_point.row, 0));
|
||||
let tab_size = buffer.language_settings_at(start, cx).tab_size;
|
||||
let tab_size_indent = IndentSize::spaces(tab_size.get());
|
||||
let reduced_indent =
|
||||
existing_indent.with_delta(Ordering::Less, tab_size_indent);
|
||||
let mut new_text = String::new();
|
||||
new_text.extend(reduced_indent.chars());
|
||||
new_text.push_str(continuation);
|
||||
(row_start, new_text, true)
|
||||
}
|
||||
NewlineConfig::Newline {
|
||||
additional_indent,
|
||||
extra_line_additional_indent,
|
||||
prevent_auto_indent,
|
||||
} => {
|
||||
let capacity_for_delimiter =
|
||||
delimiter.as_deref().map(str::len).unwrap_or_default();
|
||||
let extra_line_len = extra_line_additional_indent
|
||||
.map(|i| 1 + existing_indent.len as usize + i.len as usize)
|
||||
.unwrap_or(0);
|
||||
let mut new_text = String::with_capacity(
|
||||
1 + capacity_for_delimiter
|
||||
+ existing_indent.len as usize
|
||||
+ additional_indent.len as usize
|
||||
+ extra_line_len,
|
||||
);
|
||||
new_text.push('\n');
|
||||
new_text.extend(existing_indent.chars());
|
||||
new_text.extend(additional_indent.chars());
|
||||
if let Some(delimiter) = &delimiter {
|
||||
new_text.push_str(delimiter);
|
||||
}
|
||||
if let Some(extra_indent) = extra_line_additional_indent {
|
||||
new_text.push('\n');
|
||||
new_text.extend(existing_indent.chars());
|
||||
new_text.extend(extra_indent.chars());
|
||||
}
|
||||
(start, new_text, *prevent_auto_indent)
|
||||
}
|
||||
};
|
||||
|
||||
let anchor = buffer.anchor_after(end);
|
||||
let new_selection = selection.map(|_| anchor);
|
||||
(
|
||||
((start..end, new_text), prevent_auto_indent),
|
||||
(newline_formatting.insert_extra_newline, new_selection),
|
||||
((edit_start..end, new_text), prevent_auto_indent),
|
||||
(newline_config.has_extra_line(), new_selection),
|
||||
)
|
||||
})
|
||||
.unzip()
|
||||
@@ -10387,6 +10452,22 @@ impl Editor {
|
||||
}
|
||||
prev_edited_row = selection.end.row;
|
||||
|
||||
// If cursor is after a list prefix, make selection non-empty to trigger line indent
|
||||
if selection.is_empty() {
|
||||
let cursor = selection.head();
|
||||
let settings = buffer.language_settings_at(cursor, cx);
|
||||
if settings.indent_list_on_tab {
|
||||
if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) {
|
||||
if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) {
|
||||
row_delta = Self::indent_selection(
|
||||
buffer, &snapshot, selection, &mut edits, row_delta, cx,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection is non-empty, then increase the indentation of the selected lines.
|
||||
if !selection.is_empty() {
|
||||
row_delta =
|
||||
@@ -11413,25 +11494,12 @@ impl Editor {
|
||||
let diff = buffer.diff_for(hunk.buffer_id)?;
|
||||
let buffer = buffer.buffer(hunk.buffer_id)?;
|
||||
let buffer = buffer.read(cx);
|
||||
|
||||
let base_text = diff.read(cx).base_text();
|
||||
let mut base_text_start = hunk.diff_base_byte_range.start.0.to_point(base_text);
|
||||
let original_text = diff
|
||||
.read(cx)
|
||||
.base_text()
|
||||
.as_rope()
|
||||
.slice(hunk.diff_base_byte_range.start.0..hunk.diff_base_byte_range.end.0);
|
||||
let buffer_snapshot = buffer.snapshot();
|
||||
let mut buffer_start = hunk.buffer_range.start.to_point(&buffer_snapshot);
|
||||
if base_text_start.row > 0
|
||||
&& base_text_start.column == 0
|
||||
&& buffer_start.row > 0
|
||||
&& buffer_start.column == 0
|
||||
{
|
||||
base_text_start.row -= 1;
|
||||
base_text_start.column = base_text.line_len(base_text_start.row);
|
||||
buffer_start.row -= 1;
|
||||
buffer_start.column = buffer.line_len(buffer_start.row);
|
||||
}
|
||||
|
||||
let original_text = diff.read(cx).base_text().as_rope().slice(
|
||||
text::ToOffset::to_offset(&base_text_start, base_text)..hunk.diff_base_byte_range.end.0,
|
||||
);
|
||||
let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default();
|
||||
if let Err(i) = buffer_revert_changes.binary_search_by(|probe| {
|
||||
probe
|
||||
@@ -11440,13 +11508,7 @@ impl Editor {
|
||||
.cmp(&hunk.buffer_range.start, &buffer_snapshot)
|
||||
.then(probe.0.end.cmp(&hunk.buffer_range.end, &buffer_snapshot))
|
||||
}) {
|
||||
buffer_revert_changes.insert(
|
||||
i,
|
||||
(
|
||||
buffer_snapshot.anchor_before(buffer_start)..hunk.buffer_range.end,
|
||||
original_text,
|
||||
),
|
||||
);
|
||||
buffer_revert_changes.insert(i, (hunk.buffer_range.clone(), original_text));
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
@@ -23374,7 +23436,7 @@ fn documentation_delimiter_for_newline(
|
||||
start_point: &Point,
|
||||
buffer: &MultiBufferSnapshot,
|
||||
language: &LanguageScope,
|
||||
newline_formatting: &mut NewlineFormatting,
|
||||
newline_config: &mut NewlineConfig,
|
||||
) -> Option<Arc<str>> {
|
||||
let BlockCommentConfig {
|
||||
start: start_tag,
|
||||
@@ -23426,6 +23488,9 @@ fn documentation_delimiter_for_newline(
|
||||
}
|
||||
};
|
||||
|
||||
let mut needs_extra_line = false;
|
||||
let mut extra_line_additional_indent = IndentSize::spaces(0);
|
||||
|
||||
let cursor_is_before_end_tag_if_exists = {
|
||||
let mut char_position = 0u32;
|
||||
let mut end_tag_offset = None;
|
||||
@@ -23443,11 +23508,11 @@ fn documentation_delimiter_for_newline(
|
||||
let cursor_is_before_end_tag = column <= end_tag_offset;
|
||||
if cursor_is_after_start_tag {
|
||||
if cursor_is_before_end_tag {
|
||||
newline_formatting.insert_extra_newline = true;
|
||||
needs_extra_line = true;
|
||||
}
|
||||
let cursor_is_at_start_of_end_tag = column == end_tag_offset;
|
||||
if cursor_is_at_start_of_end_tag {
|
||||
newline_formatting.indent_on_extra_newline.len = *len;
|
||||
extra_line_additional_indent.len = *len;
|
||||
}
|
||||
}
|
||||
cursor_is_before_end_tag
|
||||
@@ -23459,39 +23524,240 @@ fn documentation_delimiter_for_newline(
|
||||
if (cursor_is_after_start_tag || cursor_is_after_delimiter)
|
||||
&& cursor_is_before_end_tag_if_exists
|
||||
{
|
||||
if cursor_is_after_start_tag {
|
||||
newline_formatting.indent_on_newline.len = *len;
|
||||
}
|
||||
let additional_indent = if cursor_is_after_start_tag {
|
||||
IndentSize::spaces(*len)
|
||||
} else {
|
||||
IndentSize::spaces(0)
|
||||
};
|
||||
|
||||
*newline_config = NewlineConfig::Newline {
|
||||
additional_indent,
|
||||
extra_line_additional_indent: if needs_extra_line {
|
||||
Some(extra_line_additional_indent)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
prevent_auto_indent: true,
|
||||
};
|
||||
Some(delimiter.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct NewlineFormatting {
|
||||
insert_extra_newline: bool,
|
||||
indent_on_newline: IndentSize,
|
||||
indent_on_extra_newline: IndentSize,
|
||||
const ORDERED_LIST_MAX_MARKER_LEN: usize = 16;
|
||||
|
||||
fn list_delimiter_for_newline(
|
||||
start_point: &Point,
|
||||
buffer: &MultiBufferSnapshot,
|
||||
language: &LanguageScope,
|
||||
newline_config: &mut NewlineConfig,
|
||||
) -> Option<Arc<str>> {
|
||||
let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
|
||||
|
||||
let num_of_whitespaces = snapshot
|
||||
.chars_for_range(range.clone())
|
||||
.take_while(|c| c.is_whitespace())
|
||||
.count();
|
||||
|
||||
let task_list_entries: Vec<_> = language
|
||||
.task_list()
|
||||
.into_iter()
|
||||
.flat_map(|config| {
|
||||
config
|
||||
.prefixes
|
||||
.iter()
|
||||
.map(|prefix| (prefix.as_ref(), config.continuation.as_ref()))
|
||||
})
|
||||
.collect();
|
||||
let unordered_list_entries: Vec<_> = language
|
||||
.unordered_list()
|
||||
.iter()
|
||||
.map(|marker| (marker.as_ref(), marker.as_ref()))
|
||||
.collect();
|
||||
|
||||
let all_entries: Vec<_> = task_list_entries
|
||||
.into_iter()
|
||||
.chain(unordered_list_entries)
|
||||
.collect();
|
||||
|
||||
if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() {
|
||||
let candidate: String = snapshot
|
||||
.chars_for_range(range.clone())
|
||||
.skip(num_of_whitespaces)
|
||||
.take(max_prefix_len)
|
||||
.collect();
|
||||
|
||||
if let Some((prefix, continuation)) = all_entries
|
||||
.iter()
|
||||
.filter(|(prefix, _)| candidate.starts_with(*prefix))
|
||||
.max_by_key(|(prefix, _)| prefix.len())
|
||||
{
|
||||
let end_of_prefix = num_of_whitespaces + prefix.len();
|
||||
let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
|
||||
let has_content_after_marker = snapshot
|
||||
.chars_for_range(range)
|
||||
.skip(end_of_prefix)
|
||||
.any(|c| !c.is_whitespace());
|
||||
|
||||
if has_content_after_marker && cursor_is_after_prefix {
|
||||
return Some((*continuation).into());
|
||||
}
|
||||
|
||||
if start_point.column as usize == end_of_prefix {
|
||||
if num_of_whitespaces == 0 {
|
||||
*newline_config = NewlineConfig::ClearCurrentLine;
|
||||
} else {
|
||||
*newline_config = NewlineConfig::UnindentCurrentLine {
|
||||
continuation: (*continuation).into(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let candidate: String = snapshot
|
||||
.chars_for_range(range.clone())
|
||||
.skip(num_of_whitespaces)
|
||||
.take(ORDERED_LIST_MAX_MARKER_LEN)
|
||||
.collect();
|
||||
|
||||
for ordered_config in language.ordered_list() {
|
||||
let regex = match Regex::new(&ordered_config.pattern) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(captures) = regex.captures(&candidate) {
|
||||
let full_match = captures.get(0)?;
|
||||
let marker_len = full_match.len();
|
||||
let end_of_prefix = num_of_whitespaces + marker_len;
|
||||
let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
|
||||
|
||||
let has_content_after_marker = snapshot
|
||||
.chars_for_range(range)
|
||||
.skip(end_of_prefix)
|
||||
.any(|c| !c.is_whitespace());
|
||||
|
||||
if has_content_after_marker && cursor_is_after_prefix {
|
||||
let number: u32 = captures.get(1)?.as_str().parse().ok()?;
|
||||
let continuation = ordered_config
|
||||
.format
|
||||
.replace("{1}", &(number + 1).to_string());
|
||||
return Some(continuation.into());
|
||||
}
|
||||
|
||||
if start_point.column as usize == end_of_prefix {
|
||||
let continuation = ordered_config.format.replace("{1}", "1");
|
||||
if num_of_whitespaces == 0 {
|
||||
*newline_config = NewlineConfig::ClearCurrentLine;
|
||||
} else {
|
||||
*newline_config = NewlineConfig::UnindentCurrentLine {
|
||||
continuation: continuation.into(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
impl NewlineFormatting {
|
||||
fn new(
|
||||
buffer: &MultiBufferSnapshot,
|
||||
range: Range<MultiBufferOffset>,
|
||||
language: &LanguageScope,
|
||||
) -> Self {
|
||||
Self {
|
||||
insert_extra_newline: Self::insert_extra_newline_brackets(
|
||||
buffer,
|
||||
range.clone(),
|
||||
language,
|
||||
) || Self::insert_extra_newline_tree_sitter(buffer, range),
|
||||
indent_on_newline: IndentSize::spaces(0),
|
||||
indent_on_extra_newline: IndentSize::spaces(0),
|
||||
fn is_list_prefix_row(
|
||||
row: MultiBufferRow,
|
||||
buffer: &MultiBufferSnapshot,
|
||||
language: &LanguageScope,
|
||||
) -> bool {
|
||||
let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let num_of_whitespaces = snapshot
|
||||
.chars_for_range(range.clone())
|
||||
.take_while(|c| c.is_whitespace())
|
||||
.count();
|
||||
|
||||
let task_list_prefixes: Vec<_> = language
|
||||
.task_list()
|
||||
.into_iter()
|
||||
.flat_map(|config| {
|
||||
config
|
||||
.prefixes
|
||||
.iter()
|
||||
.map(|p| p.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
let unordered_list_markers: Vec<_> = language
|
||||
.unordered_list()
|
||||
.iter()
|
||||
.map(|marker| marker.as_ref())
|
||||
.collect();
|
||||
let all_prefixes: Vec<_> = task_list_prefixes
|
||||
.into_iter()
|
||||
.chain(unordered_list_markers)
|
||||
.collect();
|
||||
if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() {
|
||||
let candidate: String = snapshot
|
||||
.chars_for_range(range.clone())
|
||||
.skip(num_of_whitespaces)
|
||||
.take(max_prefix_len)
|
||||
.collect();
|
||||
if all_prefixes
|
||||
.iter()
|
||||
.any(|prefix| candidate.starts_with(*prefix))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let ordered_list_candidate: String = snapshot
|
||||
.chars_for_range(range)
|
||||
.skip(num_of_whitespaces)
|
||||
.take(ORDERED_LIST_MAX_MARKER_LEN)
|
||||
.collect();
|
||||
for ordered_config in language.ordered_list() {
|
||||
let regex = match Regex::new(&ordered_config.pattern) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(captures) = regex.captures(&ordered_list_candidate) {
|
||||
return captures.get(0).is_some();
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum NewlineConfig {
|
||||
/// Insert newline with optional additional indent and optional extra blank line
|
||||
Newline {
|
||||
additional_indent: IndentSize,
|
||||
extra_line_additional_indent: Option<IndentSize>,
|
||||
prevent_auto_indent: bool,
|
||||
},
|
||||
/// Clear the current line
|
||||
ClearCurrentLine,
|
||||
/// Unindent the current line and add continuation
|
||||
UnindentCurrentLine { continuation: Arc<str> },
|
||||
}
|
||||
|
||||
impl NewlineConfig {
|
||||
fn has_extra_line(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Newline {
|
||||
extra_line_additional_indent: Some(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_extra_newline_brackets(
|
||||
buffer: &MultiBufferSnapshot,
|
||||
range: Range<MultiBufferOffset>,
|
||||
|
||||
@@ -28021,7 +28021,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
|
||||
"
|
||||
});
|
||||
|
||||
// Case 2: Test adding new line after nested list preserves indent of previous line
|
||||
// Case 2: Test adding new line after nested list continues the list with unchecked task
|
||||
cx.set_state(&indoc! {"
|
||||
- [ ] Item 1
|
||||
- [ ] Item 1.a
|
||||
@@ -28038,20 +28038,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
|
||||
- [x] Item 2
|
||||
- [x] Item 2.a
|
||||
- [x] Item 2.b
|
||||
ˇ"
|
||||
- [ ] ˇ"
|
||||
});
|
||||
|
||||
// Case 3: Test adding a new nested list item preserves indent
|
||||
cx.set_state(&indoc! {"
|
||||
- [ ] Item 1
|
||||
- [ ] Item 1.a
|
||||
- [x] Item 2
|
||||
- [x] Item 2.a
|
||||
- [x] Item 2.b
|
||||
ˇ"
|
||||
});
|
||||
// Case 3: Test adding content to continued list item
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("-", window, cx);
|
||||
editor.handle_input("Item 2.c", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state(indoc! {"
|
||||
@@ -28060,22 +28052,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
|
||||
- [x] Item 2
|
||||
- [x] Item 2.a
|
||||
- [x] Item 2.b
|
||||
-ˇ"
|
||||
});
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input(" [x] Item 2.c", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] Item 1
|
||||
- [ ] Item 1.a
|
||||
- [x] Item 2
|
||||
- [x] Item 2.a
|
||||
- [x] Item 2.b
|
||||
- [x] Item 2.cˇ"
|
||||
- [ ] Item 2.cˇ"
|
||||
});
|
||||
|
||||
// Case 4: Test adding new line after nested ordered list preserves indent of previous line
|
||||
// Case 4: Test adding new line after nested ordered list continues with next number
|
||||
cx.set_state(indoc! {"
|
||||
1. Item 1
|
||||
1. Item 1.a
|
||||
@@ -28092,44 +28072,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
|
||||
2. Item 2
|
||||
1. Item 2.a
|
||||
2. Item 2.b
|
||||
ˇ"
|
||||
3. ˇ"
|
||||
});
|
||||
|
||||
// Case 5: Adding new ordered list item preserves indent
|
||||
cx.set_state(indoc! {"
|
||||
1. Item 1
|
||||
1. Item 1.a
|
||||
2. Item 2
|
||||
1. Item 2.a
|
||||
2. Item 2.b
|
||||
ˇ"
|
||||
});
|
||||
// Case 5: Adding content to continued ordered list item
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("3", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. Item 1
|
||||
1. Item 1.a
|
||||
2. Item 2
|
||||
1. Item 2.a
|
||||
2. Item 2.b
|
||||
3ˇ"
|
||||
});
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input(".", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. Item 1
|
||||
1. Item 1.a
|
||||
2. Item 2
|
||||
1. Item 2.a
|
||||
2. Item 2.b
|
||||
3.ˇ"
|
||||
});
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input(" Item 2.c", window, cx);
|
||||
editor.handle_input("Item 2.c", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state(indoc! {"
|
||||
@@ -29497,6 +29445,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
|
||||
cx.assert_editor_state(after);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.tab_size = Some(2.try_into().unwrap());
|
||||
});
|
||||
|
||||
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
|
||||
|
||||
// Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker
|
||||
cx.set_state(indoc! {"
|
||||
- [ ] taskˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] ˇ
|
||||
"});
|
||||
|
||||
// Case 2: Works with checked task items too
|
||||
cx.set_state(indoc! {"
|
||||
- [x] completed taskˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [x] completed task
|
||||
- [ ] ˇ
|
||||
"});
|
||||
|
||||
// Case 3: Cursor position doesn't matter - content after marker is what counts
|
||||
cx.set_state(indoc! {"
|
||||
- [ ] taˇsk
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] ta
|
||||
- [ ] ˇsk
|
||||
"});
|
||||
|
||||
// Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker
|
||||
cx.set_state(indoc! {"
|
||||
- [ ] ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(
|
||||
indoc! {"
|
||||
- [ ]$$
|
||||
ˇ
|
||||
"}
|
||||
.replace("$", " ")
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
// Case 5: Adding newline with content adds marker preserving indentation
|
||||
cx.set_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] indentedˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] indented
|
||||
- [ ] ˇ
|
||||
"});
|
||||
|
||||
// Case 6: Adding newline with cursor right after prefix, unindents
|
||||
cx.set_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] sub task
|
||||
- [ ] ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] sub task
|
||||
- [ ] ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
|
||||
// Case 7: Adding newline with cursor right after prefix, removes marker
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] sub task
|
||||
- [ ] ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [ ] task
|
||||
- [ ] sub task
|
||||
ˇ
|
||||
"});
|
||||
|
||||
// Case 8: Cursor before or inside prefix does not add marker
|
||||
cx.set_state(indoc! {"
|
||||
ˇ- [ ] task
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|
||||
ˇ- [ ] task
|
||||
"});
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
- [ˇ ] task
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- [
|
||||
ˇ
|
||||
] task
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.tab_size = Some(2.try_into().unwrap());
|
||||
});
|
||||
|
||||
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
|
||||
|
||||
// Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker
|
||||
cx.set_state(indoc! {"
|
||||
- itemˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- item
|
||||
- ˇ
|
||||
"});
|
||||
|
||||
// Case 2: Works with different markers
|
||||
cx.set_state(indoc! {"
|
||||
* starred itemˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
* starred item
|
||||
* ˇ
|
||||
"});
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
+ plus itemˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
+ plus item
|
||||
+ ˇ
|
||||
"});
|
||||
|
||||
// Case 3: Cursor position doesn't matter - content after marker is what counts
|
||||
cx.set_state(indoc! {"
|
||||
- itˇem
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- it
|
||||
- ˇem
|
||||
"});
|
||||
|
||||
// Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
|
||||
cx.set_state(indoc! {"
|
||||
- ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(
|
||||
indoc! {"
|
||||
- $
|
||||
ˇ
|
||||
"}
|
||||
.replace("$", " ")
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
// Case 5: Adding newline with content adds marker preserving indentation
|
||||
cx.set_state(indoc! {"
|
||||
- item
|
||||
- indentedˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- item
|
||||
- indented
|
||||
- ˇ
|
||||
"});
|
||||
|
||||
// Case 6: Adding newline with cursor right after marker, unindents
|
||||
cx.set_state(indoc! {"
|
||||
- item
|
||||
- sub item
|
||||
- ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- item
|
||||
- sub item
|
||||
- ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
|
||||
// Case 7: Adding newline with cursor right after marker, removes marker
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- item
|
||||
- sub item
|
||||
- ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
- item
|
||||
- sub item
|
||||
ˇ
|
||||
"});
|
||||
|
||||
// Case 8: Cursor before or inside prefix does not add marker
|
||||
cx.set_state(indoc! {"
|
||||
ˇ- item
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|
||||
ˇ- item
|
||||
"});
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
-ˇ item
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
-
|
||||
ˇitem
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.tab_size = Some(2.try_into().unwrap());
|
||||
});
|
||||
|
||||
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
|
||||
|
||||
// Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
|
||||
cx.set_state(indoc! {"
|
||||
1. first itemˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. first item
|
||||
2. ˇ
|
||||
"});
|
||||
|
||||
// Case 2: Works with larger numbers
|
||||
cx.set_state(indoc! {"
|
||||
10. tenth itemˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
10. tenth item
|
||||
11. ˇ
|
||||
"});
|
||||
|
||||
// Case 3: Cursor position doesn't matter - content after marker is what counts
|
||||
cx.set_state(indoc! {"
|
||||
1. itˇem
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. it
|
||||
2. ˇem
|
||||
"});
|
||||
|
||||
// Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
|
||||
cx.set_state(indoc! {"
|
||||
1. ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(
|
||||
indoc! {"
|
||||
1. $
|
||||
ˇ
|
||||
"}
|
||||
.replace("$", " ")
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
// Case 5: Adding newline with content adds marker preserving indentation
|
||||
cx.set_state(indoc! {"
|
||||
1. item
|
||||
2. indentedˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. item
|
||||
2. indented
|
||||
3. ˇ
|
||||
"});
|
||||
|
||||
// Case 6: Adding newline with cursor right after marker, unindents
|
||||
cx.set_state(indoc! {"
|
||||
1. item
|
||||
2. sub item
|
||||
3. ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. item
|
||||
2. sub item
|
||||
1. ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
|
||||
// Case 7: Adding newline with cursor right after marker, removes marker
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. item
|
||||
2. sub item
|
||||
1. ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. item
|
||||
2. sub item
|
||||
ˇ
|
||||
"});
|
||||
|
||||
// Case 8: Cursor before or inside prefix does not add marker
|
||||
cx.set_state(indoc! {"
|
||||
ˇ1. item
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
|
||||
ˇ1. item
|
||||
"});
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
1ˇ. item
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1
|
||||
ˇ. item
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.tab_size = Some(2.try_into().unwrap());
|
||||
});
|
||||
|
||||
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
|
||||
|
||||
// Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
|
||||
cx.set_state(indoc! {"
|
||||
1. first item
|
||||
1. sub first item
|
||||
2. sub second item
|
||||
3. ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
cx.assert_editor_state(indoc! {"
|
||||
1. first item
|
||||
1. sub first item
|
||||
2. sub second item
|
||||
1. ˇ
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_tab_list_indent(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.tab_size = Some(2.try_into().unwrap());
|
||||
});
|
||||
|
||||
let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
|
||||
|
||||
// Case 1: Unordered list - cursor after prefix, adds indent before prefix
|
||||
cx.set_state(indoc! {"
|
||||
- ˇitem
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
$$- ˇitem
|
||||
"};
|
||||
cx.assert_editor_state(expected.replace("$", " ").as_str());
|
||||
|
||||
// Case 2: Task list - cursor after prefix
|
||||
cx.set_state(indoc! {"
|
||||
- [ ] ˇtask
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
$$- [ ] ˇtask
|
||||
"};
|
||||
cx.assert_editor_state(expected.replace("$", " ").as_str());
|
||||
|
||||
// Case 3: Ordered list - cursor after prefix
|
||||
cx.set_state(indoc! {"
|
||||
1. ˇfirst
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
$$1. ˇfirst
|
||||
"};
|
||||
cx.assert_editor_state(expected.replace("$", " ").as_str());
|
||||
|
||||
// Case 4: With existing indentation - adds more indent
|
||||
let initial = indoc! {"
|
||||
$$- ˇitem
|
||||
"};
|
||||
cx.set_state(initial.replace("$", " ").as_str());
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
$$$$- ˇitem
|
||||
"};
|
||||
cx.assert_editor_state(expected.replace("$", " ").as_str());
|
||||
|
||||
// Case 5: Empty list item
|
||||
cx.set_state(indoc! {"
|
||||
- ˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
$$- ˇ
|
||||
"};
|
||||
cx.assert_editor_state(expected.replace("$", " ").as_str());
|
||||
|
||||
// Case 6: Cursor at end of line with content
|
||||
cx.set_state(indoc! {"
|
||||
- itemˇ
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
$$- itemˇ
|
||||
"};
|
||||
cx.assert_editor_state(expected.replace("$", " ").as_str());
|
||||
|
||||
// Case 7: Cursor at start of list item, indents it
|
||||
cx.set_state(indoc! {"
|
||||
- item
|
||||
ˇ - sub item
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
- item
|
||||
ˇ - sub item
|
||||
"};
|
||||
cx.assert_editor_state(expected);
|
||||
|
||||
// Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false
|
||||
cx.update_editor(|_, _, cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.all_languages.defaults.indent_list_on_tab = Some(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
cx.set_state(indoc! {"
|
||||
- item
|
||||
ˇ - sub item
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
|
||||
cx.wait_for_autoindent_applied().await;
|
||||
let expected = indoc! {"
|
||||
- item
|
||||
ˇ- sub item
|
||||
"};
|
||||
cx.assert_editor_state(expected);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
@@ -1077,11 +1077,9 @@ impl App {
|
||||
self.platform.window_appearance()
|
||||
}
|
||||
|
||||
/// Writes data to the primary selection buffer.
|
||||
/// Only available on Linux.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
pub fn write_to_primary(&self, item: ClipboardItem) {
|
||||
self.platform.write_to_primary(item)
|
||||
/// Reads data from the platform clipboard.
|
||||
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
self.platform.read_from_clipboard()
|
||||
}
|
||||
|
||||
/// Writes data to the platform clipboard.
|
||||
@@ -1096,9 +1094,31 @@ impl App {
|
||||
self.platform.read_from_primary()
|
||||
}
|
||||
|
||||
/// Reads data from the platform clipboard.
|
||||
pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
self.platform.read_from_clipboard()
|
||||
/// Writes data to the primary selection buffer.
|
||||
/// Only available on Linux.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
pub fn write_to_primary(&self, item: ClipboardItem) {
|
||||
self.platform.write_to_primary(item)
|
||||
}
|
||||
|
||||
/// Reads data from macOS's "Find" pasteboard.
|
||||
///
|
||||
/// Used to share the current search string between apps.
|
||||
///
|
||||
/// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
|
||||
self.platform.read_from_find_pasteboard()
|
||||
}
|
||||
|
||||
/// Writes data to macOS's "Find" pasteboard.
|
||||
///
|
||||
/// Used to share the current search string between apps.
|
||||
///
|
||||
/// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn write_to_find_pasteboard(&self, item: ClipboardItem) {
|
||||
self.platform.write_to_find_pasteboard(item)
|
||||
}
|
||||
|
||||
/// Writes credentials to the platform keychain.
|
||||
|
||||
@@ -372,11 +372,17 @@ impl TextLayout {
|
||||
(None, "".into(), TruncateFrom::End)
|
||||
};
|
||||
|
||||
// Only use cached layout if:
|
||||
// 1. We have a cached size
|
||||
// 2. wrap_width matches (or both are None)
|
||||
// 3. truncate_width is None (if truncate_width is Some, we need to re-layout
|
||||
// because the previous layout may have been computed without truncation)
|
||||
if let Some(text_layout) = element_state.0.borrow().as_ref()
|
||||
&& text_layout.size.is_some()
|
||||
&& let Some(size) = text_layout.size
|
||||
&& (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
|
||||
&& truncate_width.is_none()
|
||||
{
|
||||
return text_layout.size.unwrap();
|
||||
return size;
|
||||
}
|
||||
|
||||
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
|
||||
|
||||
@@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static {
|
||||
fn set_cursor_style(&self, style: CursorStyle);
|
||||
fn should_auto_hide_scrollbars(&self) -> bool;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn write_to_primary(&self, item: ClipboardItem);
|
||||
fn read_from_clipboard(&self) -> Option<ClipboardItem>;
|
||||
fn write_to_clipboard(&self, item: ClipboardItem);
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn read_from_primary(&self) -> Option<ClipboardItem>;
|
||||
fn read_from_clipboard(&self) -> Option<ClipboardItem>;
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn write_to_primary(&self, item: ClipboardItem);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_from_find_pasteboard(&self) -> Option<ClipboardItem>;
|
||||
#[cfg(target_os = "macos")]
|
||||
fn write_to_find_pasteboard(&self, item: ClipboardItem);
|
||||
|
||||
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
|
||||
fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;
|
||||
|
||||
@@ -5,6 +5,7 @@ mod display;
|
||||
mod display_link;
|
||||
mod events;
|
||||
mod keyboard;
|
||||
mod pasteboard;
|
||||
|
||||
#[cfg(feature = "screen-capture")]
|
||||
mod screen_capture;
|
||||
@@ -21,8 +22,6 @@ use metal_renderer as renderer;
|
||||
#[cfg(feature = "macos-blade")]
|
||||
use crate::platform::blade as renderer;
|
||||
|
||||
mod attributed_string;
|
||||
|
||||
#[cfg(feature = "font-kit")]
|
||||
mod open_type;
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
use cocoa::base::id;
|
||||
use cocoa::foundation::NSRange;
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
|
||||
/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes),
|
||||
/// which are needed for copying rich text (that is, text intermingled with images)
|
||||
/// to the clipboard. This adds access to those APIs.
|
||||
#[allow(non_snake_case)]
|
||||
pub trait NSAttributedString: Sized {
|
||||
unsafe fn alloc(_: Self) -> id {
|
||||
msg_send![class!(NSAttributedString), alloc]
|
||||
}
|
||||
|
||||
unsafe fn init_attributed_string(self, string: id) -> id;
|
||||
unsafe fn appendAttributedString_(self, attr_string: id);
|
||||
unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
|
||||
unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
|
||||
unsafe fn string(self) -> id;
|
||||
}
|
||||
|
||||
impl NSAttributedString for id {
|
||||
unsafe fn init_attributed_string(self, string: id) -> id {
|
||||
msg_send![self, initWithString: string]
|
||||
}
|
||||
|
||||
unsafe fn appendAttributedString_(self, attr_string: id) {
|
||||
let _: () = msg_send![self, appendAttributedString: attr_string];
|
||||
}
|
||||
|
||||
unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
|
||||
msg_send![self, RTFDFromRange: range documentAttributes: attrs]
|
||||
}
|
||||
|
||||
unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
|
||||
msg_send![self, RTFFromRange: range documentAttributes: attrs]
|
||||
}
|
||||
|
||||
unsafe fn string(self) -> id {
|
||||
msg_send![self, string]
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NSMutableAttributedString: NSAttributedString {
|
||||
unsafe fn alloc(_: Self) -> id {
|
||||
msg_send![class!(NSMutableAttributedString), alloc]
|
||||
}
|
||||
}
|
||||
|
||||
impl NSMutableAttributedString for id {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::platform::mac::ns_string;
|
||||
|
||||
use super::*;
|
||||
use cocoa::appkit::NSImage;
|
||||
use cocoa::base::nil;
|
||||
use cocoa::foundation::NSAutoreleasePool;
|
||||
#[test]
|
||||
#[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348
|
||||
fn test_nsattributed_string() {
|
||||
// TODO move these to parent module once it's actually ready to be used
|
||||
#[allow(non_snake_case)]
|
||||
pub trait NSTextAttachment: Sized {
|
||||
unsafe fn alloc(_: Self) -> id {
|
||||
msg_send![class!(NSTextAttachment), alloc]
|
||||
}
|
||||
}
|
||||
|
||||
impl NSTextAttachment for id {}
|
||||
|
||||
unsafe {
|
||||
let image: id = {
|
||||
let img: id = msg_send![class!(NSImage), alloc];
|
||||
let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")];
|
||||
let img: id = msg_send![img, autorelease];
|
||||
img
|
||||
};
|
||||
let _size = image.size();
|
||||
|
||||
let string = ns_string("Test String");
|
||||
let attr_string = NSMutableAttributedString::alloc(nil)
|
||||
.init_attributed_string(string)
|
||||
.autorelease();
|
||||
let hello_string = ns_string("Hello World");
|
||||
let hello_attr_string = NSAttributedString::alloc(nil)
|
||||
.init_attributed_string(hello_string)
|
||||
.autorelease();
|
||||
attr_string.appendAttributedString_(hello_attr_string);
|
||||
|
||||
let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease];
|
||||
let _: () = msg_send![attachment, setImage: image];
|
||||
let image_attr_string =
|
||||
msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment];
|
||||
attr_string.appendAttributedString_(image_attr_string);
|
||||
|
||||
let another_string = ns_string("Another String");
|
||||
let another_attr_string = NSAttributedString::alloc(nil)
|
||||
.init_attributed_string(another_string)
|
||||
.autorelease();
|
||||
attr_string.appendAttributedString_(another_attr_string);
|
||||
|
||||
let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length];
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// pasteboard.clearContents();
|
||||
|
||||
let rtfd_data = attr_string.RTFDFromRange_documentAttributes_(
|
||||
NSRange::new(0, msg_send![attr_string, length]),
|
||||
nil,
|
||||
);
|
||||
assert_ne!(rtfd_data, nil);
|
||||
// if rtfd_data != nil {
|
||||
// pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
|
||||
// }
|
||||
|
||||
// let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
|
||||
// NSRange::new(0, attributed_string.length()),
|
||||
// nil,
|
||||
// );
|
||||
// if rtf_data != nil {
|
||||
// pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF);
|
||||
// }
|
||||
|
||||
// let plain_text = attributed_string.string();
|
||||
// pasteboard.setString_forType(plain_text, NSPasteboardTypeString);
|
||||
}
|
||||
}
|
||||
}
|
||||
344
crates/gpui/src/platform/mac/pasteboard.rs
Normal file
344
crates/gpui/src/platform/mac/pasteboard.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use core::slice;
|
||||
use std::ffi::c_void;
|
||||
|
||||
use cocoa::{
|
||||
appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
|
||||
base::{id, nil},
|
||||
foundation::NSData,
|
||||
};
|
||||
use objc::{msg_send, runtime::Object, sel, sel_impl};
|
||||
use strum::IntoEnumIterator as _;
|
||||
|
||||
use crate::{
|
||||
ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
|
||||
platform::mac::ns_string,
|
||||
};
|
||||
|
||||
pub struct Pasteboard {
|
||||
inner: id,
|
||||
text_hash_type: id,
|
||||
metadata_type: id,
|
||||
}
|
||||
|
||||
impl Pasteboard {
|
||||
pub fn general() -> Self {
|
||||
unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
|
||||
}
|
||||
|
||||
pub fn find() -> Self {
|
||||
unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn unique() -> Self {
|
||||
unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
|
||||
}
|
||||
|
||||
unsafe fn new(inner: id) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
text_hash_type: unsafe { ns_string("zed-text-hash") },
|
||||
metadata_type: unsafe { ns_string("zed-metadata") },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Option<ClipboardItem> {
|
||||
// First, see if it's a string.
|
||||
unsafe {
|
||||
let pasteboard_types: id = self.inner.types();
|
||||
let string_type: id = ns_string("public.utf8-plain-text");
|
||||
|
||||
if msg_send![pasteboard_types, containsObject: string_type] {
|
||||
let data = self.inner.dataForType(string_type);
|
||||
if data == nil {
|
||||
return None;
|
||||
} else if data.bytes().is_null() {
|
||||
// https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
|
||||
// "If the length of the NSData object is 0, this property returns nil."
|
||||
return Some(self.read_string(&[]));
|
||||
} else {
|
||||
let bytes =
|
||||
slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
|
||||
|
||||
return Some(self.read_string(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
// If it wasn't a string, try the various supported image types.
|
||||
for format in ImageFormat::iter() {
|
||||
if let Some(item) = self.read_image(format) {
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it wasn't a string or a supported image type, give up.
|
||||
None
|
||||
}
|
||||
|
||||
fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
|
||||
let mut ut_type: UTType = format.into();
|
||||
|
||||
unsafe {
|
||||
let types: id = self.inner.types();
|
||||
if msg_send![types, containsObject: ut_type.inner()] {
|
||||
self.data_for_type(ut_type.inner_mut()).map(|bytes| {
|
||||
let bytes = bytes.to_vec();
|
||||
let id = hash(&bytes);
|
||||
|
||||
ClipboardItem {
|
||||
entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
|
||||
unsafe {
|
||||
let text = String::from_utf8_lossy(text_bytes).to_string();
|
||||
let metadata = self
|
||||
.data_for_type(self.text_hash_type)
|
||||
.and_then(|hash_bytes| {
|
||||
let hash_bytes = hash_bytes.try_into().ok()?;
|
||||
let hash = u64::from_be_bytes(hash_bytes);
|
||||
let metadata = self.data_for_type(self.metadata_type)?;
|
||||
|
||||
if hash == ClipboardString::text_hash(&text) {
|
||||
String::from_utf8(metadata.to_vec()).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
ClipboardItem {
|
||||
entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
|
||||
unsafe {
|
||||
let data = self.inner.dataForType(kind);
|
||||
if data == nil {
|
||||
None
|
||||
} else {
|
||||
Some(slice::from_raw_parts(
|
||||
data.bytes() as *mut u8,
|
||||
data.length() as usize,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, item: ClipboardItem) {
|
||||
unsafe {
|
||||
match item.entries.as_slice() {
|
||||
[] => {
|
||||
// Writing an empty list of entries just clears the clipboard.
|
||||
self.inner.clearContents();
|
||||
}
|
||||
[ClipboardEntry::String(string)] => {
|
||||
self.write_plaintext(string);
|
||||
}
|
||||
[ClipboardEntry::Image(image)] => {
|
||||
self.write_image(image);
|
||||
}
|
||||
[ClipboardEntry::ExternalPaths(_)] => {}
|
||||
_ => {
|
||||
// Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
|
||||
//
|
||||
// This was the existing behavior before I refactored the outer clipboard code:
|
||||
// https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
|
||||
//
|
||||
// Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
|
||||
|
||||
let mut combined = ClipboardString {
|
||||
text: String::new(),
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
for entry in item.entries {
|
||||
match entry {
|
||||
ClipboardEntry::String(text) => {
|
||||
combined.text.push_str(&text.text());
|
||||
if combined.metadata.is_none() {
|
||||
combined.metadata = text.metadata;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.write_plaintext(&combined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_plaintext(&self, string: &ClipboardString) {
|
||||
unsafe {
|
||||
self.inner.clearContents();
|
||||
|
||||
let text_bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
string.text.as_ptr() as *const c_void,
|
||||
string.text.len() as u64,
|
||||
);
|
||||
self.inner
|
||||
.setData_forType(text_bytes, NSPasteboardTypeString);
|
||||
|
||||
if let Some(metadata) = string.metadata.as_ref() {
|
||||
let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
|
||||
let hash_bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
hash_bytes.as_ptr() as *const c_void,
|
||||
hash_bytes.len() as u64,
|
||||
);
|
||||
self.inner.setData_forType(hash_bytes, self.text_hash_type);
|
||||
|
||||
let metadata_bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
metadata.as_ptr() as *const c_void,
|
||||
metadata.len() as u64,
|
||||
);
|
||||
self.inner
|
||||
.setData_forType(metadata_bytes, self.metadata_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn write_image(&self, image: &Image) {
|
||||
unsafe {
|
||||
self.inner.clearContents();
|
||||
|
||||
let bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
image.bytes.as_ptr() as *const c_void,
|
||||
image.bytes.len() as u64,
|
||||
);
|
||||
|
||||
self.inner
|
||||
.setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[link(name = "AppKit", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
/// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
|
||||
pub static NSPasteboardNameFind: id;
|
||||
}
|
||||
|
||||
impl From<ImageFormat> for UTType {
|
||||
fn from(value: ImageFormat) -> Self {
|
||||
match value {
|
||||
ImageFormat::Png => Self::png(),
|
||||
ImageFormat::Jpeg => Self::jpeg(),
|
||||
ImageFormat::Tiff => Self::tiff(),
|
||||
ImageFormat::Webp => Self::webp(),
|
||||
ImageFormat::Gif => Self::gif(),
|
||||
ImageFormat::Bmp => Self::bmp(),
|
||||
ImageFormat::Svg => Self::svg(),
|
||||
ImageFormat::Ico => Self::ico(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
|
||||
pub struct UTType(id);
|
||||
|
||||
impl UTType {
|
||||
pub fn png() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
|
||||
Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
|
||||
}
|
||||
|
||||
pub fn jpeg() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
|
||||
Self(unsafe { ns_string("public.jpeg") })
|
||||
}
|
||||
|
||||
pub fn gif() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
|
||||
Self(unsafe { ns_string("com.compuserve.gif") })
|
||||
}
|
||||
|
||||
pub fn webp() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
|
||||
Self(unsafe { ns_string("org.webmproject.webp") })
|
||||
}
|
||||
|
||||
pub fn bmp() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
|
||||
Self(unsafe { ns_string("com.microsoft.bmp") })
|
||||
}
|
||||
|
||||
pub fn svg() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
|
||||
Self(unsafe { ns_string("public.svg-image") })
|
||||
}
|
||||
|
||||
pub fn ico() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
|
||||
Self(unsafe { ns_string("com.microsoft.ico") })
|
||||
}
|
||||
|
||||
pub fn tiff() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
|
||||
Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
|
||||
}
|
||||
|
||||
fn inner(&self) -> *const Object {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn inner_mut(&self) -> *mut Object {
|
||||
self.0 as *mut _
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
|
||||
|
||||
use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_string() {
|
||||
let pasteboard = Pasteboard::unique();
|
||||
assert_eq!(pasteboard.read(), None);
|
||||
|
||||
let item = ClipboardItem::new_string("1".to_string());
|
||||
pasteboard.write(item.clone());
|
||||
assert_eq!(pasteboard.read(), Some(item));
|
||||
|
||||
let item = ClipboardItem {
|
||||
entries: vec![ClipboardEntry::String(
|
||||
ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
|
||||
)],
|
||||
};
|
||||
pasteboard.write(item.clone());
|
||||
assert_eq!(pasteboard.read(), Some(item));
|
||||
|
||||
let text_from_other_app = "text from other app";
|
||||
unsafe {
|
||||
let bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
text_from_other_app.as_ptr() as *const c_void,
|
||||
text_from_other_app.len() as u64,
|
||||
);
|
||||
pasteboard
|
||||
.inner
|
||||
.setData_forType(bytes, NSPasteboardTypeString);
|
||||
}
|
||||
assert_eq!(
|
||||
pasteboard.read(),
|
||||
Some(ClipboardItem::new_string(text_from_other_app.to_string()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,24 @@
|
||||
use super::{
|
||||
BoolExt, MacKeyboardLayout, MacKeyboardMapper,
|
||||
attributed_string::{NSAttributedString, NSMutableAttributedString},
|
||||
events::key_to_native,
|
||||
ns_string, renderer,
|
||||
BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer,
|
||||
};
|
||||
use crate::{
|
||||
Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
|
||||
CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
|
||||
MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
|
||||
PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
|
||||
PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
|
||||
Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor,
|
||||
KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu,
|
||||
PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
|
||||
PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance,
|
||||
WindowParams, platform::mac::pasteboard::Pasteboard,
|
||||
};
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use block::ConcreteBlock;
|
||||
use cocoa::{
|
||||
appkit::{
|
||||
NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
|
||||
NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
|
||||
NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString,
|
||||
NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
|
||||
NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel,
|
||||
NSVisualEffectState, NSVisualEffectView, NSWindow,
|
||||
},
|
||||
base::{BOOL, NO, YES, id, nil, selector},
|
||||
foundation::{
|
||||
NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString,
|
||||
NSUInteger, NSURL,
|
||||
NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL,
|
||||
},
|
||||
};
|
||||
use core_foundation::{
|
||||
@@ -49,7 +44,6 @@ use ptr::null_mut;
|
||||
use semver::Version;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
convert::TryInto,
|
||||
ffi::{CStr, OsStr, c_void},
|
||||
os::{raw::c_char, unix::ffi::OsStrExt},
|
||||
path::{Path, PathBuf},
|
||||
@@ -58,7 +52,6 @@ use std::{
|
||||
slice, str,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use strum::IntoEnumIterator;
|
||||
use util::{
|
||||
ResultExt,
|
||||
command::{new_smol_command, new_std_command},
|
||||
@@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState {
|
||||
text_system: Arc<dyn PlatformTextSystem>,
|
||||
renderer_context: renderer::Context,
|
||||
headless: bool,
|
||||
pasteboard: id,
|
||||
text_hash_pasteboard_type: id,
|
||||
metadata_pasteboard_type: id,
|
||||
general_pasteboard: Pasteboard,
|
||||
find_pasteboard: Pasteboard,
|
||||
reopen: Option<Box<dyn FnMut()>>,
|
||||
on_keyboard_layout_change: Option<Box<dyn FnMut()>>,
|
||||
quit: Option<Box<dyn FnMut()>>,
|
||||
@@ -206,9 +198,8 @@ impl MacPlatform {
|
||||
background_executor: BackgroundExecutor::new(dispatcher.clone()),
|
||||
foreground_executor: ForegroundExecutor::new(dispatcher),
|
||||
renderer_context: renderer::Context::default(),
|
||||
pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
|
||||
text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
|
||||
metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
|
||||
general_pasteboard: Pasteboard::general(),
|
||||
find_pasteboard: Pasteboard::find(),
|
||||
reopen: None,
|
||||
quit: None,
|
||||
menu_command: None,
|
||||
@@ -224,20 +215,6 @@ impl MacPlatform {
|
||||
}))
|
||||
}
|
||||
|
||||
unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> {
|
||||
unsafe {
|
||||
let data = pasteboard.dataForType(kind);
|
||||
if data == nil {
|
||||
None
|
||||
} else {
|
||||
Some(slice::from_raw_parts(
|
||||
data.bytes() as *mut u8,
|
||||
data.length() as usize,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_menu_bar(
|
||||
&self,
|
||||
menus: &Vec<Menu>,
|
||||
@@ -1034,119 +1011,24 @@ impl Platform for MacPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_to_clipboard(&self, item: ClipboardItem) {
|
||||
use crate::ClipboardEntry;
|
||||
|
||||
unsafe {
|
||||
// We only want to use NSAttributedString if there are multiple entries to write.
|
||||
if item.entries.len() <= 1 {
|
||||
match item.entries.first() {
|
||||
Some(entry) => match entry {
|
||||
ClipboardEntry::String(string) => {
|
||||
self.write_plaintext_to_clipboard(string);
|
||||
}
|
||||
ClipboardEntry::Image(image) => {
|
||||
self.write_image_to_clipboard(image);
|
||||
}
|
||||
ClipboardEntry::ExternalPaths(_) => {}
|
||||
},
|
||||
None => {
|
||||
// Writing an empty list of entries just clears the clipboard.
|
||||
let state = self.0.lock();
|
||||
state.pasteboard.clearContents();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut any_images = false;
|
||||
let attributed_string = {
|
||||
let mut buf = NSMutableAttributedString::alloc(nil)
|
||||
// TODO can we skip this? Or at least part of it?
|
||||
.init_attributed_string(ns_string(""))
|
||||
.autorelease();
|
||||
|
||||
for entry in item.entries {
|
||||
if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry
|
||||
{
|
||||
let to_append = NSAttributedString::alloc(nil)
|
||||
.init_attributed_string(ns_string(&text))
|
||||
.autorelease();
|
||||
|
||||
buf.appendAttributedString_(to_append);
|
||||
}
|
||||
}
|
||||
|
||||
buf
|
||||
};
|
||||
|
||||
let state = self.0.lock();
|
||||
state.pasteboard.clearContents();
|
||||
|
||||
// Only set rich text clipboard types if we actually have 1+ images to include.
|
||||
if any_images {
|
||||
let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_(
|
||||
NSRange::new(0, msg_send![attributed_string, length]),
|
||||
nil,
|
||||
);
|
||||
if rtfd_data != nil {
|
||||
state
|
||||
.pasteboard
|
||||
.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
|
||||
}
|
||||
|
||||
let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
|
||||
NSRange::new(0, attributed_string.length()),
|
||||
nil,
|
||||
);
|
||||
if rtf_data != nil {
|
||||
state
|
||||
.pasteboard
|
||||
.setData_forType(rtf_data, NSPasteboardTypeRTF);
|
||||
}
|
||||
}
|
||||
|
||||
let plain_text = attributed_string.string();
|
||||
state
|
||||
.pasteboard
|
||||
.setString_forType(plain_text, NSPasteboardTypeString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
let state = self.0.lock();
|
||||
let pasteboard = state.pasteboard;
|
||||
state.general_pasteboard.read()
|
||||
}
|
||||
|
||||
// First, see if it's a string.
|
||||
unsafe {
|
||||
let types: id = pasteboard.types();
|
||||
let string_type: id = ns_string("public.utf8-plain-text");
|
||||
fn write_to_clipboard(&self, item: ClipboardItem) {
|
||||
let state = self.0.lock();
|
||||
state.general_pasteboard.write(item);
|
||||
}
|
||||
|
||||
if msg_send![types, containsObject: string_type] {
|
||||
let data = pasteboard.dataForType(string_type);
|
||||
if data == nil {
|
||||
return None;
|
||||
} else if data.bytes().is_null() {
|
||||
// https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
|
||||
// "If the length of the NSData object is 0, this property returns nil."
|
||||
return Some(self.read_string_from_clipboard(&state, &[]));
|
||||
} else {
|
||||
let bytes =
|
||||
slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
|
||||
fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
|
||||
let state = self.0.lock();
|
||||
state.find_pasteboard.read()
|
||||
}
|
||||
|
||||
return Some(self.read_string_from_clipboard(&state, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
// If it wasn't a string, try the various supported image types.
|
||||
for format in ImageFormat::iter() {
|
||||
if let Some(item) = try_clipboard_image(pasteboard, format) {
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it wasn't a string or a supported image type, give up.
|
||||
None
|
||||
fn write_to_find_pasteboard(&self, item: ClipboardItem) {
|
||||
let state = self.0.lock();
|
||||
state.find_pasteboard.write(item);
|
||||
}
|
||||
|
||||
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
|
||||
@@ -1255,116 +1137,6 @@ impl Platform for MacPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
impl MacPlatform {
|
||||
unsafe fn read_string_from_clipboard(
|
||||
&self,
|
||||
state: &MacPlatformState,
|
||||
text_bytes: &[u8],
|
||||
) -> ClipboardItem {
|
||||
unsafe {
|
||||
let text = String::from_utf8_lossy(text_bytes).to_string();
|
||||
let metadata = self
|
||||
.read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type)
|
||||
.and_then(|hash_bytes| {
|
||||
let hash_bytes = hash_bytes.try_into().ok()?;
|
||||
let hash = u64::from_be_bytes(hash_bytes);
|
||||
let metadata = self
|
||||
.read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?;
|
||||
|
||||
if hash == ClipboardString::text_hash(&text) {
|
||||
String::from_utf8(metadata.to_vec()).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
ClipboardItem {
|
||||
entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) {
|
||||
unsafe {
|
||||
let state = self.0.lock();
|
||||
state.pasteboard.clearContents();
|
||||
|
||||
let text_bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
string.text.as_ptr() as *const c_void,
|
||||
string.text.len() as u64,
|
||||
);
|
||||
state
|
||||
.pasteboard
|
||||
.setData_forType(text_bytes, NSPasteboardTypeString);
|
||||
|
||||
if let Some(metadata) = string.metadata.as_ref() {
|
||||
let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
|
||||
let hash_bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
hash_bytes.as_ptr() as *const c_void,
|
||||
hash_bytes.len() as u64,
|
||||
);
|
||||
state
|
||||
.pasteboard
|
||||
.setData_forType(hash_bytes, state.text_hash_pasteboard_type);
|
||||
|
||||
let metadata_bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
metadata.as_ptr() as *const c_void,
|
||||
metadata.len() as u64,
|
||||
);
|
||||
state
|
||||
.pasteboard
|
||||
.setData_forType(metadata_bytes, state.metadata_pasteboard_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn write_image_to_clipboard(&self, image: &Image) {
|
||||
unsafe {
|
||||
let state = self.0.lock();
|
||||
state.pasteboard.clearContents();
|
||||
|
||||
let bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
image.bytes.as_ptr() as *const c_void,
|
||||
image.bytes.len() as u64,
|
||||
);
|
||||
|
||||
state
|
||||
.pasteboard
|
||||
.setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option<ClipboardItem> {
|
||||
let mut ut_type: UTType = format.into();
|
||||
|
||||
unsafe {
|
||||
let types: id = pasteboard.types();
|
||||
if msg_send![types, containsObject: ut_type.inner()] {
|
||||
let data = pasteboard.dataForType(ut_type.inner_mut());
|
||||
if data == nil {
|
||||
None
|
||||
} else {
|
||||
let bytes = Vec::from(slice::from_raw_parts(
|
||||
data.bytes() as *mut u8,
|
||||
data.length() as usize,
|
||||
));
|
||||
let id = hash(&bytes);
|
||||
|
||||
Some(ClipboardItem {
|
||||
entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn path_from_objc(path: id) -> PathBuf {
|
||||
let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
|
||||
let bytes = unsafe { path.UTF8String() as *const u8 };
|
||||
@@ -1605,120 +1377,3 @@ mod security {
|
||||
pub const errSecUserCanceled: OSStatus = -128;
|
||||
pub const errSecItemNotFound: OSStatus = -25300;
|
||||
}
|
||||
|
||||
impl From<ImageFormat> for UTType {
|
||||
fn from(value: ImageFormat) -> Self {
|
||||
match value {
|
||||
ImageFormat::Png => Self::png(),
|
||||
ImageFormat::Jpeg => Self::jpeg(),
|
||||
ImageFormat::Tiff => Self::tiff(),
|
||||
ImageFormat::Webp => Self::webp(),
|
||||
ImageFormat::Gif => Self::gif(),
|
||||
ImageFormat::Bmp => Self::bmp(),
|
||||
ImageFormat::Svg => Self::svg(),
|
||||
ImageFormat::Ico => Self::ico(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
|
||||
struct UTType(id);
|
||||
|
||||
impl UTType {
|
||||
pub fn png() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
|
||||
Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
|
||||
}
|
||||
|
||||
pub fn jpeg() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
|
||||
Self(unsafe { ns_string("public.jpeg") })
|
||||
}
|
||||
|
||||
pub fn gif() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
|
||||
Self(unsafe { ns_string("com.compuserve.gif") })
|
||||
}
|
||||
|
||||
pub fn webp() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
|
||||
Self(unsafe { ns_string("org.webmproject.webp") })
|
||||
}
|
||||
|
||||
pub fn bmp() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
|
||||
Self(unsafe { ns_string("com.microsoft.bmp") })
|
||||
}
|
||||
|
||||
pub fn svg() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
|
||||
Self(unsafe { ns_string("public.svg-image") })
|
||||
}
|
||||
|
||||
pub fn ico() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
|
||||
Self(unsafe { ns_string("com.microsoft.ico") })
|
||||
}
|
||||
|
||||
pub fn tiff() -> Self {
|
||||
// https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
|
||||
Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
|
||||
}
|
||||
|
||||
fn inner(&self) -> *const Object {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn inner_mut(&self) -> *mut Object {
|
||||
self.0 as *mut _
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::ClipboardItem;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clipboard() {
|
||||
let platform = build_platform();
|
||||
assert_eq!(platform.read_from_clipboard(), None);
|
||||
|
||||
let item = ClipboardItem::new_string("1".to_string());
|
||||
platform.write_to_clipboard(item.clone());
|
||||
assert_eq!(platform.read_from_clipboard(), Some(item));
|
||||
|
||||
let item = ClipboardItem {
|
||||
entries: vec![ClipboardEntry::String(
|
||||
ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
|
||||
)],
|
||||
};
|
||||
platform.write_to_clipboard(item.clone());
|
||||
assert_eq!(platform.read_from_clipboard(), Some(item));
|
||||
|
||||
let text_from_other_app = "text from other app";
|
||||
unsafe {
|
||||
let bytes = NSData::dataWithBytes_length_(
|
||||
nil,
|
||||
text_from_other_app.as_ptr() as *const c_void,
|
||||
text_from_other_app.len() as u64,
|
||||
);
|
||||
platform
|
||||
.0
|
||||
.lock()
|
||||
.pasteboard
|
||||
.setData_forType(bytes, NSPasteboardTypeString);
|
||||
}
|
||||
assert_eq!(
|
||||
platform.read_from_clipboard(),
|
||||
Some(ClipboardItem::new_string(text_from_other_app.to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_platform() -> MacPlatform {
|
||||
let platform = MacPlatform::new(false);
|
||||
platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
|
||||
platform
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(crate) struct TestPlatform {
|
||||
current_clipboard_item: Mutex<Option<ClipboardItem>>,
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
current_primary_item: Mutex<Option<ClipboardItem>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
current_find_pasteboard_item: Mutex<Option<ClipboardItem>>,
|
||||
pub(crate) prompts: RefCell<TestPrompts>,
|
||||
screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
|
||||
pub opened_url: RefCell<Option<String>>,
|
||||
@@ -117,6 +119,8 @@ impl TestPlatform {
|
||||
current_clipboard_item: Mutex::new(None),
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
current_primary_item: Mutex::new(None),
|
||||
#[cfg(target_os = "macos")]
|
||||
current_find_pasteboard_item: Mutex::new(None),
|
||||
weak: weak.clone(),
|
||||
opened_url: Default::default(),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -398,9 +402,8 @@ impl Platform for TestPlatform {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn write_to_primary(&self, item: ClipboardItem) {
|
||||
*self.current_primary_item.lock() = Some(item);
|
||||
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
self.current_clipboard_item.lock().clone()
|
||||
}
|
||||
|
||||
fn write_to_clipboard(&self, item: ClipboardItem) {
|
||||
@@ -412,8 +415,19 @@ impl Platform for TestPlatform {
|
||||
self.current_primary_item.lock().clone()
|
||||
}
|
||||
|
||||
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
|
||||
self.current_clipboard_item.lock().clone()
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
fn write_to_primary(&self, item: ClipboardItem) {
|
||||
*self.current_primary_item.lock() = Some(item);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
|
||||
self.current_find_pasteboard_item.lock().clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn write_to_find_pasteboard(&self, item: ClipboardItem) {
|
||||
*self.current_find_pasteboard_item.lock() = Some(item);
|
||||
}
|
||||
|
||||
fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fmt,
|
||||
iter::FusedIterator,
|
||||
sync::{Arc, atomic::AtomicUsize},
|
||||
@@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng};
|
||||
use crate::Priority;
|
||||
|
||||
struct PriorityQueues<T> {
|
||||
high_priority: Vec<T>,
|
||||
medium_priority: Vec<T>,
|
||||
low_priority: Vec<T>,
|
||||
high_priority: VecDeque<T>,
|
||||
medium_priority: VecDeque<T>,
|
||||
low_priority: VecDeque<T>,
|
||||
}
|
||||
|
||||
impl<T> PriorityQueues<T> {
|
||||
@@ -42,9 +43,9 @@ impl<T> PriorityQueueState<T> {
|
||||
let mut queues = self.queues.lock();
|
||||
match priority {
|
||||
Priority::Realtime(_) => unreachable!(),
|
||||
Priority::High => queues.high_priority.push(item),
|
||||
Priority::Medium => queues.medium_priority.push(item),
|
||||
Priority::Low => queues.low_priority.push(item),
|
||||
Priority::High => queues.high_priority.push_back(item),
|
||||
Priority::Medium => queues.medium_priority.push_back(item),
|
||||
Priority::Low => queues.low_priority.push_back(item),
|
||||
};
|
||||
self.condvar.notify_one();
|
||||
Ok(())
|
||||
@@ -141,9 +142,9 @@ impl<T> PriorityQueueReceiver<T> {
|
||||
pub(crate) fn new() -> (PriorityQueueSender<T>, Self) {
|
||||
let state = PriorityQueueState {
|
||||
queues: parking_lot::Mutex::new(PriorityQueues {
|
||||
high_priority: Vec::new(),
|
||||
medium_priority: Vec::new(),
|
||||
low_priority: Vec::new(),
|
||||
high_priority: VecDeque::new(),
|
||||
medium_priority: VecDeque::new(),
|
||||
low_priority: VecDeque::new(),
|
||||
}),
|
||||
condvar: parking_lot::Condvar::new(),
|
||||
receiver_count: AtomicUsize::new(1),
|
||||
@@ -226,7 +227,7 @@ impl<T> PriorityQueueReceiver<T> {
|
||||
if !queues.high_priority.is_empty() {
|
||||
let flip = self.rand.random_ratio(P::High.probability(), mass);
|
||||
if flip {
|
||||
return Ok(queues.high_priority.pop());
|
||||
return Ok(queues.high_priority.pop_front());
|
||||
}
|
||||
mass -= P::High.probability();
|
||||
}
|
||||
@@ -234,7 +235,7 @@ impl<T> PriorityQueueReceiver<T> {
|
||||
if !queues.medium_priority.is_empty() {
|
||||
let flip = self.rand.random_ratio(P::Medium.probability(), mass);
|
||||
if flip {
|
||||
return Ok(queues.medium_priority.pop());
|
||||
return Ok(queues.medium_priority.pop_front());
|
||||
}
|
||||
mass -= P::Medium.probability();
|
||||
}
|
||||
@@ -242,7 +243,7 @@ impl<T> PriorityQueueReceiver<T> {
|
||||
if !queues.low_priority.is_empty() {
|
||||
let flip = self.rand.random_ratio(P::Low.probability(), mass);
|
||||
if flip {
|
||||
return Ok(queues.low_priority.pop());
|
||||
return Ok(queues.low_priority.pop_front());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -876,7 +876,9 @@ pub struct Window {
|
||||
active: Rc<Cell<bool>>,
|
||||
hovered: Rc<Cell<bool>>,
|
||||
pub(crate) needs_present: Rc<Cell<bool>>,
|
||||
pub(crate) last_input_timestamp: Rc<Cell<Instant>>,
|
||||
/// Tracks recent input event timestamps to determine if input is arriving at a high rate.
|
||||
/// Used to selectively enable VRR optimization only when input rate exceeds 60fps.
|
||||
pub(crate) input_rate_tracker: Rc<RefCell<InputRateTracker>>,
|
||||
last_input_modality: InputModality,
|
||||
pub(crate) refreshing: bool,
|
||||
pub(crate) activation_observers: SubscriberSet<(), AnyObserver>,
|
||||
@@ -897,6 +899,51 @@ struct ModifierState {
|
||||
saw_keystroke: bool,
|
||||
}
|
||||
|
||||
/// Tracks input event timestamps to determine if input is arriving at a high rate.
|
||||
/// Used for selective VRR (Variable Refresh Rate) optimization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct InputRateTracker {
|
||||
timestamps: Vec<Instant>,
|
||||
window: Duration,
|
||||
inputs_per_second: u32,
|
||||
sustain_until: Instant,
|
||||
sustain_duration: Duration,
|
||||
}
|
||||
|
||||
impl Default for InputRateTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timestamps: Vec::new(),
|
||||
window: Duration::from_millis(100),
|
||||
inputs_per_second: 60,
|
||||
sustain_until: Instant::now(),
|
||||
sustain_duration: Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputRateTracker {
|
||||
pub fn record_input(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.timestamps.push(now);
|
||||
self.prune_old_timestamps(now);
|
||||
|
||||
let min_events = self.inputs_per_second as u128 * self.window.as_millis() / 1000;
|
||||
if self.timestamps.len() as u128 >= min_events {
|
||||
self.sustain_until = now + self.sustain_duration;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_high_rate(&self) -> bool {
|
||||
Instant::now() < self.sustain_until
|
||||
}
|
||||
|
||||
fn prune_old_timestamps(&mut self, now: Instant) {
|
||||
self.timestamps
|
||||
.retain(|&t| now.duration_since(t) <= self.window);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DrawPhase {
|
||||
None,
|
||||
@@ -1047,7 +1094,7 @@ impl Window {
|
||||
let hovered = Rc::new(Cell::new(platform_window.is_hovered()));
|
||||
let needs_present = Rc::new(Cell::new(false));
|
||||
let next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>> = Default::default();
|
||||
let last_input_timestamp = Rc::new(Cell::new(Instant::now()));
|
||||
let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default()));
|
||||
|
||||
platform_window
|
||||
.request_decorations(window_decorations.unwrap_or(WindowDecorations::Server));
|
||||
@@ -1075,7 +1122,7 @@ impl Window {
|
||||
let active = active.clone();
|
||||
let needs_present = needs_present.clone();
|
||||
let next_frame_callbacks = next_frame_callbacks.clone();
|
||||
let last_input_timestamp = last_input_timestamp.clone();
|
||||
let input_rate_tracker = input_rate_tracker.clone();
|
||||
move |request_frame_options| {
|
||||
let next_frame_callbacks = next_frame_callbacks.take();
|
||||
if !next_frame_callbacks.is_empty() {
|
||||
@@ -1088,12 +1135,12 @@ impl Window {
|
||||
.log_err();
|
||||
}
|
||||
|
||||
// Keep presenting the current scene for 1 extra second since the
|
||||
// last input to prevent the display from underclocking the refresh rate.
|
||||
// Keep presenting if input was recently arriving at a high rate (>= 60fps).
|
||||
// Once high-rate input is detected, we sustain presentation for 1 second
|
||||
// to prevent display underclocking during active input.
|
||||
let needs_present = request_frame_options.require_presentation
|
||||
|| needs_present.get()
|
||||
|| (active.get()
|
||||
&& last_input_timestamp.get().elapsed() < Duration::from_secs(1));
|
||||
|| (active.get() && input_rate_tracker.borrow_mut().is_high_rate());
|
||||
|
||||
if invalidator.is_dirty() || request_frame_options.force_render {
|
||||
measure("frame duration", || {
|
||||
@@ -1101,7 +1148,6 @@ impl Window {
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
let arena_clear_needed = window.draw(cx);
|
||||
window.present();
|
||||
// drop the arena elements after present to reduce latency
|
||||
arena_clear_needed.clear();
|
||||
})
|
||||
.log_err();
|
||||
@@ -1299,7 +1345,7 @@ impl Window {
|
||||
active,
|
||||
hovered,
|
||||
needs_present,
|
||||
last_input_timestamp,
|
||||
input_rate_tracker,
|
||||
last_input_modality: InputModality::Mouse,
|
||||
refreshing: false,
|
||||
activation_observers: SubscriberSet::new(),
|
||||
@@ -3691,8 +3737,6 @@ impl Window {
|
||||
/// Dispatch a mouse or keyboard event on the window.
|
||||
#[profiling::function]
|
||||
pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult {
|
||||
self.last_input_timestamp.set(Instant::now());
|
||||
|
||||
// Track whether this input was keyboard-based for focus-visible styling
|
||||
self.last_input_modality = match &event {
|
||||
PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => {
|
||||
@@ -3793,6 +3837,10 @@ impl Window {
|
||||
self.dispatch_key_event(any_key_event, cx);
|
||||
}
|
||||
|
||||
if self.invalidator.is_dirty() {
|
||||
self.input_rate_tracker.borrow_mut().record_input();
|
||||
}
|
||||
|
||||
DispatchEventResult {
|
||||
propagate: cx.propagate_event,
|
||||
default_prevented: self.default_prevented,
|
||||
|
||||
@@ -827,6 +827,15 @@ pub struct LanguageConfig {
|
||||
/// Delimiters and configuration for recognizing and formatting documentation comments.
|
||||
#[serde(default, alias = "documentation")]
|
||||
pub documentation_comment: Option<BlockCommentConfig>,
|
||||
/// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
|
||||
#[serde(default)]
|
||||
pub unordered_list: Vec<Arc<str>>,
|
||||
/// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
|
||||
#[serde(default)]
|
||||
pub ordered_list: Vec<OrderedListConfig>,
|
||||
/// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
|
||||
#[serde(default)]
|
||||
pub task_list: Option<TaskListConfig>,
|
||||
/// A list of additional regex patterns that should be treated as prefixes
|
||||
/// for creating boundaries during rewrapping, ensuring content from one
|
||||
/// prefixed section doesn't merge with another (e.g., markdown list items).
|
||||
@@ -898,6 +907,24 @@ pub struct DecreaseIndentConfig {
|
||||
pub valid_after: Vec<String>,
|
||||
}
|
||||
|
||||
/// Configuration for continuing ordered lists with auto-incrementing numbers.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
pub struct OrderedListConfig {
|
||||
/// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `).
|
||||
pub pattern: String,
|
||||
/// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `).
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
/// Configuration for continuing task lists on newline.
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
pub struct TaskListConfig {
|
||||
/// The list markers to match (e.g., `- [ ] `, `- [x] `).
|
||||
pub prefixes: Vec<Arc<str>>,
|
||||
/// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
|
||||
pub continuation: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub struct LanguageMatcher {
|
||||
/// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
|
||||
@@ -1068,6 +1095,9 @@ impl Default for LanguageConfig {
|
||||
line_comments: Default::default(),
|
||||
block_comment: Default::default(),
|
||||
documentation_comment: Default::default(),
|
||||
unordered_list: Default::default(),
|
||||
ordered_list: Default::default(),
|
||||
task_list: Default::default(),
|
||||
rewrap_prefixes: Default::default(),
|
||||
scope_opt_in_language_servers: Default::default(),
|
||||
overrides: Default::default(),
|
||||
@@ -2153,6 +2183,21 @@ impl LanguageScope {
|
||||
self.language.config.documentation_comment.as_ref()
|
||||
}
|
||||
|
||||
/// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
|
||||
pub fn unordered_list(&self) -> &[Arc<str>] {
|
||||
&self.language.config.unordered_list
|
||||
}
|
||||
|
||||
/// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `).
|
||||
pub fn ordered_list(&self) -> &[OrderedListConfig] {
|
||||
&self.language.config.ordered_list
|
||||
}
|
||||
|
||||
/// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `).
|
||||
pub fn task_list(&self) -> Option<&TaskListConfig> {
|
||||
self.language.config.task_list.as_ref()
|
||||
}
|
||||
|
||||
/// Returns additional regex patterns that act as prefix markers for creating
|
||||
/// boundaries during rewrapping.
|
||||
///
|
||||
|
||||
@@ -122,6 +122,10 @@ pub struct LanguageSettings {
|
||||
pub whitespace_map: WhitespaceMap,
|
||||
/// Whether to start a new line with a comment when a previous line is a comment as well.
|
||||
pub extend_comment_on_newline: bool,
|
||||
/// Whether to continue markdown lists when pressing enter.
|
||||
pub extend_list_on_newline: bool,
|
||||
/// Whether to indent list items when pressing tab after a list marker.
|
||||
pub indent_list_on_tab: bool,
|
||||
/// Inlay hint related settings.
|
||||
pub inlay_hints: InlayHintSettings,
|
||||
/// Whether to automatically close brackets.
|
||||
@@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings {
|
||||
tab: SharedString::new(whitespace_map.tab.unwrap().to_string()),
|
||||
},
|
||||
extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(),
|
||||
extend_list_on_newline: settings.extend_list_on_newline.unwrap(),
|
||||
indent_list_on_tab: settings.indent_list_on_tab.unwrap(),
|
||||
inlay_hints: InlayHintSettings {
|
||||
enabled: inlay_hints.enabled.unwrap(),
|
||||
show_value_hints: inlay_hints.show_value_hints.unwrap(),
|
||||
|
||||
@@ -20,6 +20,9 @@ rewrap_prefixes = [
|
||||
">\\s*",
|
||||
"[-*+]\\s+\\[[\\sx]\\]\\s+"
|
||||
]
|
||||
unordered_list = ["- ", "* ", "+ "]
|
||||
ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }]
|
||||
task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " }
|
||||
|
||||
auto_indent_on_paste = false
|
||||
auto_indent_using_last_non_empty_line = false
|
||||
|
||||
@@ -32,8 +32,7 @@ use tempfile::TempDir;
|
||||
use util::{
|
||||
paths::{PathStyle, RemotePathBuf},
|
||||
rel_path::RelPath,
|
||||
shell::{Shell, ShellKind},
|
||||
shell_builder::ShellBuilder,
|
||||
shell::ShellKind,
|
||||
};
|
||||
|
||||
pub(crate) struct SshRemoteConnection {
|
||||
@@ -1544,8 +1543,6 @@ fn build_command(
|
||||
} else {
|
||||
write!(exec, "{ssh_shell} -l")?;
|
||||
};
|
||||
let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
|
||||
.build(Some(exec.clone()), &[]);
|
||||
|
||||
let mut args = Vec::new();
|
||||
args.extend(ssh_args);
|
||||
@@ -1556,8 +1553,7 @@ fn build_command(
|
||||
}
|
||||
|
||||
args.push("-t".into());
|
||||
args.push(command);
|
||||
args.extend(command_args);
|
||||
args.push(exec);
|
||||
|
||||
Ok(CommandTemplate {
|
||||
program: "ssh".into(),
|
||||
@@ -1597,9 +1593,6 @@ mod tests {
|
||||
"-p",
|
||||
"2222",
|
||||
"-t",
|
||||
"/bin/fish",
|
||||
"-i",
|
||||
"-c",
|
||||
"cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
|
||||
]
|
||||
);
|
||||
@@ -1632,9 +1625,6 @@ mod tests {
|
||||
"-L",
|
||||
"1:foo:2",
|
||||
"-t",
|
||||
"/bin/fish",
|
||||
"-i",
|
||||
"-c",
|
||||
"cd && exec env INPUT_VA=val /bin/fish -l"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -106,7 +106,10 @@ pub struct BufferSearchBar {
|
||||
replacement_editor_focused: bool,
|
||||
active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
|
||||
active_match_index: Option<usize>,
|
||||
active_searchable_item_subscription: Option<Subscription>,
|
||||
#[cfg(target_os = "macos")]
|
||||
active_searchable_item_subscriptions: Option<[Subscription; 2]>,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
active_searchable_item_subscriptions: Option<Subscription>,
|
||||
active_search: Option<Arc<SearchQuery>>,
|
||||
searchable_items_with_matches: HashMap<Box<dyn WeakSearchableItemHandle>, AnyVec<dyn Send>>,
|
||||
pending_search: Option<Task<()>>,
|
||||
@@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar {
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
cx.notify();
|
||||
self.active_searchable_item_subscription.take();
|
||||
self.active_searchable_item_subscriptions.take();
|
||||
self.active_searchable_item.take();
|
||||
|
||||
self.pending_search.take();
|
||||
@@ -482,18 +485,58 @@ impl ToolbarItemView for BufferSearchBar {
|
||||
{
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
self.active_searchable_item_subscription =
|
||||
Some(searchable_item_handle.subscribe_to_search_events(
|
||||
window,
|
||||
cx,
|
||||
Box::new(move |search_event, window, cx| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_active_searchable_item_event(search_event, window, cx)
|
||||
});
|
||||
let search_event_subscription = searchable_item_handle.subscribe_to_search_events(
|
||||
window,
|
||||
cx,
|
||||
Box::new(move |search_event, window, cx| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.on_active_searchable_item_event(search_event, window, cx)
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let item_focus_handle = searchable_item_handle.item_focus_handle(cx);
|
||||
|
||||
self.active_searchable_item_subscriptions = Some([
|
||||
search_event_subscription,
|
||||
cx.on_focus(&item_focus_handle, window, |this, window, cx| {
|
||||
if this.query_editor_focused || this.replacement_editor_focused {
|
||||
// no need to read pasteboard since focus came from toolbar
|
||||
return;
|
||||
}
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
if let Some(item) = cx.read_from_find_pasteboard()
|
||||
&& let Some(text) = item.text()
|
||||
{
|
||||
if this.query(cx) != text {
|
||||
let search_options = item
|
||||
.metadata()
|
||||
.and_then(|m| m.parse().ok())
|
||||
.and_then(SearchOptions::from_bits)
|
||||
.unwrap_or(this.search_options);
|
||||
|
||||
drop(this.search(
|
||||
&text,
|
||||
Some(search_options),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
));
|
||||
]);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
self.active_searchable_item_subscriptions = Some(search_event_subscription);
|
||||
}
|
||||
|
||||
let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
|
||||
self.active_searchable_item = Some(searchable_item_handle);
|
||||
@@ -663,7 +706,7 @@ impl BufferSearchBar {
|
||||
replacement_editor,
|
||||
replacement_editor_focused: false,
|
||||
active_searchable_item: None,
|
||||
active_searchable_item_subscription: None,
|
||||
active_searchable_item_subscriptions: None,
|
||||
active_match_index: None,
|
||||
searchable_items_with_matches: Default::default(),
|
||||
default_options: search_options,
|
||||
@@ -904,11 +947,21 @@ impl BufferSearchBar {
|
||||
});
|
||||
self.set_search_options(options, cx);
|
||||
self.clear_matches(window, cx);
|
||||
#[cfg(target_os = "macos")]
|
||||
self.update_find_pasteboard(cx);
|
||||
cx.notify();
|
||||
}
|
||||
self.update_matches(!updated, add_to_history, window, cx)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn update_find_pasteboard(&mut self, cx: &mut App) {
|
||||
cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata(
|
||||
self.query(cx),
|
||||
self.search_options.bits().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(active_editor) = self.active_searchable_item.as_ref() {
|
||||
let handle = active_editor.item_focus_handle(cx);
|
||||
@@ -1098,11 +1151,12 @@ impl BufferSearchBar {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if search.await.is_ok() {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.activate_current_match(window, cx)
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
this.activate_current_match(window, cx);
|
||||
#[cfg(target_os = "macos")]
|
||||
this.update_find_pasteboard(cx);
|
||||
})?;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
@@ -1293,6 +1347,7 @@ impl BufferSearchBar {
|
||||
.insert(active_searchable_item.downgrade(), matches);
|
||||
|
||||
this.update_match_index(window, cx);
|
||||
|
||||
if add_to_history {
|
||||
this.search_history
|
||||
.add(&mut this.search_history_cursor, query_text);
|
||||
|
||||
@@ -363,6 +363,14 @@ pub struct LanguageSettingsContent {
|
||||
///
|
||||
/// Default: true
|
||||
pub extend_comment_on_newline: Option<bool>,
|
||||
/// Whether to continue markdown lists when pressing enter.
|
||||
///
|
||||
/// Default: true
|
||||
pub extend_list_on_newline: Option<bool>,
|
||||
/// Whether to indent list items when pressing tab after a list marker.
|
||||
///
|
||||
/// Default: true
|
||||
pub indent_list_on_tab: Option<bool>,
|
||||
/// Inlay hint related settings.
|
||||
pub inlay_hints: Option<InlayHintSettingsContent>,
|
||||
/// Whether to automatically type closing characters for you. For example,
|
||||
|
||||
@@ -430,6 +430,8 @@ impl VsCodeSettings {
|
||||
enable_language_server: None,
|
||||
ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"),
|
||||
extend_comment_on_newline: None,
|
||||
extend_list_on_newline: None,
|
||||
indent_list_on_tab: None,
|
||||
format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| {
|
||||
if b {
|
||||
FormatOnSave::On
|
||||
|
||||
@@ -166,11 +166,11 @@ impl Render for TitleBar {
|
||||
.when(title_bar_settings.show_project_items, |title_bar| {
|
||||
title_bar
|
||||
.children(self.render_restricted_mode(cx))
|
||||
.children(self.render_project_host(cx))
|
||||
.child(self.render_project_name(cx))
|
||||
.children(self.render_project_host(window, cx))
|
||||
.child(self.render_project_name(window, cx))
|
||||
})
|
||||
.when(title_bar_settings.show_branch_name, |title_bar| {
|
||||
title_bar.children(self.render_project_repo(cx))
|
||||
title_bar.children(self.render_project_repo(window, cx))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -350,7 +350,14 @@ impl TitleBar {
|
||||
.next()
|
||||
}
|
||||
|
||||
fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
fn render_remote_project_connection(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
let workspace = self.workspace.clone();
|
||||
let is_picker_open = self.is_picker_open(window, cx);
|
||||
|
||||
let options = self.project.read(cx).remote_connection_options(cx)?;
|
||||
let host: SharedString = options.display_name().into();
|
||||
|
||||
@@ -395,7 +402,7 @@ impl TitleBar {
|
||||
let meta = SharedString::from(meta);
|
||||
|
||||
Some(
|
||||
ButtonLike::new("ssh-server-icon")
|
||||
ButtonLike::new("remote_project")
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -410,26 +417,35 @@ impl TitleBar {
|
||||
)
|
||||
.child(Label::new(nickname).size(LabelSize::Small).truncate()),
|
||||
)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
tooltip_title,
|
||||
Some(&OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
}),
|
||||
meta.clone(),
|
||||
cx,
|
||||
)
|
||||
.when(!is_picker_open, |this| {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
tooltip_title,
|
||||
Some(&OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
}),
|
||||
meta.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
.on_click(move |event, window, cx| {
|
||||
let position = event.position();
|
||||
let _ = workspace.update(cx, |this, cx| {
|
||||
this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
|
||||
position,
|
||||
});
|
||||
|
||||
window.dispatch_action(
|
||||
OpenRemote {
|
||||
from_existing_connection: false,
|
||||
create_new_window: false,
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
@@ -481,9 +497,13 @@ impl TitleBar {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
pub fn render_project_host(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
if self.project.read(cx).is_via_remote_server() {
|
||||
return self.render_remote_project_connection(cx);
|
||||
return self.render_remote_project_connection(window, cx);
|
||||
}
|
||||
|
||||
if self.project.read(cx).is_disconnected(cx) {
|
||||
@@ -491,7 +511,6 @@ impl TitleBar {
|
||||
Button::new("disconnected", "Disconnected")
|
||||
.disabled(true)
|
||||
.color(Color::Disabled)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.label_size(LabelSize::Small)
|
||||
.into_any_element(),
|
||||
);
|
||||
@@ -504,15 +523,19 @@ impl TitleBar {
|
||||
.read(cx)
|
||||
.participant_indices()
|
||||
.get(&host_user.id)?;
|
||||
|
||||
Some(
|
||||
Button::new("project_owner_trigger", host_user.github_login.clone())
|
||||
.color(Color::Player(participant_index.0))
|
||||
.style(ButtonStyle::Subtle)
|
||||
.label_size(LabelSize::Small)
|
||||
.tooltip(Tooltip::text(format!(
|
||||
"{} is sharing this project. Click to follow.",
|
||||
host_user.github_login
|
||||
)))
|
||||
.tooltip(move |_, cx| {
|
||||
let tooltip_title = format!(
|
||||
"{} is sharing this project. Click to follow.",
|
||||
host_user.github_login
|
||||
);
|
||||
|
||||
Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx)
|
||||
})
|
||||
.on_click({
|
||||
let host_peer_id = host.peer_id;
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
@@ -527,7 +550,14 @@ impl TitleBar {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
pub fn render_project_name(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let workspace = self.workspace.clone();
|
||||
let is_picker_open = self.is_picker_open(window, cx);
|
||||
|
||||
let name = self.project_name(cx);
|
||||
let is_project_selected = name.is_some();
|
||||
let name = if let Some(name) = name {
|
||||
@@ -537,19 +567,25 @@ impl TitleBar {
|
||||
};
|
||||
|
||||
Button::new("project_name_trigger", name)
|
||||
.when(!is_project_selected, |b| b.color(Color::Muted))
|
||||
.style(ButtonStyle::Subtle)
|
||||
.label_size(LabelSize::Small)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Recent Projects",
|
||||
&zed_actions::OpenRecent {
|
||||
create_new_window: false,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.when(!is_project_selected, |s| s.color(Color::Muted))
|
||||
.when(!is_picker_open, |this| {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::for_action(
|
||||
"Recent Projects",
|
||||
&zed_actions::OpenRecent {
|
||||
create_new_window: false,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
.on_click(move |event, window, cx| {
|
||||
let position = event.position();
|
||||
let _ = workspace.update(cx, |this, _cx| {
|
||||
this.set_next_modal_placement(workspace::ModalPlacement::Anchored { position })
|
||||
});
|
||||
|
||||
window.dispatch_action(
|
||||
OpenRecent {
|
||||
create_new_window: false,
|
||||
@@ -557,84 +593,102 @@ impl TitleBar {
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_project_repo(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let settings = TitleBarSettings::get_global(cx);
|
||||
pub fn render_project_repo(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
let repository = self.project.read(cx).active_repository(cx)?;
|
||||
let repository_count = self.project.read(cx).repositories(cx).len();
|
||||
let workspace = self.workspace.upgrade()?;
|
||||
let repo = repository.read(cx);
|
||||
let branch_name = repo
|
||||
.branch
|
||||
.as_ref()
|
||||
.map(|branch| branch.name())
|
||||
.map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
|
||||
.or_else(|| {
|
||||
repo.head_commit.as_ref().map(|commit| {
|
||||
commit
|
||||
.sha
|
||||
.chars()
|
||||
.take(MAX_SHORT_SHA_LENGTH)
|
||||
.collect::<String>()
|
||||
})
|
||||
})?;
|
||||
let project_name = self.project_name(cx);
|
||||
let repo_name = repo
|
||||
.work_directory_abs_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(SharedString::new);
|
||||
let show_repo_name =
|
||||
repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
|
||||
let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
|
||||
format!("{repo_name}/{branch_name}")
|
||||
} else {
|
||||
branch_name
|
||||
|
||||
let (branch_name, icon_info) = {
|
||||
let repo = repository.read(cx);
|
||||
let branch_name = repo
|
||||
.branch
|
||||
.as_ref()
|
||||
.map(|branch| branch.name())
|
||||
.map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
|
||||
.or_else(|| {
|
||||
repo.head_commit.as_ref().map(|commit| {
|
||||
commit
|
||||
.sha
|
||||
.chars()
|
||||
.take(MAX_SHORT_SHA_LENGTH)
|
||||
.collect::<String>()
|
||||
})
|
||||
});
|
||||
|
||||
let branch_name = branch_name?;
|
||||
|
||||
let project_name = self.project_name(cx);
|
||||
let repo_name = repo
|
||||
.work_directory_abs_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(SharedString::new);
|
||||
let show_repo_name =
|
||||
repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
|
||||
let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
|
||||
format!("{repo_name}/{branch_name}")
|
||||
} else {
|
||||
branch_name
|
||||
};
|
||||
|
||||
let status = repo.status_summary();
|
||||
let tracked = status.index + status.worktree;
|
||||
let icon_info = if status.conflict > 0 {
|
||||
(IconName::Warning, Color::VersionControlConflict)
|
||||
} else if tracked.modified > 0 {
|
||||
(IconName::SquareDot, Color::VersionControlModified)
|
||||
} else if tracked.added > 0 || status.untracked > 0 {
|
||||
(IconName::SquarePlus, Color::VersionControlAdded)
|
||||
} else if tracked.deleted > 0 {
|
||||
(IconName::SquareMinus, Color::VersionControlDeleted)
|
||||
} else {
|
||||
(IconName::GitBranch, Color::Muted)
|
||||
};
|
||||
|
||||
(branch_name, icon_info)
|
||||
};
|
||||
|
||||
let is_picker_open = self.is_picker_open(window, cx);
|
||||
let settings = TitleBarSettings::get_global(cx);
|
||||
|
||||
Some(
|
||||
Button::new("project_branch_trigger", branch_name)
|
||||
.color(Color::Muted)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.label_size(LabelSize::Small)
|
||||
.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Recent Branches",
|
||||
Some(&zed_actions::git::Branch),
|
||||
"Local branches only",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
let _ = workspace.update(cx, |this, cx| {
|
||||
window.focus(&this.active_pane().focus_handle(cx), cx);
|
||||
window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
|
||||
});
|
||||
.color(Color::Muted)
|
||||
.when(!is_picker_open, |this| {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Recent Branches",
|
||||
Some(&zed_actions::git::Branch),
|
||||
"Local branches only",
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.when(settings.show_branch_icon, |branch_button| {
|
||||
let (icon, icon_color) = {
|
||||
let status = repo.status_summary();
|
||||
let tracked = status.index + status.worktree;
|
||||
if status.conflict > 0 {
|
||||
(IconName::Warning, Color::VersionControlConflict)
|
||||
} else if tracked.modified > 0 {
|
||||
(IconName::SquareDot, Color::VersionControlModified)
|
||||
} else if tracked.added > 0 || status.untracked > 0 {
|
||||
(IconName::SquarePlus, Color::VersionControlAdded)
|
||||
} else if tracked.deleted > 0 {
|
||||
(IconName::SquareMinus, Color::VersionControlDeleted)
|
||||
} else {
|
||||
(IconName::GitBranch, Color::Muted)
|
||||
}
|
||||
};
|
||||
|
||||
let (icon, icon_color) = icon_info;
|
||||
branch_button
|
||||
.icon(icon)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_color(icon_color)
|
||||
.icon_size(IconSize::Indicator)
|
||||
})
|
||||
.on_click(move |event, window, cx| {
|
||||
let position = event.position();
|
||||
let _ = workspace.update(cx, |this, cx| {
|
||||
this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
|
||||
position,
|
||||
});
|
||||
window.focus(&this.active_pane().focus_handle(cx), cx);
|
||||
window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
|
||||
});
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -726,7 +780,7 @@ impl TitleBar {
|
||||
|
||||
pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
|
||||
let client = self.client.clone();
|
||||
Button::new("sign_in", "Sign in")
|
||||
Button::new("sign_in", "Sign In")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(move |_, window, cx| {
|
||||
let client = client.clone();
|
||||
@@ -848,4 +902,10 @@ impl TitleBar {
|
||||
})
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
}
|
||||
|
||||
fn is_picker_open(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +241,16 @@ impl RenderOnce for LabelLike {
|
||||
.when(self.strikethrough, |this| this.line_through())
|
||||
.when(self.single_line, |this| this.whitespace_nowrap())
|
||||
.when(self.truncate, |this| {
|
||||
this.overflow_x_hidden().text_ellipsis()
|
||||
this.min_w_0()
|
||||
.overflow_x_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
})
|
||||
.when(self.truncate_start, |this| {
|
||||
this.overflow_x_hidden().text_ellipsis_start()
|
||||
this.min_w_0()
|
||||
.overflow_x_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis_start()
|
||||
})
|
||||
.text_color(color)
|
||||
.font_weight(
|
||||
|
||||
@@ -230,6 +230,14 @@ struct VimEdit {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
/// Pastes the specified file's contents.
|
||||
#[derive(Clone, PartialEq, Action)]
|
||||
#[action(namespace = vim, no_json, no_register)]
|
||||
struct VimRead {
|
||||
pub range: Option<CommandRange>,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Action)]
|
||||
#[action(namespace = vim, no_json, no_register)]
|
||||
struct VimNorm {
|
||||
@@ -643,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
||||
});
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, action: &VimRead, window, cx| {
|
||||
vim.update_editor(cx, |vim, editor, cx| {
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let end = if let Some(range) = action.range.clone() {
|
||||
let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
match &range.start {
|
||||
// inserting text above the first line uses the command ":0r {name}"
|
||||
Position::Line { row: 0, offset: 0 } if range.end.is_none() => {
|
||||
snapshot.clip_point(Point::new(0, 0), Bias::Right)
|
||||
}
|
||||
_ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right),
|
||||
}
|
||||
} else {
|
||||
let end_row = editor
|
||||
.selections
|
||||
.newest::<Point>(&editor.display_snapshot(cx))
|
||||
.range()
|
||||
.end
|
||||
.row;
|
||||
snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right)
|
||||
};
|
||||
let is_end_of_file = end == snapshot.max_point();
|
||||
let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end);
|
||||
|
||||
let mut text = if is_end_of_file {
|
||||
String::from('\n')
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut task = None;
|
||||
if action.filename.is_empty() {
|
||||
text.push_str(
|
||||
&editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.map(|buffer| buffer.read(cx).text())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
if let Some(project) = editor.project().cloned() {
|
||||
project.update(cx, |project, cx| {
|
||||
let Some(worktree) = project.visible_worktrees(cx).next() else {
|
||||
return;
|
||||
};
|
||||
let path_style = worktree.read(cx).path_style();
|
||||
let Some(path) =
|
||||
RelPath::new(Path::new(&action.filename), path_style).log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
task =
|
||||
Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx)));
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |editor, cx| {
|
||||
if let Some(task) = task {
|
||||
text.push_str(
|
||||
&task
|
||||
.await
|
||||
.log_err()
|
||||
.map(|loaded_file| loaded_file.text)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
if !text.is_empty() && !is_end_of_file {
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
let _ = editor.update_in(cx, |editor, window, cx| {
|
||||
editor.transact(window, cx, |editor, window, cx| {
|
||||
editor.edit([(edit_range.clone(), text)], cx);
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
let point = if is_end_of_file {
|
||||
Point::new(
|
||||
edit_range.start.to_point(&snapshot).row.saturating_add(1),
|
||||
0,
|
||||
)
|
||||
} else {
|
||||
Point::new(edit_range.start.to_point(&snapshot).row, 0)
|
||||
};
|
||||
s.select_ranges([point..point]);
|
||||
})
|
||||
});
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
|
||||
let keystrokes = action
|
||||
.command
|
||||
@@ -1338,6 +1447,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
|
||||
VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
|
||||
.bang(editor::actions::ReloadFile)
|
||||
.filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
|
||||
VimCommand::new(
|
||||
("r", "ead"),
|
||||
VimRead {
|
||||
range: None,
|
||||
filename: "".into(),
|
||||
},
|
||||
)
|
||||
.filename(|_, filename| {
|
||||
Some(
|
||||
VimRead {
|
||||
range: None,
|
||||
filename,
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
})
|
||||
.range(|action, range| {
|
||||
let mut action: VimRead = action.as_any().downcast_ref::<VimRead>().unwrap().clone();
|
||||
action.range.replace(range.clone());
|
||||
Some(Box::new(action))
|
||||
}),
|
||||
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
|
||||
Some(
|
||||
VimSplit {
|
||||
@@ -2575,6 +2705,76 @@ mod test {
|
||||
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_command_read(cx: &mut TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
|
||||
let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
|
||||
let path = Path::new(path!("/root/dir/other.rs"));
|
||||
fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
|
||||
|
||||
cx.workspace(|workspace, _, cx| {
|
||||
assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
|
||||
});
|
||||
|
||||
// File without trailing newline
|
||||
cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
|
||||
|
||||
cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
|
||||
|
||||
cx.set_state("one\nˇtwo\nthree", Mode::Normal);
|
||||
cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
|
||||
|
||||
cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.run_until_parked();
|
||||
cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
|
||||
|
||||
// Empty filename
|
||||
cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
|
||||
cx.simulate_keystrokes(": r");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
|
||||
|
||||
// File with trailing newline
|
||||
fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
|
||||
cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
|
||||
|
||||
cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
|
||||
|
||||
cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
|
||||
|
||||
cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
|
||||
|
||||
// Empty file
|
||||
fs.as_fake().insert_file(path, "".into()).await;
|
||||
cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
|
||||
cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_command_quit(cx: &mut TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use gpui::{
|
||||
AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView,
|
||||
MouseButton, Subscription,
|
||||
MouseButton, Pixels, Point, Subscription,
|
||||
};
|
||||
use ui::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum ModalPlacement {
|
||||
#[default]
|
||||
Centered,
|
||||
Anchored {
|
||||
position: Point<Pixels>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DismissDecision {
|
||||
Dismiss(bool),
|
||||
@@ -58,6 +67,7 @@ pub struct ActiveModal {
|
||||
_subscriptions: [Subscription; 2],
|
||||
previous_focus_handle: Option<FocusHandle>,
|
||||
focus_handle: FocusHandle,
|
||||
placement: ModalPlacement,
|
||||
}
|
||||
|
||||
pub struct ModalLayer {
|
||||
@@ -87,6 +97,19 @@ impl ModalLayer {
|
||||
where
|
||||
V: ModalView,
|
||||
B: FnOnce(&mut Window, &mut Context<V>) -> V,
|
||||
{
|
||||
self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view);
|
||||
}
|
||||
|
||||
pub fn toggle_modal_with_placement<V, B>(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
placement: ModalPlacement,
|
||||
build_view: B,
|
||||
) where
|
||||
V: ModalView,
|
||||
B: FnOnce(&mut Window, &mut Context<V>) -> V,
|
||||
{
|
||||
if let Some(active_modal) = &self.active_modal {
|
||||
let is_close = active_modal.modal.view().downcast::<V>().is_ok();
|
||||
@@ -96,12 +119,17 @@ impl ModalLayer {
|
||||
}
|
||||
}
|
||||
let new_modal = cx.new(|cx| build_view(window, cx));
|
||||
self.show_modal(new_modal, window, cx);
|
||||
self.show_modal(new_modal, placement, window, cx);
|
||||
cx.emit(ModalOpenedEvent);
|
||||
}
|
||||
|
||||
fn show_modal<V>(&mut self, new_modal: Entity<V>, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
fn show_modal<V>(
|
||||
&mut self,
|
||||
new_modal: Entity<V>,
|
||||
placement: ModalPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) where
|
||||
V: ModalView,
|
||||
{
|
||||
let focus_handle = cx.focus_handle();
|
||||
@@ -123,6 +151,7 @@ impl ModalLayer {
|
||||
],
|
||||
previous_focus_handle: window.focused(cx),
|
||||
focus_handle,
|
||||
placement,
|
||||
});
|
||||
cx.defer_in(window, move |_, window, cx| {
|
||||
window.focus(&new_modal.focus_handle(cx), cx);
|
||||
@@ -183,6 +212,30 @@ impl Render for ModalLayer {
|
||||
return active_modal.modal.view().into_any_element();
|
||||
}
|
||||
|
||||
let content = h_flex()
|
||||
.occlude()
|
||||
.child(active_modal.modal.view())
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
});
|
||||
|
||||
let positioned = match active_modal.placement {
|
||||
ModalPlacement::Centered => v_flex()
|
||||
.h(px(0.0))
|
||||
.top_20()
|
||||
.items_center()
|
||||
.track_focus(&active_modal.focus_handle)
|
||||
.child(content)
|
||||
.into_any_element(),
|
||||
ModalPlacement::Anchored { position } => div()
|
||||
.absolute()
|
||||
.left(position.x)
|
||||
.top(position.y - px(20.))
|
||||
.track_focus(&active_modal.focus_handle)
|
||||
.child(content)
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
.size_full()
|
||||
@@ -199,21 +252,7 @@ impl Render for ModalLayer {
|
||||
this.hide_modal(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.h(px(0.0))
|
||||
.top_20()
|
||||
.items_center()
|
||||
.track_focus(&active_modal.focus_handle)
|
||||
.child(
|
||||
h_flex()
|
||||
.occlude()
|
||||
.child(active_modal.modal.view())
|
||||
.on_mouse_down(MouseButton::Left, |_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(positioned)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1846,6 +1846,7 @@ impl Pane {
|
||||
}
|
||||
|
||||
for item_to_close in items_to_close {
|
||||
let mut should_close = true;
|
||||
let mut should_save = true;
|
||||
if save_intent == SaveIntent::Close {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
@@ -1861,7 +1862,7 @@ impl Pane {
|
||||
{
|
||||
Ok(success) => {
|
||||
if !success {
|
||||
break;
|
||||
should_close = false;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1880,23 +1881,25 @@ impl Pane {
|
||||
})?;
|
||||
match answer.await {
|
||||
Ok(0) => {}
|
||||
Ok(1..) | Err(_) => break,
|
||||
Ok(1..) | Err(_) => should_close = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the item from the pane.
|
||||
pane.update_in(cx, |pane, window, cx| {
|
||||
pane.remove_item(
|
||||
item_to_close.item_id(),
|
||||
false,
|
||||
pane.close_pane_if_empty,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
if should_close {
|
||||
pane.update_in(cx, |pane, window, cx| {
|
||||
pane.remove_item(
|
||||
item_to_close.item_id(),
|
||||
false,
|
||||
pane.close_pane_if_empty,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pane.update(cx, |_, cx| cx.notify()).ok();
|
||||
@@ -6614,6 +6617,60 @@ mod tests {
|
||||
cx.simulate_prompt_answer("Discard all");
|
||||
save.await.unwrap();
|
||||
assert_item_labels(&pane, [], cx);
|
||||
|
||||
add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| {
|
||||
item.project_items
|
||||
.push(TestProjectItem::new_dirty(1, "A.txt", cx))
|
||||
});
|
||||
add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| {
|
||||
item.project_items
|
||||
.push(TestProjectItem::new_dirty(2, "B.txt", cx))
|
||||
});
|
||||
add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| {
|
||||
item.project_items
|
||||
.push(TestProjectItem::new_dirty(3, "C.txt", cx))
|
||||
});
|
||||
assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
|
||||
|
||||
let close_task = pane.update_in(cx, |pane, window, cx| {
|
||||
pane.close_all_items(
|
||||
&CloseAllItems {
|
||||
save_intent: None,
|
||||
close_pinned: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
cx.simulate_prompt_answer("Discard all");
|
||||
close_task.await.unwrap();
|
||||
assert_item_labels(&pane, [], cx);
|
||||
|
||||
add_labeled_item(&pane, "Clean1", false, cx);
|
||||
add_labeled_item(&pane, "Dirty", true, cx).update(cx, |item, cx| {
|
||||
item.project_items
|
||||
.push(TestProjectItem::new_dirty(1, "Dirty.txt", cx))
|
||||
});
|
||||
add_labeled_item(&pane, "Clean2", false, cx);
|
||||
assert_item_labels(&pane, ["Clean1", "Dirty^", "Clean2*"], cx);
|
||||
|
||||
let close_task = pane.update_in(cx, |pane, window, cx| {
|
||||
pane.close_all_items(
|
||||
&CloseAllItems {
|
||||
save_intent: None,
|
||||
close_pinned: false,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
cx.simulate_prompt_answer("Cancel");
|
||||
close_task.await.unwrap();
|
||||
assert_item_labels(&pane, ["Dirty*^"], cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -1204,6 +1204,7 @@ pub struct Workspace {
|
||||
last_open_dock_positions: Vec<DockPosition>,
|
||||
removing: bool,
|
||||
utility_panes: UtilityPaneState,
|
||||
next_modal_placement: Option<ModalPlacement>,
|
||||
}
|
||||
|
||||
impl EventEmitter<Event> for Workspace {}
|
||||
@@ -1620,6 +1621,7 @@ impl Workspace {
|
||||
last_open_dock_positions: Vec::new(),
|
||||
removing: false,
|
||||
utility_panes: UtilityPaneState::default(),
|
||||
next_modal_placement: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6326,12 +6328,25 @@ impl Workspace {
|
||||
self.modal_layer.read(cx).active_modal()
|
||||
}
|
||||
|
||||
pub fn is_modal_open<V: 'static>(&self, cx: &App) -> bool {
|
||||
self.modal_layer.read(cx).active_modal::<V>().is_some()
|
||||
}
|
||||
|
||||
pub fn set_next_modal_placement(&mut self, placement: ModalPlacement) {
|
||||
self.next_modal_placement = Some(placement);
|
||||
}
|
||||
|
||||
fn take_next_modal_placement(&mut self) -> ModalPlacement {
|
||||
self.next_modal_placement.take().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn toggle_modal<V: ModalView, B>(&mut self, window: &mut Window, cx: &mut App, build: B)
|
||||
where
|
||||
B: FnOnce(&mut Window, &mut Context<V>) -> V,
|
||||
{
|
||||
let placement = self.take_next_modal_placement();
|
||||
self.modal_layer.update(cx, |modal_layer, cx| {
|
||||
modal_layer.toggle_modal(window, cx, build)
|
||||
modal_layer.toggle_modal_with_placement(window, cx, placement, build)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9409,7 +9424,7 @@ mod tests {
|
||||
let right_pane = right_pane.await.unwrap();
|
||||
cx.focus(&right_pane);
|
||||
|
||||
let mut close = right_pane.update_in(cx, |pane, window, cx| {
|
||||
let close = right_pane.update_in(cx, |pane, window, cx| {
|
||||
pane.close_all_items(&CloseAllItems::default(), window, cx)
|
||||
.unwrap()
|
||||
});
|
||||
@@ -9421,9 +9436,16 @@ mod tests {
|
||||
assert!(!msg.contains("3.txt"));
|
||||
assert!(!msg.contains("4.txt"));
|
||||
|
||||
// With best-effort close, cancelling item 1 keeps it open but items 4
|
||||
// and (3,4) still close since their entries exist in left pane.
|
||||
cx.simulate_prompt_answer("Cancel");
|
||||
close.await;
|
||||
|
||||
right_pane.read_with(cx, |pane, _| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
});
|
||||
|
||||
// Remove item 3 from left pane, making (2,3) the only item with entry 3.
|
||||
left_pane
|
||||
.update_in(cx, |left_pane, window, cx| {
|
||||
left_pane.close_item_by_id(
|
||||
@@ -9436,26 +9458,25 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
close = right_pane.update_in(cx, |pane, window, cx| {
|
||||
let close = left_pane.update_in(cx, |pane, window, cx| {
|
||||
pane.close_all_items(&CloseAllItems::default(), window, cx)
|
||||
.unwrap()
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let details = cx.pending_prompt().unwrap().1;
|
||||
assert!(details.contains("1.txt"));
|
||||
assert!(!details.contains("2.txt"));
|
||||
assert!(details.contains("0.txt"));
|
||||
assert!(details.contains("3.txt"));
|
||||
// ideally this assertion could be made, but today we can only
|
||||
// save whole items not project items, so the orphaned item 3 causes
|
||||
// 4 to be saved too.
|
||||
// assert!(!details.contains("4.txt"));
|
||||
assert!(details.contains("4.txt"));
|
||||
// Ideally 2.txt wouldn't appear since entry 2 still exists in item 2.
|
||||
// But we can only save whole items, so saving (2,3) for entry 3 includes 2.
|
||||
// assert!(!details.contains("2.txt"));
|
||||
|
||||
cx.simulate_prompt_answer("Save all");
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
close.await;
|
||||
right_pane.read_with(cx, |pane, _| {
|
||||
|
||||
left_pane.read_with(cx, |pane, _| {
|
||||
assert_eq!(pane.items_len(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1585,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
|
||||
|
||||
`boolean` values
|
||||
|
||||
## Extend List On Newline
|
||||
|
||||
- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list.
|
||||
- Setting: `extend_list_on_newline`
|
||||
- Default: `true`
|
||||
|
||||
**Options**
|
||||
|
||||
`boolean` values
|
||||
|
||||
## Indent List On Tab
|
||||
|
||||
- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists.
|
||||
- Setting: `indent_list_on_tab`
|
||||
- Default: `true`
|
||||
|
||||
**Options**
|
||||
|
||||
`boolean` values
|
||||
|
||||
## Status Bar
|
||||
|
||||
- Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere.
|
||||
|
||||
@@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c
|
||||
},
|
||||
```
|
||||
|
||||
### List Continuation
|
||||
|
||||
Zed automatically continues lists when you press Enter at the end of a list item. Supported list types:
|
||||
|
||||
- Unordered lists (`-`, `*`, or `+` markers)
|
||||
- Ordered lists (numbers are auto-incremented)
|
||||
- Task lists (`- [ ]` and `- [x]`)
|
||||
|
||||
Pressing Enter on an empty list item removes the marker and exits the list.
|
||||
|
||||
To disable this behavior:
|
||||
|
||||
```json [settings]
|
||||
"languages": {
|
||||
"Markdown": {
|
||||
"extend_list_on_newline": false
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### List Indentation
|
||||
|
||||
Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists.
|
||||
|
||||
To disable this behavior:
|
||||
|
||||
```json [settings]
|
||||
"languages": {
|
||||
"Markdown": {
|
||||
"indent_list_on_tab": false
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
### Trailing Whitespace
|
||||
|
||||
By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `<br />` in Markdown files you can disable this behavior with:
|
||||
|
||||
Reference in New Issue
Block a user