Compare commits

..

10 Commits

Author SHA1 Message Date
Kirill Bulatov
8dd0274b97 Add a draft that fixes things 2025-12-20 00:27:01 +02:00
Agus Zubiaga
d7e41f74fb search: Respect macOS' find pasteboard (#45311)
Closes #17467

Release Notes:

- On macOS, buffer search now syncs with the system find pasteboard,
allowing <kbd>⌘E</kbd> and <kbd>⌘G</kbd> to work seamlessly across Zed
and other apps.
2025-12-19 13:31:27 -03:00
Ben Kunkle
e05dcecac4 Make pane::CloseAllItems best effort (#45368)
Closes #ISSUE

Release Notes:

- Fixed an issue where the `pane: close all items` action would give up
if you hit "Cancel" on the prompt for what to do with a dirty buffer
2025-12-19 16:21:56 +00:00
Danilo Leal
32600f255a gpui: Fix truncation flickering (#45373)
It's been a little that we've noticed some flickering and other weird
resizing behavior with text truncation in Zed:


https://github.com/user-attachments/assets/4d5691a3-cd3d-45e0-8b96-74a4e0e273d2


https://github.com/user-attachments/assets/d1d0e587-7676-4da0-8818-f4e50f0e294e

Initially, we suspected this could be due to how we calculate the length
of a line to insert truncation, which is based first on the length of
each individual character, and then second goes through a pass
calculating the line length as a whole. This could cause mismatch and
culminate in our bug.

However, even though that felt like a reasonable suspicion, I realized
something rather simple at some point: the `truncate` and
`truncate_start` methods in the `Label` didn't use `whitespace_nowrap`.
If you take Tailwind as an example, their `truncate` utility class takes
`overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`. This
pointed out to a potential bug with `whitespace_nowrap` where that was
blocking truncation entirely, even though that's technically part of
what's necessary to truncate as you don't want text that will be
truncated to wrap.

Ultimately, what was happening was that the text element was caching its
layout based on its `wrap_width` but not considering its
`truncate_width`. The truncate width is essentially the new definitive
width of the text based on the available space, which was never being
computed. So the fix here was to add `truncate_width.is_none()` to the
cache validation check, so that it only uses the cached text element
size _if the truncation width is untouched_. But if that changes, we
need to account for the new width. Then, in the Label component, we
added `min_w_0` to allow the label div to shrink below its original
size, and finally, we added `whitespace_nowrap()` as the cache check
fundamentally fixed that method's problem.

In a future PR, we can basically remove the `single_line()` label method
because: 1) whenever you want a single label, you most likely want it to
truncate, and 2) most instances of `truncate` are already followed by
`single_line` in Zed today, so we can cut that part.

Result is no flickering with truncated labels!


https://github.com/user-attachments/assets/ae17cbde-0de7-42ca-98a4-22fcb452016b

Release Notes:

- Fixed a bug in GPUI where truncated text would flicker as you resized
the container in which the text was in.

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
2025-12-19 13:14:31 -03:00
Raduan A.
a7e07010e5 editor: Add automatic markdown list continuation on newline and indent on tab (#42800)
Closes #5089

Release notes:
- Markdown lists now continue automatically when you press Enter
(unordered, ordered, and task lists). This can be configured with
`extend_list_on_newline` (default: true).
- You can now indent list markers with Tab to quickly create nested
lists. This can be configured with `indent_list_on_tab` (default: true).

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-12-19 21:44:02 +05:30
feeiyu
ea34cc5324 Fix terminal doesn't switch to project directory when opening remote project on Windows (#45328)
Closes #45253

Release Notes:

- Fixed terminal doesn't switch to project directory when opening remote
project on Windows
2025-12-19 17:06:16 +01:00
Danilo Leal
a7d43063d4 workspace: Make title bar pickers render nearby the trigger when mouse-triggered (#45361)
From Zed's title bar, you can click on buttons to open three modal
pickers: remote projects, projects, and branches. All of these pickers
use the modal layer, which by default, renders them centered on the UI.
However, a UX issue we've been bothered by is that when you _click_ to
open them, they show up just way too far from where your mouse likely is
(nearby the trigger you just clicked). So, this PR introduces a
`ModalPlacement` enum to the modal layer, so that we can pick between
the "centered" and "anchored" options to render the picker. This way, we
can make the pickers use anchored positioning when triggered through a
mouse click and use the default centered positioning when triggered
through the keybinding.

One thing to note is that the anchored positioning here is not as
polished as regular popovers/dropdowns, because it simply uses the x and
y coordinates of the click to place the picker as opposed to using
GPUI's `Corner` enum, thus making them more connected to their triggers.
I chose to do it this way for now because it's a simpler and more
contained change, given it wouldn't require a tighter connection at the
code level between trigger and picker. But maybe we will want to do that
in the near future because we can bake in some other related behaviors
like automatically hiding the button trigger tooltip if the picker is
open and changing its text color to communicate which button triggered
the open picker.


https://github.com/user-attachments/assets/30d9c26a-24de-4702-8b7d-018b397f77e1

Release Notes:

- Improved the UX of title bar modal pickers (remote projects, projects,
and branches) by making them open closer to the trigger when triggering
them with the mouse.
2025-12-19 13:01:48 -03:00
AidanV
8001877df2 vim: Add :r[ead] [name] command (#45332)
This adds the following Vim commands: 
- `:r[ead] [name]`
- `:{range}r[ead] [name]`

The most important parts of this feature are outlined
[here](https://vimhelp.org/insert.txt.html#%3Ar).

The only intentional difference between this and Vim is that Vim only
allows `:read` (no filename) for buffers with a file attached. I am
allowing it for all buffers because I think that could be useful.

Release Notes:

- vim: Added the [`:r[ead] [name]` Vim
command](https://vimhelp.org/insert.txt.html#:read)

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-12-19 15:31:16 +00:00
Antonio Scandurra
b603372f44 Reduce GPU usage by activating VRR optimization only during high-rate input (#45369)
Fixes #29073

This PR reduces unnecessary GPU usage by being more selective about when
we present frames to prevent display underclocking (VRR optimization).

## Problem

Previously, we would keep presenting frames for 1 second after *any*
input event, regardless of whether it triggered a re-render. This caused
unnecessary GPU work when the user was idle or during low-frequency
interactions.

## Solution

1. **Only track input that triggers re-renders**: We now only record
input timestamps when the input actually causes the window to become
dirty, rather than on every input event.

2. **Rate-based activation**: The VRR optimization now only activates
when input arrives at a high rate (≥ 60fps over the last 100ms). This
means casual mouse movements or occasional keystrokes won't trigger
continuous frame presentation.

3. **Sustained optimization**: Once high-rate input is detected (e.g.,
during scrolling or dragging), we sustain frame presentation for 1
second to prevent display underclocking, even if input briefly pauses.

## Implementation

Added `InputRateTracker` which:
- Tracks input timestamps in a 100ms sliding window
- Activates when the window contains ≥ 6 events (60fps × 0.1s)
- Extends a `sustain_until` timestamp by 1 second each time high rate is
detected

Release Notes:

- Reduced GPU usage when idle by only presenting frames during bursts of
high-frequency input.
2025-12-19 16:06:28 +01:00
Yara 🏳️‍⚧️
7427924405 adjusted scheduler prioritization algorithm (#45367)
This fixes a number of issues where zed depends on the order of polling which changed when switching scheduler. We have adjusted the algorithm so it matches the previous order while keeping the prioritization feature.

Release Notes:
- N/A
2025-12-19 15:03:35 +00:00
30 changed files with 2211 additions and 931 deletions

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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>,

View File

@@ -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
"
});
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, |_| {});

View File

@@ -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.

View File

@@ -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);

View File

@@ -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>)>>>;

View File

@@ -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;

View File

@@ -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);
}
}
}

View 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()))
);
}
}

View File

@@ -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
}
}

View File

@@ -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<()>> {

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -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.
///

View File

@@ -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(),

View File

@@ -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

View File

@@ -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"
]
);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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()
}
}

View File

@@ -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]

View File

@@ -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);
});
}

View File

@@ -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.

View File

@@ -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: