Compare commits

...
Sign in to create a new pull request.

31 commits

Author SHA1 Message Date
Joseph T. Lyons
48b8853def zed 0.107.7 2023-10-16 14:39:59 -04:00
Joseph T. Lyons
c59fd08c65 Fix telemetry-related crash on start up (#3131)
Fixes (hopefully)
[#2136](https://github.com/zed-industries/community/issues/2136).

Release Notes:

- N/A
2023-10-16 14:11:07 -04:00
Joseph T. Lyons
bfea1f2263 v0.107.x stable 2023-10-11 12:40:23 -04:00
Kirill Bulatov
5a2a92c7fe zed 0.107.6 2023-10-11 12:50:56 +03:00
Kirill Bulatov
ac96237806 Omit history files with path that does not exist on disk anymore (#3113) 2023-10-11 12:46:14 +03:00
Kirill Bulatov
596e2f307b Ignore history items' paths when matching search queries (#3107)
Follow-up of https://github.com/zed-industries/zed/pull/3059 

Before: 

![image](https://github.com/zed-industries/zed/assets/2690773/4eb2d2d1-1aa3-40b8-b782-bf2bc5f17b43)

After:

![image](https://github.com/zed-industries/zed/assets/2690773/5587d46b-9198-45fe-9372-114a95d4b7d6)

Release Notes:

- N/A
2023-10-11 12:43:47 +03:00
Antonio Scandurra
933537e912 zed 0.107.5 2023-10-11 08:27:53 +02:00
Antonio Scandurra
c44181d445 Revert outline summarization (#3114)
This pull request essentially reverts #3067: we noticed that only using
the function signatures produces far worse results in codegen, and so
that feels like a regression compared to before. We should re-enable
this once we have a smarter approach to fetching context during codegen,
possibly when #3097 lands.

As a drive-by, we also fixed a longstanding bug that caused codegen to
include the final line of a selection even if the selection ended at the
start of the line.

Ideally, I'd like to hot fix this to preview so that it goes to stable
during the weekly release.

/cc: @KCaverly @nathansobo 

Release Notes:

- N/A
2023-10-11 08:26:55 +02:00
Joseph T. Lyons
ad0e53aa6f Fix Discord text truncation 2023-10-11 01:54:54 -04:00
Max Brunsfeld
0f622417d7 zed 0.107.4 2023-10-10 15:56:19 -07:00
Max Brunsfeld
7ef8bd6377 Always log panics (#2896)
I just panicked and wanted to see the cause, but forgot that panic files
get deleted when Zed uploads them.

Release Notes:

- Panics are now written to `~/Library/Logs/Zed/Zed.log`
2023-10-10 15:55:31 -07:00
Max Brunsfeld
aed317840f Fix inclusion of spurious views from other projects in FollowResponse (#3116)
A logic error in https://github.com/zed-industries/zed/pull/2993 caused
follow responses to sometimes contain extra views for other unshared
projects 😱 . These views would generally fail to deserialize on the
other end. This would create a broken intermediate state, where the
following relationship was registered on the server (and on the leader's
client), but the follower didn't have the state necessary for following
into certain views.

Release Notes:

- Fixed a bug where following would sometimes fail if the leader had
another unshared project open.
2023-10-10 15:54:19 -07:00
Joseph T. Lyons
a15b9a55d2 Truncate Discord release note text (#3112)
Hopefully this works the first time 😅

Release Notes:

- N/A
2023-10-10 00:09:08 -04:00
Max Brunsfeld
91ee6b509f zed 0.107.3 2023-10-09 15:28:46 -07:00
Max Brunsfeld
78fe18acbc More small following-related fixes (#3110) 2023-10-09 15:27:25 -07:00
Kirill Bulatov
9d76ba445f Detect file paths that end with : (#3109)
New rustc messages look like

```
thread 'tests::test_history_items_vs_very_good_external_match' panicked at crates/file_finder/src/file_finder.rs:1902:13:
assertion `left == right` failed: Only one history item contains collab_ui, it should be present and others should be filtered out
  left: 0
 right: 1
```

now and we fail to parse that `13:` bit properly, fix that.

One caveat is that we highlight the entire word including the trailing
`:`:
<img width="914" alt="image"
src="https://github.com/zed-industries/zed/assets/2690773/d653a8ff-3e6e-4e3d-b6ea-dad0c8db0f06">

this is unfortunate, but better than nothing (as now).
This is due to the fact, that we detect words with regex inside the
`terminal.rs` and send events to other place that's able to check paths
for existence (and whether that's a path at all), currently there's no
way to detect a path and sanitize it in `terminal.rs`

Release Notes:

- N/A
2023-10-09 15:27:16 -07:00
Max Brunsfeld
b865efe3a3 Fix bug that allowed following multiple people in one pane (#3108)
I've also simplified the representation of a workspace's leaders, so
that it encodes in the type that there can only be one leader per pane.

Release Notes:

- Fixed a bug where you could accidentally follow multiple collaborators
in one pane at the same time.
2023-10-09 15:27:05 -07:00
Joseph T. Lyons
f92d44ed70 Use display name for release channel in panic events (#3101)
This was a mistake from long ago - something I've been meaning to fix
for a long time. All other events use `display_name()`, but panic
events, which leads to mistakes when filtering out `Zed Dev`, which
isn't the format that `dev_name()` returns. I'm adding a fix to zed.dev
as well:

- https://github.com/zed-industries/zed.dev/pull/393

so that the values are adjusted for all clients, not just ones with this
fix. I will correct the data in clickhouse, and adjust the queries in
metabase.

Release Notes:

- N/A
2023-10-06 14:20:29 -04:00
Joseph T. Lyons
62358b9bce Add session id to panic events (#3098)
Release Notes:

- N/A
2023-10-06 13:33:17 -04:00
Max Brunsfeld
df63290a32 Fix panic when immediately closing a window while opening paths (#3092)
Fixes this panic that I've been seeing in Slack:


[example](https://zed-industries.slack.com/archives/C04S6T1T7TQ/p1696530575535779)


```
thread 'main' panicked at 'assertion failed: opened_items.len() == project_paths_to_open.len()'
crates/workspace/src/workspace.rs:3628
<backtrace::capture::Backtrace>::create
<backtrace::capture::Backtrace>::new
Zed::init_panic_hook::{closure#0}
std::panicking::rust_panic_with_hook
std::panicking::begin_panic_handler::{{closure}}
std::sys_common::backtrace::__rust_end_short_backtrace
_rust_begin_unwind
core::panicking::panic_fmt
core::panicking::panic
<workspace::Workspace>::new_local::{closure#0}::{closure#0}
```

I believe it was caused by a window being closed immediately, while it
was still loading some paths. There was a mismatch in expectation
between the `workspace::open_items` function (which contains this
assertion), and the `Workspace::load_workspace` method. That later
method can return an empty vector if the workspace handle is dropped
while it is executing.

Release Notes:

- Fixed a crash when closing a Zed window immediately after opening it
2023-10-05 16:43:58 -07:00
Max Brunsfeld
6098f94dc1 zed 0.107.2 2023-10-05 16:03:07 -07:00
Mikayla Maki
6f4dee5b1d Add markdown parsing to channel chat (#3088)
TODO:
- [x] Add markdown rendering to channel chat
- [x] Unify (?) rendering logic between hover popover and chat
- [x] ~~Determine how to deal with document-oriented markdown like `#`~~
Unimportant until we want to do something special with `#channel`
- [x] Tidy up spacing and styles in chat panel

Release Notes:

- Added markdown rendering to channel chat
- Improved channel chat message style
- Fixed a bug where long chat messages would not soft wrap
2023-10-05 16:01:28 -07:00
Max Brunsfeld
4ca2645a54 Fix bugs in handling mutual following (#3091)
This fixes some bugs in our following logic, due to our attempts to
prevent infinite loops when two people follow each other.

* Propagate all of leader's views to a new follower, even if those views
were originally created by that follower.
* Propagate active view changes to followers, even if the active view is
following that follower.
* Avoid redundant active view updates on the client.

Release Notes:

- Fixed bugs where it was impossible to follow someone into a view that
they previously following you into.
2023-10-05 15:18:31 -07:00
Joseph T. Lyons
c41a3ec01b Add session id (#3090)
Release Notes:

- N/A
2023-10-05 15:51:18 -04:00
Mikayla
4edd0365a1
collab 0.22.2 2023-10-04 15:45:55 -07:00
Mikayla
cc4fb1c1b5
zed 0.107.1 2023-10-04 15:44:17 -07:00
Mikayla Maki
fc3d754aea
Remove old code from notes icon click handler (#3085)
Release Notes:

- Fix clicking the notes icon when people are in the channel (preview
only)
2023-10-04 15:43:24 -07:00
Mikayla Maki
643f3db2b2
107 channel touch ups (#3087)
Release Notes:

- Add user avatars to channel chat messages
- Group messages by sender
- Fix visual bugs in new chat and note buttons
2023-10-04 15:43:16 -07:00
Max Brunsfeld
b90c04009f
Ensure chat messages are retrieved in order of id (#3086)
Also, remove logic for implicitly marking chat messages as observed when
they are fetched. I think this is unnecessary, because the client always
explicitly acknowledges messages when they are shown.

Release Notes:

- Fixed a bug where chat messages were shown out of order (preview only)
2023-10-04 15:43:08 -07:00
Conrad Irwin
11f7a2cb0e Fix panic in increment (#3084)
Release Notes:

- Fixes a panic in vim when incrementing a non-number.
2023-10-04 15:40:30 -06:00
Joseph T. Lyons
8bdc59703a v0.107.x preview 2023-10-04 15:00:34 -04:00
36 changed files with 1872 additions and 920 deletions

View file

@ -6,8 +6,8 @@ jobs:
discord_release: discord_release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Get appropriate URL - name: Get release URL
id: get-appropriate-url id: get-release-url
run: | run: |
if [ "${{ github.event.release.prerelease }}" == "true" ]; then if [ "${{ github.event.release.prerelease }}" == "true" ]; then
URL="https://zed.dev/releases/preview/latest" URL="https://zed.dev/releases/preview/latest"
@ -15,14 +15,19 @@ jobs:
URL="https://zed.dev/releases/stable/latest" URL="https://zed.dev/releases/stable/latest"
fi fi
echo "::set-output name=URL::$URL" echo "::set-output name=URL::$URL"
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.2.0
id: get-content
with:
stringToTruncate: |
📣 Zed ${{ github.event.release.tag_name }} was just released!
Restart your Zed or head to ${{ steps.get-release-url.outputs.URL }} to grab it.
${{ github.event.release.body }}
maxLength: 2000
- name: Discord Webhook Action - name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0 uses: tsickert/discord-webhook@v5.3.0
with: with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: | content: ${{ steps.get-content.outputs.string }}
📣 Zed ${{ github.event.release.tag_name }} was just released!
Restart your Zed or head to ${{ steps.get-appropriate-url.outputs.URL }} to grab it.
${{ github.event.release.body }}

24
Cargo.lock generated
View file

@ -1467,7 +1467,7 @@ dependencies = [
[[package]] [[package]]
name = "collab" name = "collab"
version = "0.22.1" version = "0.22.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -1563,6 +1563,7 @@ dependencies = [
"postage", "postage",
"project", "project",
"recent_projects", "recent_projects",
"rich_text",
"schemars", "schemars",
"serde", "serde",
"serde_derive", "serde_derive",
@ -2405,6 +2406,7 @@ dependencies = [
"project", "project",
"pulldown-cmark", "pulldown-cmark",
"rand 0.8.5", "rand 0.8.5",
"rich_text",
"rpc", "rpc",
"schemars", "schemars",
"serde", "serde",
@ -6242,6 +6244,24 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "rich_text"
version = "0.1.0"
dependencies = [
"anyhow",
"collections",
"futures 0.3.28",
"gpui",
"language",
"lazy_static",
"pulldown-cmark",
"smallvec",
"smol",
"sum_tree",
"theme",
"util",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.16.20" version = "0.16.20"
@ -10063,7 +10083,7 @@ dependencies = [
[[package]] [[package]]
name = "zed" name = "zed"
version = "0.107.0" version = "0.107.7"
dependencies = [ dependencies = [
"activity_indicator", "activity_indicator",
"anyhow", "anyhow",

View file

@ -64,6 +64,7 @@ members = [
"crates/sqlez", "crates/sqlez",
"crates/sqlez_macros", "crates/sqlez_macros",
"crates/feature_flags", "crates/feature_flags",
"crates/rich_text",
"crates/storybook", "crates/storybook",
"crates/sum_tree", "crates/sum_tree",
"crates/terminal", "crates/terminal",

View file

@ -17,7 +17,7 @@ use editor::{
BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
}, },
scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, scroll::autoscroll::{Autoscroll, AutoscrollStrategy},
Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, ToPoint,
}; };
use fs::Fs; use fs::Fs;
use futures::StreamExt; use futures::StreamExt;
@ -278,22 +278,36 @@ impl AssistantPanel {
if selection.start.excerpt_id() != selection.end.excerpt_id() { if selection.start.excerpt_id() != selection.end.excerpt_id() {
return; return;
} }
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
// Extend the selection to the start and the end of the line.
let mut point_selection = selection.map(|selection| selection.to_point(&snapshot));
if point_selection.end > point_selection.start {
point_selection.start.column = 0;
// If the selection ends at the start of the line, we don't want to include it.
if point_selection.end.column == 0 {
point_selection.end.row -= 1;
}
point_selection.end.column = snapshot.line_len(point_selection.end.row);
}
let codegen_kind = if point_selection.start == point_selection.end {
CodegenKind::Generate {
position: snapshot.anchor_after(point_selection.start),
}
} else {
CodegenKind::Transform {
range: snapshot.anchor_before(point_selection.start)
..snapshot.anchor_after(point_selection.end),
}
};
let inline_assist_id = post_inc(&mut self.next_inline_assist_id); let inline_assist_id = post_inc(&mut self.next_inline_assist_id);
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
let provider = Arc::new(OpenAICompletionProvider::new( let provider = Arc::new(OpenAICompletionProvider::new(
api_key, api_key,
cx.background().clone(), cx.background().clone(),
)); ));
let codegen_kind = if editor.read(cx).selections.newest::<usize>(cx).is_empty() {
CodegenKind::Generate {
position: selection.start,
}
} else {
CodegenKind::Transform {
range: selection.start..selection.end,
}
};
let codegen = cx.add_model(|cx| { let codegen = cx.add_model(|cx| {
Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx) Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
}); });
@ -319,7 +333,7 @@ impl AssistantPanel {
editor.insert_blocks( editor.insert_blocks(
[BlockProperties { [BlockProperties {
style: BlockStyle::Flex, style: BlockStyle::Flex,
position: selection.head().bias_left(&snapshot), position: snapshot.anchor_before(point_selection.head()),
height: 2, height: 2,
render: Arc::new({ render: Arc::new({
let inline_assistant = inline_assistant.clone(); let inline_assistant = inline_assistant.clone();
@ -578,10 +592,7 @@ impl AssistantPanel {
let codegen_kind = codegen.read(cx).kind().clone(); let codegen_kind = codegen.read(cx).kind().clone();
let user_prompt = user_prompt.to_string(); let user_prompt = user_prompt.to_string();
let prompt = cx.background().spawn(async move {
let language_name = language_name.as_deref();
generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
});
let mut messages = Vec::new(); let mut messages = Vec::new();
let mut model = settings::get::<AssistantSettings>(cx) let mut model = settings::get::<AssistantSettings>(cx)
.default_open_ai_model .default_open_ai_model
@ -597,6 +608,11 @@ impl AssistantPanel {
model = conversation.model.clone(); model = conversation.model.clone();
} }
let prompt = cx.background().spawn(async move {
let language_name = language_name.as_deref();
generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
});
cx.spawn(|_, mut cx| async move { cx.spawn(|_, mut cx| async move {
let prompt = prompt.await; let prompt = prompt.await;

View file

@ -1,9 +1,7 @@
use crate::streaming_diff::{Hunk, StreamingDiff}; use crate::streaming_diff::{Hunk, StreamingDiff};
use ai::completion::{CompletionProvider, OpenAIRequest}; use ai::completion::{CompletionProvider, OpenAIRequest};
use anyhow::Result; use anyhow::Result;
use editor::{ use editor::{multi_buffer, Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
multi_buffer, Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
};
use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
use gpui::{Entity, ModelContext, ModelHandle, Task}; use gpui::{Entity, ModelContext, ModelHandle, Task};
use language::{Rope, TransactionId}; use language::{Rope, TransactionId};
@ -40,26 +38,11 @@ impl Entity for Codegen {
impl Codegen { impl Codegen {
pub fn new( pub fn new(
buffer: ModelHandle<MultiBuffer>, buffer: ModelHandle<MultiBuffer>,
mut kind: CodegenKind, kind: CodegenKind,
provider: Arc<dyn CompletionProvider>, provider: Arc<dyn CompletionProvider>,
cx: &mut ModelContext<Self>, cx: &mut ModelContext<Self>,
) -> Self { ) -> Self {
let snapshot = buffer.read(cx).snapshot(cx); let snapshot = buffer.read(cx).snapshot(cx);
match &mut kind {
CodegenKind::Transform { range } => {
let mut point_range = range.to_point(&snapshot);
point_range.start.column = 0;
if point_range.end.column > 0 || point_range.start.row == point_range.end.row {
point_range.end.column = snapshot.line_len(point_range.end.row);
}
range.start = snapshot.anchor_before(point_range.start);
range.end = snapshot.anchor_after(point_range.end);
}
CodegenKind::Generate { position } => {
*position = position.bias_right(&snapshot);
}
}
Self { Self {
provider, provider,
buffer: buffer.clone(), buffer: buffer.clone(),
@ -386,7 +369,7 @@ mod tests {
let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
let range = buffer.read_with(cx, |buffer, cx| { let range = buffer.read_with(cx, |buffer, cx| {
let snapshot = buffer.snapshot(cx); let snapshot = buffer.snapshot(cx);
snapshot.anchor_before(Point::new(1, 4))..snapshot.anchor_after(Point::new(4, 4)) snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5))
}); });
let provider = Arc::new(TestCompletionProvider::new()); let provider = Arc::new(TestCompletionProvider::new());
let codegen = cx.add_model(|cx| { let codegen = cx.add_model(|cx| {

View file

@ -4,6 +4,7 @@ use std::cmp::{self, Reverse};
use std::fmt::Write; use std::fmt::Write;
use std::ops::Range; use std::ops::Range;
#[allow(dead_code)]
fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> String { fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> String {
#[derive(Debug)] #[derive(Debug)]
struct Match { struct Match {
@ -121,6 +122,7 @@ pub fn generate_content_prompt(
range: Range<impl ToOffset>, range: Range<impl ToOffset>,
kind: CodegenKind, kind: CodegenKind,
) -> String { ) -> String {
let range = range.to_offset(buffer);
let mut prompt = String::new(); let mut prompt = String::new();
// General Preamble // General Preamble
@ -130,17 +132,29 @@ pub fn generate_content_prompt(
writeln!(prompt, "You're an expert engineer.\n").unwrap(); writeln!(prompt, "You're an expert engineer.\n").unwrap();
} }
let outline = summarize(buffer, range); let mut content = String::new();
content.extend(buffer.text_for_range(0..range.start));
if range.start == range.end {
content.push_str("<|START|>");
} else {
content.push_str("<|START|");
}
content.extend(buffer.text_for_range(range.clone()));
if range.start != range.end {
content.push_str("|END|>");
}
content.extend(buffer.text_for_range(range.end..buffer.len()));
writeln!( writeln!(
prompt, prompt,
"The file you are currently working on has the following outline:" "The file you are currently working on has the following content:"
) )
.unwrap(); .unwrap();
if let Some(language_name) = language_name { if let Some(language_name) = language_name {
let language_name = language_name.to_lowercase(); let language_name = language_name.to_lowercase();
writeln!(prompt, "```{language_name}\n{outline}\n```").unwrap(); writeln!(prompt, "```{language_name}\n{content}\n```").unwrap();
} else { } else {
writeln!(prompt, "```\n{outline}\n```").unwrap(); writeln!(prompt, "```\n{content}\n```").unwrap();
} }
match kind { match kind {

View file

@ -600,27 +600,30 @@ impl Room {
/// Returns the most 'active' projects, defined as most people in the project /// Returns the most 'active' projects, defined as most people in the project
pub fn most_active_project(&self) -> Option<(u64, u64)> { pub fn most_active_project(&self) -> Option<(u64, u64)> {
let mut projects = HashMap::default(); let mut project_hosts_and_guest_counts = HashMap::<u64, (Option<u64>, u32)>::default();
let mut hosts = HashMap::default();
for participant in self.remote_participants.values() { for participant in self.remote_participants.values() {
match participant.location { match participant.location {
ParticipantLocation::SharedProject { project_id } => { ParticipantLocation::SharedProject { project_id } => {
*projects.entry(project_id).or_insert(0) += 1; project_hosts_and_guest_counts
.entry(project_id)
.or_default()
.1 += 1;
} }
ParticipantLocation::External | ParticipantLocation::UnsharedProject => {} ParticipantLocation::External | ParticipantLocation::UnsharedProject => {}
} }
for project in &participant.projects { for project in &participant.projects {
*projects.entry(project.id).or_insert(0) += 1; project_hosts_and_guest_counts
hosts.insert(project.id, participant.user.id); .entry(project.id)
.or_default()
.0 = Some(participant.user.id);
} }
} }
let mut pairs: Vec<(u64, usize)> = projects.into_iter().collect(); project_hosts_and_guest_counts
pairs.sort_by_key(|(_, count)| *count as i32); .into_iter()
.filter_map(|(id, (host, guest_count))| Some((id, host?, guest_count)))
pairs .max_by_key(|(_, _, guest_count)| *guest_count)
.first() .map(|(id, host, _)| (id, host))
.map(|(project_id, _)| (*project_id, hosts[&project_id]))
} }
async fn handle_room_updated( async fn handle_room_updated(
@ -686,6 +689,7 @@ impl Room {
let Some(peer_id) = participant.peer_id else { let Some(peer_id) = participant.peer_id else {
continue; continue;
}; };
let participant_index = ParticipantIndex(participant.participant_index);
this.participant_user_ids.insert(participant.user_id); this.participant_user_ids.insert(participant.user_id);
let old_projects = this let old_projects = this
@ -736,8 +740,9 @@ impl Room {
if let Some(remote_participant) = if let Some(remote_participant) =
this.remote_participants.get_mut(&participant.user_id) this.remote_participants.get_mut(&participant.user_id)
{ {
remote_participant.projects = participant.projects;
remote_participant.peer_id = peer_id; remote_participant.peer_id = peer_id;
remote_participant.projects = participant.projects;
remote_participant.participant_index = participant_index;
if location != remote_participant.location { if location != remote_participant.location {
remote_participant.location = location; remote_participant.location = location;
cx.emit(Event::ParticipantLocationChanged { cx.emit(Event::ParticipantLocationChanged {
@ -749,9 +754,7 @@ impl Room {
participant.user_id, participant.user_id,
RemoteParticipant { RemoteParticipant {
user: user.clone(), user: user.clone(),
participant_index: ParticipantIndex( participant_index,
participant.participant_index,
),
peer_id, peer_id,
projects: participant.projects, projects: participant.projects,
location, location,

View file

@ -36,7 +36,7 @@ pub struct ChannelMessage {
pub nonce: u128, pub nonce: u128,
} }
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ChannelMessageId { pub enum ChannelMessageId {
Saved(u64), Saved(u64),
Pending(usize), Pending(usize),

View file

@ -70,7 +70,7 @@ pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100); pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100);
pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
actions!(client, [SignIn, SignOut]); actions!(client, [SignIn, SignOut, Reconnect]);
pub fn init_settings(cx: &mut AppContext) { pub fn init_settings(cx: &mut AppContext) {
settings::register::<TelemetrySettings>(cx); settings::register::<TelemetrySettings>(cx);
@ -102,6 +102,17 @@ pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
} }
} }
}); });
cx.add_global_action({
let client = client.clone();
move |_: &Reconnect, cx| {
if let Some(client) = client.upgrade() {
cx.spawn(|cx| async move {
client.reconnect(&cx);
})
.detach();
}
}
});
} }
pub struct Client { pub struct Client {
@ -1212,6 +1223,11 @@ impl Client {
self.set_status(Status::SignedOut, cx); self.set_status(Status::SignedOut, cx);
} }
pub fn reconnect(self: &Arc<Self>, cx: &AsyncAppContext) {
self.peer.teardown();
self.set_status(Status::ConnectionLost, cx);
}
fn connection_id(&self) -> Result<ConnectionId> { fn connection_id(&self) -> Result<ConnectionId> {
if let Status::Connected { connection_id, .. } = *self.status().borrow() { if let Status::Connected { connection_id, .. } = *self.status().borrow() {
Ok(connection_id) Ok(connection_id)

View file

@ -4,7 +4,9 @@ use lazy_static::lazy_static;
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Serialize; use serde::Serialize;
use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration}; use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration};
use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt}; use sysinfo::{
CpuRefreshKind, Pid, PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt,
};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use util::http::HttpClient; use util::http::HttpClient;
use util::{channel::ReleaseChannel, TryFutureExt}; use util::{channel::ReleaseChannel, TryFutureExt};
@ -18,7 +20,8 @@ pub struct Telemetry {
#[derive(Default)] #[derive(Default)]
struct TelemetryState { struct TelemetryState {
metrics_id: Option<Arc<str>>, // Per logged-in user metrics_id: Option<Arc<str>>, // Per logged-in user
installation_id: Option<Arc<str>>, // Per app installation installation_id: Option<Arc<str>>, // Per app installation (different for dev, preview, and stable)
session_id: Option<Arc<str>>, // Per app launch
app_version: Option<Arc<str>>, app_version: Option<Arc<str>>,
release_channel: Option<&'static str>, release_channel: Option<&'static str>,
os_name: &'static str, os_name: &'static str,
@ -41,6 +44,7 @@ lazy_static! {
struct ClickhouseEventRequestBody { struct ClickhouseEventRequestBody {
token: &'static str, token: &'static str,
installation_id: Option<Arc<str>>, installation_id: Option<Arc<str>>,
session_id: Option<Arc<str>>,
is_staff: Option<bool>, is_staff: Option<bool>,
app_version: Option<Arc<str>>, app_version: Option<Arc<str>>,
os_name: &'static str, os_name: &'static str,
@ -131,6 +135,7 @@ impl Telemetry {
release_channel, release_channel,
installation_id: None, installation_id: None,
metrics_id: None, metrics_id: None,
session_id: None,
clickhouse_events_queue: Default::default(), clickhouse_events_queue: Default::default(),
flush_clickhouse_events_task: Default::default(), flush_clickhouse_events_task: Default::default(),
log_file: None, log_file: None,
@ -145,9 +150,15 @@ impl Telemetry {
Some(self.state.lock().log_file.as_ref()?.path().to_path_buf()) Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
} }
pub fn start(self: &Arc<Self>, installation_id: Option<String>, cx: &mut AppContext) { pub fn start(
self: &Arc<Self>,
installation_id: Option<String>,
session_id: String,
cx: &mut AppContext,
) {
let mut state = self.state.lock(); let mut state = self.state.lock();
state.installation_id = installation_id.map(|id| id.into()); state.installation_id = installation_id.map(|id| id.into());
state.session_id = Some(session_id.into());
let has_clickhouse_events = !state.clickhouse_events_queue.is_empty(); let has_clickhouse_events = !state.clickhouse_events_queue.is_empty();
drop(state); drop(state);
@ -157,8 +168,16 @@ impl Telemetry {
let this = self.clone(); let this = self.clone();
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
let mut system = System::new_all(); // Avoiding calling `System::new_all()`, as there have been crashes related to it
system.refresh_all(); let refresh_kind = RefreshKind::new()
.with_memory() // For memory usage
.with_processes(ProcessRefreshKind::everything()) // For process usage
.with_cpu(CpuRefreshKind::everything()); // For core count
let mut system = System::new_with_specifics(refresh_kind);
// Avoiding calling `refresh_all()`, just update what we need
system.refresh_specifics(refresh_kind);
loop { loop {
// Waiting some amount of time before the first query is important to get a reasonable value // Waiting some amount of time before the first query is important to get a reasonable value
@ -166,8 +185,7 @@ impl Telemetry {
const DURATION_BETWEEN_SYSTEM_EVENTS: Duration = Duration::from_secs(60); const DURATION_BETWEEN_SYSTEM_EVENTS: Duration = Duration::from_secs(60);
smol::Timer::after(DURATION_BETWEEN_SYSTEM_EVENTS).await; smol::Timer::after(DURATION_BETWEEN_SYSTEM_EVENTS).await;
system.refresh_memory(); system.refresh_specifics(refresh_kind);
system.refresh_processes();
let current_process = Pid::from_u32(std::process::id()); let current_process = Pid::from_u32(std::process::id());
let Some(process) = system.processes().get(&current_process) else { let Some(process) = system.processes().get(&current_process) else {
@ -279,22 +297,21 @@ impl Telemetry {
{ {
let state = this.state.lock(); let state = this.state.lock();
json_bytes.clear(); let request_body = ClickhouseEventRequestBody {
serde_json::to_writer( token: ZED_SECRET_CLIENT_TOKEN,
&mut json_bytes, installation_id: state.installation_id.clone(),
&ClickhouseEventRequestBody { session_id: state.session_id.clone(),
token: ZED_SECRET_CLIENT_TOKEN, is_staff: state.is_staff.clone(),
installation_id: state.installation_id.clone(), app_version: state.app_version.clone(),
is_staff: state.is_staff.clone(), os_name: state.os_name,
app_version: state.app_version.clone(), os_version: state.os_version.clone(),
os_name: state.os_name, architecture: state.architecture,
os_version: state.os_version.clone(),
architecture: state.architecture,
release_channel: state.release_channel, release_channel: state.release_channel,
events, events,
}, };
)?; json_bytes.clear();
serde_json::to_writer(&mut json_bytes, &request_body)?;
} }
this.http_client this.http_client

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab" default-run = "collab"
edition = "2021" edition = "2021"
name = "collab" name = "collab"
version = "0.22.1" version = "0.22.2"
publish = false publish = false
[[bin]] [[bin]]

View file

@ -9,13 +9,3 @@ pub mod projects;
pub mod rooms; pub mod rooms;
pub mod servers; pub mod servers;
pub mod users; pub mod users;
fn max_assign<T: Ord>(max: &mut Option<T>, val: T) {
if let Some(max_val) = max {
if val > *max_val {
*max = Some(val);
}
} else {
*max = Some(val);
}
}

View file

@ -89,17 +89,14 @@ impl Database {
let mut rows = channel_message::Entity::find() let mut rows = channel_message::Entity::find()
.filter(condition) .filter(condition)
.order_by_asc(channel_message::Column::Id)
.limit(count as u64) .limit(count as u64)
.stream(&*tx) .stream(&*tx)
.await?; .await?;
let mut max_id = None;
let mut messages = Vec::new(); let mut messages = Vec::new();
while let Some(row) = rows.next().await { while let Some(row) = rows.next().await {
let row = row?; let row = row?;
max_assign(&mut max_id, row.id);
let nonce = row.nonce.as_u64_pair(); let nonce = row.nonce.as_u64_pair();
messages.push(proto::ChannelMessage { messages.push(proto::ChannelMessage {
id: row.id.to_proto(), id: row.id.to_proto(),
@ -113,50 +110,6 @@ impl Database {
}); });
} }
drop(rows); drop(rows);
if let Some(max_id) = max_id {
let has_older_message = observed_channel_messages::Entity::find()
.filter(
observed_channel_messages::Column::UserId
.eq(user_id)
.and(observed_channel_messages::Column::ChannelId.eq(channel_id))
.and(observed_channel_messages::Column::ChannelMessageId.lt(max_id)),
)
.one(&*tx)
.await?
.is_some();
if has_older_message {
observed_channel_messages::Entity::update(
observed_channel_messages::ActiveModel {
user_id: ActiveValue::Unchanged(user_id),
channel_id: ActiveValue::Unchanged(channel_id),
channel_message_id: ActiveValue::Set(max_id),
},
)
.exec(&*tx)
.await?;
} else {
observed_channel_messages::Entity::insert(
observed_channel_messages::ActiveModel {
user_id: ActiveValue::Set(user_id),
channel_id: ActiveValue::Set(channel_id),
channel_message_id: ActiveValue::Set(max_id),
},
)
.on_conflict(
OnConflict::columns([
observed_channel_messages::Column::UserId,
observed_channel_messages::Column::ChannelId,
])
.update_columns([observed_channel_messages::Column::ChannelMessageId])
.to_owned(),
)
.exec(&*tx)
.await?;
}
}
Ok(messages) Ok(messages)
}) })
.await .await

View file

@ -1906,13 +1906,10 @@ async fn follow(
.check_room_participants(room_id, leader_id, session.connection_id) .check_room_participants(room_id, leader_id, session.connection_id)
.await?; .await?;
let mut response_payload = session let response_payload = session
.peer .peer
.forward_request(session.connection_id, leader_id, request) .forward_request(session.connection_id, leader_id, request)
.await?; .await?;
response_payload
.views
.retain(|view| view.leader_id != Some(follower_id.into()));
response.send(response_payload)?; response.send(response_payload)?;
if let Some(project_id) = project_id { if let Some(project_id) = project_id {
@ -1973,14 +1970,17 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) ->
.await? .await?
}; };
let leader_id = request.variant.as_ref().and_then(|variant| match variant { // For now, don't send view update messages back to that view's current leader.
proto::update_followers::Variant::CreateView(payload) => payload.leader_id, let connection_id_to_omit = request.variant.as_ref().and_then(|variant| match variant {
proto::update_followers::Variant::UpdateView(payload) => payload.leader_id, proto::update_followers::Variant::UpdateView(payload) => payload.leader_id,
proto::update_followers::Variant::UpdateActiveView(payload) => payload.leader_id, _ => None,
}); });
for follower_peer_id in request.follower_ids.iter().copied() { for follower_peer_id in request.follower_ids.iter().copied() {
let follower_connection_id = follower_peer_id.into(); let follower_connection_id = follower_peer_id.into();
if Some(follower_peer_id) != leader_id && connection_ids.contains(&follower_connection_id) { if Some(follower_peer_id) != connection_id_to_omit
&& connection_ids.contains(&follower_connection_id)
{
session.peer.forward_send( session.peer.forward_send(
session.connection_id, session.connection_id,
follower_connection_id, follower_connection_id,

View file

@ -4,6 +4,7 @@ use collab_ui::project_shared_notification::ProjectSharedNotification;
use editor::{Editor, ExcerptRange, MultiBuffer}; use editor::{Editor, ExcerptRange, MultiBuffer};
use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle}; use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle};
use live_kit_client::MacOSDisplay; use live_kit_client::MacOSDisplay;
use rpc::proto::PeerId;
use serde_json::json; use serde_json::json;
use std::{borrow::Cow, sync::Arc}; use std::{borrow::Cow, sync::Arc};
use workspace::{ use workspace::{
@ -183,20 +184,12 @@ async fn test_basic_following(
// All clients see that clients B and C are following client A. // All clients see that clients B and C are following client A.
cx_c.foreground().run_until_parked(); cx_c.foreground().run_until_parked();
for (name, active_call, cx) in [ for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
("A", &active_call_a, &cx_a), assert_eq!(
("B", &active_call_b, &cx_b), followers_by_leader(project_id, cx),
("C", &active_call_c, &cx_c), &[(peer_id_a, vec![peer_id_b, peer_id_c])],
("D", &active_call_d, &cx_d), "followers seen by {name}"
] { );
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b, peer_id_c],
"checking followers for A as {name}"
);
});
} }
// Client C unfollows client A. // Client C unfollows client A.
@ -206,46 +199,39 @@ async fn test_basic_following(
// All clients see that clients B is following client A. // All clients see that clients B is following client A.
cx_c.foreground().run_until_parked(); cx_c.foreground().run_until_parked();
for (name, active_call, cx) in [ for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
("A", &active_call_a, &cx_a), assert_eq!(
("B", &active_call_b, &cx_b), followers_by_leader(project_id, cx),
("C", &active_call_c, &cx_c), &[(peer_id_a, vec![peer_id_b])],
("D", &active_call_d, &cx_d), "followers seen by {name}"
] { );
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b],
"checking followers for A as {name}"
);
});
} }
// Client C re-follows client A. // Client C re-follows client A.
workspace_c.update(cx_c, |workspace, cx| { workspace_c
workspace.follow(peer_id_a, cx); .update(cx_c, |workspace, cx| {
}); workspace.follow(peer_id_a, cx).unwrap()
})
.await
.unwrap();
// All clients see that clients B and C are following client A. // All clients see that clients B and C are following client A.
cx_c.foreground().run_until_parked(); cx_c.foreground().run_until_parked();
for (name, active_call, cx) in [ for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
("A", &active_call_a, &cx_a), assert_eq!(
("B", &active_call_b, &cx_b), followers_by_leader(project_id, cx),
("C", &active_call_c, &cx_c), &[(peer_id_a, vec![peer_id_b, peer_id_c])],
("D", &active_call_d, &cx_d), "followers seen by {name}"
] { );
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_a, project_id),
&[peer_id_b, peer_id_c],
"checking followers for A as {name}"
);
});
} }
// Client D follows client C. // Client D follows client B, then switches to following client C.
workspace_d
.update(cx_d, |workspace, cx| {
workspace.follow(peer_id_b, cx).unwrap()
})
.await
.unwrap();
workspace_d workspace_d
.update(cx_d, |workspace, cx| { .update(cx_d, |workspace, cx| {
workspace.follow(peer_id_c, cx).unwrap() workspace.follow(peer_id_c, cx).unwrap()
@ -255,20 +241,15 @@ async fn test_basic_following(
// All clients see that D is following C // All clients see that D is following C
cx_d.foreground().run_until_parked(); cx_d.foreground().run_until_parked();
for (name, active_call, cx) in [ for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
("A", &active_call_a, &cx_a), assert_eq!(
("B", &active_call_b, &cx_b), followers_by_leader(project_id, cx),
("C", &active_call_c, &cx_c), &[
("D", &active_call_d, &cx_d), (peer_id_a, vec![peer_id_b, peer_id_c]),
] { (peer_id_c, vec![peer_id_d])
active_call.read_with(*cx, |call, cx| { ],
let room = call.room().unwrap().read(cx); "followers seen by {name}"
assert_eq!( );
room.followers_for(peer_id_c, project_id),
&[peer_id_d],
"checking followers for C as {name}"
);
});
} }
// Client C closes the project. // Client C closes the project.
@ -277,32 +258,12 @@ async fn test_basic_following(
// Clients A and B see that client B is following A, and client C is not present in the followers. // Clients A and B see that client B is following A, and client C is not present in the followers.
cx_c.foreground().run_until_parked(); cx_c.foreground().run_until_parked();
for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] { for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
active_call.read_with(*cx, |call, cx| { assert_eq!(
let room = call.room().unwrap().read(cx); followers_by_leader(project_id, cx),
assert_eq!( &[(peer_id_a, vec![peer_id_b]),],
room.followers_for(peer_id_a, project_id), "followers seen by {name}"
&[peer_id_b], );
"checking followers for A as {name}"
);
});
}
// All clients see that no-one is following C
for (name, active_call, cx) in [
("A", &active_call_a, &cx_a),
("B", &active_call_b, &cx_b),
("C", &active_call_c, &cx_c),
("D", &active_call_d, &cx_d),
] {
active_call.read_with(*cx, |call, cx| {
let room = call.room().unwrap().read(cx);
assert_eq!(
room.followers_for(peer_id_c, project_id),
&[],
"checking followers for C as {name}"
);
});
} }
// When client A activates a different editor, client B does so as well. // When client A activates a different editor, client B does so as well.
@ -724,10 +685,9 @@ async fn test_peers_following_each_other(
.await .await
.unwrap(); .unwrap();
// Client A opens some editors. // Client A opens a file.
let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); workspace_a
let _editor_a1 = workspace_a
.update(cx_a, |workspace, cx| { .update(cx_a, |workspace, cx| {
workspace.open_path((worktree_id, "1.txt"), None, true, cx) workspace.open_path((worktree_id, "1.txt"), None, true, cx)
}) })
@ -736,10 +696,9 @@ async fn test_peers_following_each_other(
.downcast::<Editor>() .downcast::<Editor>()
.unwrap(); .unwrap();
// Client B opens an editor. // Client B opens a different file.
let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b);
let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); workspace_b
let _editor_b1 = workspace_b
.update(cx_b, |workspace, cx| { .update(cx_b, |workspace, cx| {
workspace.open_path((worktree_id, "2.txt"), None, true, cx) workspace.open_path((worktree_id, "2.txt"), None, true, cx)
}) })
@ -754,9 +713,7 @@ async fn test_peers_following_each_other(
}); });
workspace_a workspace_a
.update(cx_a, |workspace, cx| { .update(cx_a, |workspace, cx| {
assert_ne!(*workspace.active_pane(), pane_a1); workspace.follow(client_b.peer_id().unwrap(), cx).unwrap()
let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap();
workspace.follow(leader_id, cx).unwrap()
}) })
.await .await
.unwrap(); .unwrap();
@ -765,85 +722,443 @@ async fn test_peers_following_each_other(
}); });
workspace_b workspace_b
.update(cx_b, |workspace, cx| { .update(cx_b, |workspace, cx| {
assert_ne!(*workspace.active_pane(), pane_b1); workspace.follow(client_a.peer_id().unwrap(), cx).unwrap()
let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap();
workspace.follow(leader_id, cx).unwrap()
}) })
.await .await
.unwrap(); .unwrap();
workspace_a.update(cx_a, |workspace, cx| { // Clients A and B return focus to the original files they had open
workspace.activate_next_pane(cx); workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx));
}); workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx));
// Wait for focus effects to be fully flushed deterministic.run_until_parked();
workspace_a.update(cx_a, |workspace, _| {
assert_eq!(*workspace.active_pane(), pane_a1);
});
// Both clients see the other client's focused file in their right pane.
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: true,
leader: None,
items: vec![(true, "1.txt".into())]
},
PaneSummary {
active: false,
leader: client_b.peer_id(),
items: vec![(false, "1.txt".into()), (true, "2.txt".into())]
},
]
);
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: true,
leader: None,
items: vec![(true, "2.txt".into())]
},
PaneSummary {
active: false,
leader: client_a.peer_id(),
items: vec![(false, "2.txt".into()), (true, "1.txt".into())]
},
]
);
// Clients A and B each open a new file.
workspace_a workspace_a
.update(cx_a, |workspace, cx| { .update(cx_a, |workspace, cx| {
workspace.open_path((worktree_id, "3.txt"), None, true, cx) workspace.open_path((worktree_id, "3.txt"), None, true, cx)
}) })
.await .await
.unwrap(); .unwrap();
workspace_b.update(cx_b, |workspace, cx| {
workspace.activate_next_pane(cx);
});
workspace_b workspace_b
.update(cx_b, |workspace, cx| { .update(cx_b, |workspace, cx| {
assert_eq!(*workspace.active_pane(), pane_b1);
workspace.open_path((worktree_id, "4.txt"), None, true, cx) workspace.open_path((worktree_id, "4.txt"), None, true, cx)
}) })
.await .await
.unwrap(); .unwrap();
cx_a.foreground().run_until_parked(); deterministic.run_until_parked();
// Ensure leader updates don't change the active pane of followers // Both client's see the other client open the new file, but keep their
workspace_a.read_with(cx_a, |workspace, _| { // focus on their own active pane.
assert_eq!(*workspace.active_pane(), pane_a1);
});
workspace_b.read_with(cx_b, |workspace, _| {
assert_eq!(*workspace.active_pane(), pane_b1);
});
// Ensure peers following each other doesn't cause an infinite loop.
assert_eq!( assert_eq!(
workspace_a.read_with(cx_a, |workspace, cx| workspace pane_summaries(&workspace_a, cx_a),
.active_item(cx) &[
.unwrap() PaneSummary {
.project_path(cx)), active: true,
Some((worktree_id, "3.txt").into()) leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: false,
leader: client_b.peer_id(),
items: vec![
(false, "1.txt".into()),
(false, "2.txt".into()),
(true, "4.txt".into())
]
},
]
); );
workspace_a.update(cx_a, |workspace, cx| { assert_eq!(
assert_eq!( pane_summaries(&workspace_b, cx_b),
workspace.active_item(cx).unwrap().project_path(cx), &[
Some((worktree_id, "3.txt").into()) PaneSummary {
); active: true,
workspace.activate_next_pane(cx); leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: false,
leader: client_a.peer_id(),
items: vec![
(false, "2.txt".into()),
(false, "1.txt".into()),
(true, "3.txt".into())
]
},
]
);
// Client A focuses their right pane, in which they're following client B.
workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx));
deterministic.run_until_parked();
// Client B sees that client A is now looking at the same file as them.
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: client_b.peer_id(),
items: vec![
(false, "1.txt".into()),
(false, "2.txt".into()),
(true, "4.txt".into())
]
},
]
);
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: true,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: false,
leader: client_a.peer_id(),
items: vec![
(false, "2.txt".into()),
(false, "1.txt".into()),
(false, "3.txt".into()),
(true, "4.txt".into())
]
},
]
);
// Client B focuses their right pane, in which they're following client A,
// who is following them.
workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx));
deterministic.run_until_parked();
// Client A sees that client B is now looking at the same file as them.
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: true,
leader: client_a.peer_id(),
items: vec![
(false, "2.txt".into()),
(false, "1.txt".into()),
(false, "3.txt".into()),
(true, "4.txt".into())
]
},
]
);
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: client_b.peer_id(),
items: vec![
(false, "1.txt".into()),
(false, "2.txt".into()),
(true, "4.txt".into())
]
},
]
);
// Client B focuses a file that they previously followed A to, breaking
// the follow.
workspace_b.update(cx_b, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.activate_prev_item(true, cx);
});
}); });
deterministic.run_until_parked();
// Both clients see that client B is looking at that previous file.
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: true,
leader: None,
items: vec![
(false, "2.txt".into()),
(false, "1.txt".into()),
(true, "3.txt".into()),
(false, "4.txt".into())
]
},
]
);
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: client_b.peer_id(),
items: vec![
(false, "1.txt".into()),
(false, "2.txt".into()),
(false, "4.txt".into()),
(true, "3.txt".into()),
]
},
]
);
// Client B closes tabs, some of which were originally opened by client A,
// and some of which were originally opened by client B.
workspace_b.update(cx_b, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.close_inactive_items(&Default::default(), cx)
.unwrap()
.detach();
});
});
deterministic.run_until_parked();
// Both clients see that Client B is looking at the previous tab.
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: true,
leader: None,
items: vec![(true, "3.txt".into()),]
},
]
);
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: client_b.peer_id(),
items: vec![
(false, "1.txt".into()),
(false, "2.txt".into()),
(false, "4.txt".into()),
(true, "3.txt".into()),
]
},
]
);
// Client B follows client A again.
workspace_b
.update(cx_b, |workspace, cx| {
workspace.follow(client_a.peer_id().unwrap(), cx).unwrap()
})
.await
.unwrap();
// Client A cycles through some tabs.
workspace_a.update(cx_a, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.activate_prev_item(true, cx);
});
});
deterministic.run_until_parked();
// Client B follows client A into those tabs.
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: None,
items: vec![
(false, "1.txt".into()),
(false, "2.txt".into()),
(true, "4.txt".into()),
(false, "3.txt".into()),
]
},
]
);
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: true,
leader: client_a.peer_id(),
items: vec![(false, "3.txt".into()), (true, "4.txt".into())]
},
]
);
workspace_a.update(cx_a, |workspace, cx| { workspace_a.update(cx_a, |workspace, cx| {
assert_eq!( workspace.active_pane().update(cx, |pane, cx| {
workspace.active_item(cx).unwrap().project_path(cx), pane.activate_prev_item(true, cx);
Some((worktree_id, "4.txt").into()) });
);
}); });
deterministic.run_until_parked();
workspace_b.update(cx_b, |workspace, cx| { assert_eq!(
assert_eq!( pane_summaries(&workspace_a, cx_a),
workspace.active_item(cx).unwrap().project_path(cx), &[
Some((worktree_id, "4.txt").into()) PaneSummary {
); active: false,
workspace.activate_next_pane(cx); leader: None,
}); items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: None,
items: vec![
(false, "1.txt".into()),
(true, "2.txt".into()),
(false, "4.txt".into()),
(false, "3.txt".into()),
]
},
]
);
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: true,
leader: client_a.peer_id(),
items: vec![
(false, "3.txt".into()),
(false, "4.txt".into()),
(true, "2.txt".into())
]
},
]
);
workspace_b.update(cx_b, |workspace, cx| { workspace_a.update(cx_a, |workspace, cx| {
assert_eq!( workspace.active_pane().update(cx, |pane, cx| {
workspace.active_item(cx).unwrap().project_path(cx), pane.activate_prev_item(true, cx);
Some((worktree_id, "3.txt").into()) });
);
}); });
deterministic.run_until_parked();
assert_eq!(
pane_summaries(&workspace_a, cx_a),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "1.txt".into()), (true, "3.txt".into())]
},
PaneSummary {
active: true,
leader: None,
items: vec![
(true, "1.txt".into()),
(false, "2.txt".into()),
(false, "4.txt".into()),
(false, "3.txt".into()),
]
},
]
);
assert_eq!(
pane_summaries(&workspace_b, cx_b),
&[
PaneSummary {
active: false,
leader: None,
items: vec![(false, "2.txt".into()), (true, "4.txt".into())]
},
PaneSummary {
active: true,
leader: client_a.peer_id(),
items: vec![
(false, "3.txt".into()),
(false, "4.txt".into()),
(false, "2.txt".into()),
(true, "1.txt".into()),
]
},
]
);
} }
#[gpui::test(iterations = 10)] #[gpui::test(iterations = 10)]
@ -1074,24 +1389,6 @@ async fn test_peers_simultaneously_following_each_other(
}); });
} }
fn visible_push_notifications(
cx: &mut TestAppContext,
) -> Vec<gpui::ViewHandle<ProjectSharedNotification>> {
let mut ret = Vec::new();
for window in cx.windows() {
window.read_with(cx, |window| {
if let Some(handle) = window
.root_view()
.clone()
.downcast::<ProjectSharedNotification>()
{
ret.push(handle)
}
});
}
ret
}
#[gpui::test(iterations = 10)] #[gpui::test(iterations = 10)]
async fn test_following_across_workspaces( async fn test_following_across_workspaces(
deterministic: Arc<Deterministic>, deterministic: Arc<Deterministic>,
@ -1304,3 +1601,83 @@ async fn test_following_across_workspaces(
assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("y.rs")); assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("y.rs"));
}); });
} }
fn visible_push_notifications(
cx: &mut TestAppContext,
) -> Vec<gpui::ViewHandle<ProjectSharedNotification>> {
let mut ret = Vec::new();
for window in cx.windows() {
window.read_with(cx, |window| {
if let Some(handle) = window
.root_view()
.clone()
.downcast::<ProjectSharedNotification>()
{
ret.push(handle)
}
});
}
ret
}
#[derive(Debug, PartialEq, Eq)]
struct PaneSummary {
active: bool,
leader: Option<PeerId>,
items: Vec<(bool, String)>,
}
fn followers_by_leader(project_id: u64, cx: &TestAppContext) -> Vec<(PeerId, Vec<PeerId>)> {
cx.read(|cx| {
let active_call = ActiveCall::global(cx).read(cx);
let peer_id = active_call.client().peer_id();
let room = active_call.room().unwrap().read(cx);
let mut result = room
.remote_participants()
.values()
.map(|participant| participant.peer_id)
.chain(peer_id)
.filter_map(|peer_id| {
let followers = room.followers_for(peer_id, project_id);
if followers.is_empty() {
None
} else {
Some((peer_id, followers.to_vec()))
}
})
.collect::<Vec<_>>();
result.sort_by_key(|e| e.0);
result
})
}
fn pane_summaries(workspace: &ViewHandle<Workspace>, cx: &mut TestAppContext) -> Vec<PaneSummary> {
workspace.read_with(cx, |workspace, cx| {
let active_pane = workspace.active_pane();
workspace
.panes()
.iter()
.map(|pane| {
let leader = workspace.leader_for_pane(pane);
let active = pane == active_pane;
let pane = pane.read(cx);
let active_ix = pane.active_item_index();
PaneSummary {
active,
leader,
items: pane
.items()
.enumerate()
.map(|(ix, item)| {
(
ix == active_ix,
item.tab_description(0, cx)
.map_or(String::new(), |s| s.to_string()),
)
})
.collect(),
}
})
.collect()
})
}

View file

@ -37,6 +37,7 @@ fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" } gpui = { path = "../gpui" }
language = { path = "../language" } language = { path = "../language" }
menu = { path = "../menu" } menu = { path = "../menu" }
rich_text = { path = "../rich_text" }
picker = { path = "../picker" } picker = { path = "../picker" }
project = { path = "../project" } project = { path = "../project" }
recent_projects = {path = "../recent_projects"} recent_projects = {path = "../recent_projects"}

View file

@ -3,6 +3,7 @@ use anyhow::Result;
use call::ActiveCall; use call::ActiveCall;
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
use client::Client; use client::Client;
use collections::HashMap;
use db::kvp::KEY_VALUE_STORE; use db::kvp::KEY_VALUE_STORE;
use editor::Editor; use editor::Editor;
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
@ -12,12 +13,13 @@ use gpui::{
platform::{CursorStyle, MouseButton}, platform::{CursorStyle, MouseButton},
serde_json, serde_json,
views::{ItemType, Select, SelectStyle}, views::{ItemType, Select, SelectStyle},
AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task,
ViewContext, ViewHandle, WeakViewHandle, View, ViewContext, ViewHandle, WeakViewHandle,
}; };
use language::language_settings::SoftWrap; use language::{language_settings::SoftWrap, LanguageRegistry};
use menu::Confirm; use menu::Confirm;
use project::Fs; use project::Fs;
use rich_text::RichText;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::SettingsStore; use settings::SettingsStore;
use std::sync::Arc; use std::sync::Arc;
@ -35,6 +37,7 @@ const CHAT_PANEL_KEY: &'static str = "ChatPanel";
pub struct ChatPanel { pub struct ChatPanel {
client: Arc<Client>, client: Arc<Client>,
channel_store: ModelHandle<ChannelStore>, channel_store: ModelHandle<ChannelStore>,
languages: Arc<LanguageRegistry>,
active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>, active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
message_list: ListState<ChatPanel>, message_list: ListState<ChatPanel>,
input_editor: ViewHandle<Editor>, input_editor: ViewHandle<Editor>,
@ -47,6 +50,7 @@ pub struct ChatPanel {
subscriptions: Vec<gpui::Subscription>, subscriptions: Vec<gpui::Subscription>,
workspace: WeakViewHandle<Workspace>, workspace: WeakViewHandle<Workspace>,
has_focus: bool, has_focus: bool,
markdown_data: HashMap<ChannelMessageId, RichText>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -78,6 +82,7 @@ impl ChatPanel {
let fs = workspace.app_state().fs.clone(); let fs = workspace.app_state().fs.clone();
let client = workspace.app_state().client.clone(); let client = workspace.app_state().client.clone();
let channel_store = workspace.app_state().channel_store.clone(); let channel_store = workspace.app_state().channel_store.clone();
let languages = workspace.app_state().languages.clone();
let input_editor = cx.add_view(|cx| { let input_editor = cx.add_view(|cx| {
let mut editor = Editor::auto_height( let mut editor = Editor::auto_height(
@ -130,6 +135,8 @@ impl ChatPanel {
fs, fs,
client, client,
channel_store, channel_store,
languages,
active_chat: Default::default(), active_chat: Default::default(),
pending_serialization: Task::ready(None), pending_serialization: Task::ready(None),
message_list, message_list,
@ -141,6 +148,7 @@ impl ChatPanel {
workspace: workspace_handle, workspace: workspace_handle,
active: false, active: false,
width: None, width: None,
markdown_data: Default::default(),
}; };
let mut old_dock_position = this.position(cx); let mut old_dock_position = this.position(cx);
@ -177,6 +185,25 @@ impl ChatPanel {
}) })
.detach(); .detach();
let markdown = this.languages.language_for_name("Markdown");
cx.spawn(|this, mut cx| async move {
let markdown = markdown.await?;
this.update(&mut cx, |this, cx| {
this.input_editor.update(cx, |editor, cx| {
editor.buffer().update(cx, |multi_buffer, cx| {
multi_buffer
.as_singleton()
.unwrap()
.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
})
})
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
this this
}) })
} }
@ -327,13 +354,33 @@ impl ChatPanel {
messages.flex(1., true).into_any() messages.flex(1., true).into_any()
} }
fn render_message(&self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> { fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let message = self.active_chat.as_ref().unwrap().0.read(cx).message(ix); let (message, is_continuation, is_last) = {
let active_chat = self.active_chat.as_ref().unwrap().0.read(cx);
let last_message = active_chat.message(ix.saturating_sub(1));
let this_message = active_chat.message(ix);
let is_continuation = last_message.id != this_message.id
&& this_message.sender.id == last_message.sender.id;
(
active_chat.message(ix).clone(),
is_continuation,
active_chat.message_count() == ix + 1,
)
};
let is_pending = message.is_pending();
let text = self
.markdown_data
.entry(message.id)
.or_insert_with(|| rich_text::render_markdown(message.body, &self.languages, None));
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let theme = theme::current(cx); let theme = theme::current(cx);
let style = if message.is_pending() { let style = if is_pending {
&theme.chat_panel.pending_message &theme.chat_panel.pending_message
} else if is_continuation {
&theme.chat_panel.continuation_message
} else { } else {
&theme.chat_panel.message &theme.chat_panel.message
}; };
@ -346,52 +393,90 @@ impl ChatPanel {
None None
}; };
enum DeleteMessage {} enum MessageBackgroundHighlight {}
MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
let body = message.body.clone(); let container = style.container.style_for(state);
Flex::column() if is_continuation {
.with_child(
Flex::row() Flex::row()
.with_child( .with_child(
Label::new( text.element(
message.sender.github_login.clone(), theme.editor.syntax.clone(),
style.sender.text.clone(), style.body.clone(),
theme.editor.document_highlight_read_background,
cx,
) )
.contained() .flex(1., true),
.with_style(style.sender.container), )
.with_child(render_remove(message_id_to_remove, cx, &theme))
.contained()
.with_style(*container)
.with_margin_bottom(if is_last {
theme.chat_panel.last_message_bottom_spacing
} else {
0.
})
.into_any()
} else {
Flex::column()
.with_child(
Flex::row()
.with_child(
Flex::row()
.with_child(render_avatar(
message.sender.avatar.clone(),
&theme,
))
.with_child(
Label::new(
message.sender.github_login.clone(),
style.sender.text.clone(),
)
.contained()
.with_style(style.sender.container),
)
.with_child(
Label::new(
format_timestamp(
message.timestamp,
now,
self.local_timezone,
),
style.timestamp.text.clone(),
)
.contained()
.with_style(style.timestamp.container),
)
.align_children_center()
.flex(1., true),
)
.with_child(render_remove(message_id_to_remove, cx, &theme))
.align_children_center(),
) )
.with_child( .with_child(
Label::new( Flex::row()
format_timestamp(message.timestamp, now, self.local_timezone), .with_child(
style.timestamp.text.clone(), text.element(
) theme.editor.syntax.clone(),
.contained() style.body.clone(),
.with_style(style.timestamp.container), theme.editor.document_highlight_read_background,
cx,
)
.flex(1., true),
)
// Add a spacer to make everything line up
.with_child(render_remove(None, cx, &theme)),
) )
.with_children(message_id_to_remove.map(|id| { .contained()
MouseEventHandler::new::<DeleteMessage, _>( .with_style(*container)
id as usize, .with_margin_bottom(if is_last {
cx, theme.chat_panel.last_message_bottom_spacing
|mouse_state, _| { } else {
let button_style = 0.
theme.chat_panel.icon_button.style_for(mouse_state); })
render_icon_button(button_style, "icons/x.svg") .into_any()
.aligned() }
.into_any() })
}, .into_any()
)
.with_padding(Padding::uniform(2.))
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.remove_message(id, cx);
})
.flex_float()
})),
)
.with_child(Text::new(body, style.body.clone()))
.contained()
.with_style(style.container)
.into_any()
} }
fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> { fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
@ -565,6 +650,7 @@ impl ChatPanel {
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
let chat = open_chat.await?; let chat = open_chat.await?;
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
this.markdown_data = Default::default();
this.set_active_chat(chat, cx); this.set_active_chat(chat, cx);
}) })
}) })
@ -589,6 +675,72 @@ impl ChatPanel {
} }
} }
fn render_avatar(avatar: Option<Arc<ImageData>>, theme: &Arc<Theme>) -> AnyElement<ChatPanel> {
let avatar_style = theme.chat_panel.avatar;
avatar
.map(|avatar| {
Image::from_data(avatar)
.with_style(avatar_style.image)
.aligned()
.contained()
.with_corner_radius(avatar_style.outer_corner_radius)
.constrained()
.with_width(avatar_style.outer_width)
.with_height(avatar_style.outer_width)
.into_any()
})
.unwrap_or_else(|| {
Empty::new()
.constrained()
.with_width(avatar_style.outer_width)
.into_any()
})
.contained()
.with_style(theme.chat_panel.avatar_container)
.into_any()
}
fn render_remove(
message_id_to_remove: Option<u64>,
cx: &mut ViewContext<'_, '_, ChatPanel>,
theme: &Arc<Theme>,
) -> AnyElement<ChatPanel> {
enum DeleteMessage {}
message_id_to_remove
.map(|id| {
MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
render_icon_button(button_style, "icons/x.svg")
.aligned()
.into_any()
})
.with_padding(Padding::uniform(2.))
.with_cursor_style(CursorStyle::PointingHand)
.on_click(MouseButton::Left, move |_, this, cx| {
this.remove_message(id, cx);
})
.flex_float()
.into_any()
})
.unwrap_or_else(|| {
let style = theme.chat_panel.icon_button.default;
Empty::new()
.constrained()
.with_width(style.icon_width)
.aligned()
.constrained()
.with_width(style.button_width)
.with_height(style.button_width)
.contained()
.with_uniform_padding(2.)
.flex_float()
.into_any()
})
}
impl Entity for ChatPanel { impl Entity for ChatPanel {
type Event = Event; type Event = Event;
} }

View file

@ -1937,6 +1937,8 @@ impl CollabPanel {
is_dragged_over = true; is_dragged_over = true;
} }
let has_messages_notification = channel.unseen_message_id.is_some();
MouseEventHandler::new::<Channel, _>(ix, cx, |state, cx| { MouseEventHandler::new::<Channel, _>(ix, cx, |state, cx| {
let row_hovered = state.hovered(); let row_hovered = state.hovered();
@ -1974,11 +1976,7 @@ impl CollabPanel {
.left() .left()
.with_tooltip::<ChannelTooltip>( .with_tooltip::<ChannelTooltip>(
ix, ix,
if is_active { "Join channel",
"Open channel notes"
} else {
"Join channel"
},
None, None,
theme.tooltip.clone(), theme.tooltip.clone(),
cx, cx,
@ -2022,24 +2020,33 @@ impl CollabPanel {
.flex(1., true) .flex(1., true)
}) })
.with_child( .with_child(
MouseEventHandler::new::<ChannelNote, _>(ix, cx, move |_, _| { MouseEventHandler::new::<ChannelNote, _>(ix, cx, move |mouse_state, _| {
let container_style = collab_theme
.disclosure
.button
.style_for(mouse_state)
.container;
if channel.unseen_message_id.is_some() { if channel.unseen_message_id.is_some() {
Svg::new("icons/conversations.svg") Svg::new("icons/conversations.svg")
.with_color(collab_theme.channel_note_active_color) .with_color(collab_theme.channel_note_active_color)
.constrained() .constrained()
.with_width(collab_theme.channel_hash.width) .with_width(collab_theme.channel_hash.width)
.contained()
.with_style(container_style)
.with_uniform_padding(4.)
.into_any() .into_any()
} else if row_hovered { } else if row_hovered {
Svg::new("icons/conversations.svg") Svg::new("icons/conversations.svg")
.with_color(collab_theme.channel_hash.color) .with_color(collab_theme.channel_hash.color)
.constrained() .constrained()
.with_width(collab_theme.channel_hash.width) .with_width(collab_theme.channel_hash.width)
.contained()
.with_style(container_style)
.with_uniform_padding(4.)
.into_any() .into_any()
} else { } else {
Empty::new() Empty::new().into_any()
.constrained()
.with_width(collab_theme.channel_hash.width)
.into_any()
} }
}) })
.on_click(MouseButton::Left, move |_, this, cx| { .on_click(MouseButton::Left, move |_, this, cx| {
@ -2056,7 +2063,12 @@ impl CollabPanel {
.with_margin_right(4.), .with_margin_right(4.),
) )
.with_child( .with_child(
MouseEventHandler::new::<ChannelCall, _>(ix, cx, move |_, cx| { MouseEventHandler::new::<ChannelCall, _>(ix, cx, move |mouse_state, cx| {
let container_style = collab_theme
.disclosure
.button
.style_for(mouse_state)
.container;
if row_hovered || channel.unseen_note_version.is_some() { if row_hovered || channel.unseen_note_version.is_some() {
Svg::new("icons/file.svg") Svg::new("icons/file.svg")
.with_color(if channel.unseen_note_version.is_some() { .with_color(if channel.unseen_note_version.is_some() {
@ -2067,6 +2079,8 @@ impl CollabPanel {
.constrained() .constrained()
.with_width(collab_theme.channel_hash.width) .with_width(collab_theme.channel_hash.width)
.contained() .contained()
.with_style(container_style)
.with_uniform_padding(4.)
.with_margin_right(collab_theme.channel_hash.container.margin.left) .with_margin_right(collab_theme.channel_hash.container.margin.left)
.with_tooltip::<NotesTooltip>( .with_tooltip::<NotesTooltip>(
ix as usize, ix as usize,
@ -2076,23 +2090,20 @@ impl CollabPanel {
cx, cx,
) )
.into_any() .into_any()
} else { } else if has_messages_notification {
Empty::new() Empty::new()
.constrained() .constrained()
.with_width(collab_theme.channel_hash.width) .with_width(collab_theme.channel_hash.width)
.contained() .contained()
.with_uniform_padding(4.)
.with_margin_right(collab_theme.channel_hash.container.margin.left) .with_margin_right(collab_theme.channel_hash.container.margin.left)
.into_any() .into_any()
} else {
Empty::new().into_any()
} }
}) })
.on_click(MouseButton::Left, move |_, this, cx| { .on_click(MouseButton::Left, move |_, this, cx| {
let participants = this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
this.channel_store.read(cx).channel_participants(channel_id);
if is_active || participants.is_empty() {
this.open_channel_notes(&OpenChannelNotes { channel_id }, cx);
} else {
this.join_channel(channel_id, cx);
};
}), }),
) )
.align_children_center() .align_children_center()

View file

@ -36,6 +36,7 @@ language = { path = "../language" }
lsp = { path = "../lsp" } lsp = { path = "../lsp" }
project = { path = "../project" } project = { path = "../project" }
rpc = { path = "../rpc" } rpc = { path = "../rpc" }
rich_text = { path = "../rich_text" }
settings = { path = "../settings" } settings = { path = "../settings" }
snippet = { path = "../snippet" } snippet = { path = "../snippet" }
sum_tree = { path = "../sum_tree" } sum_tree = { path = "../sum_tree" }

View file

@ -8,12 +8,12 @@ use futures::FutureExt;
use gpui::{ use gpui::{
actions, actions,
elements::{Flex, MouseEventHandler, Padding, ParentElement, Text}, elements::{Flex, MouseEventHandler, Padding, ParentElement, Text},
fonts::{HighlightStyle, Underline, Weight},
platform::{CursorStyle, MouseButton}, platform::{CursorStyle, MouseButton},
AnyElement, AppContext, CursorRegion, Element, ModelHandle, MouseRegion, Task, ViewContext, AnyElement, AppContext, Element, ModelHandle, Task, ViewContext,
}; };
use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry}; use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry};
use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project};
use rich_text::{new_paragraph, render_code, render_markdown_mut, RichText};
use std::{ops::Range, sync::Arc, time::Duration}; use std::{ops::Range, sync::Arc, time::Duration};
use util::TryFutureExt; use util::TryFutureExt;
@ -346,158 +346,25 @@ fn show_hover(
} }
fn render_blocks( fn render_blocks(
theme_id: usize,
blocks: &[HoverBlock], blocks: &[HoverBlock],
language_registry: &Arc<LanguageRegistry>, language_registry: &Arc<LanguageRegistry>,
language: Option<&Arc<Language>>, language: Option<&Arc<Language>>,
style: &EditorStyle, ) -> RichText {
) -> RenderedInfo { let mut data = RichText {
let mut text = String::new(); text: Default::default(),
let mut highlights = Vec::new(); highlights: Default::default(),
let mut region_ranges = Vec::new(); region_ranges: Default::default(),
let mut regions = Vec::new(); regions: Default::default(),
};
for block in blocks { for block in blocks {
match &block.kind { match &block.kind {
HoverBlockKind::PlainText => { HoverBlockKind::PlainText => {
new_paragraph(&mut text, &mut Vec::new()); new_paragraph(&mut data.text, &mut Vec::new());
text.push_str(&block.text); data.text.push_str(&block.text);
} }
HoverBlockKind::Markdown => { HoverBlockKind::Markdown => {
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; render_markdown_mut(&block.text, language_registry, language, &mut data)
let mut bold_depth = 0;
let mut italic_depth = 0;
let mut link_url = None;
let mut current_language = None;
let mut list_stack = Vec::new();
for event in Parser::new_ext(&block.text, Options::all()) {
let prev_len = text.len();
match event {
Event::Text(t) => {
if let Some(language) = &current_language {
render_code(
&mut text,
&mut highlights,
t.as_ref(),
language,
style,
);
} else {
text.push_str(t.as_ref());
let mut style = HighlightStyle::default();
if bold_depth > 0 {
style.weight = Some(Weight::BOLD);
}
if italic_depth > 0 {
style.italic = Some(true);
}
if let Some(link_url) = link_url.clone() {
region_ranges.push(prev_len..text.len());
regions.push(RenderedRegion {
link_url: Some(link_url),
code: false,
});
style.underline = Some(Underline {
thickness: 1.0.into(),
..Default::default()
});
}
if style != HighlightStyle::default() {
let mut new_highlight = true;
if let Some((last_range, last_style)) = highlights.last_mut() {
if last_range.end == prev_len && last_style == &style {
last_range.end = text.len();
new_highlight = false;
}
}
if new_highlight {
highlights.push((prev_len..text.len(), style));
}
}
}
}
Event::Code(t) => {
text.push_str(t.as_ref());
region_ranges.push(prev_len..text.len());
if link_url.is_some() {
highlights.push((
prev_len..text.len(),
HighlightStyle {
underline: Some(Underline {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
},
));
}
regions.push(RenderedRegion {
code: true,
link_url: link_url.clone(),
});
}
Event::Start(tag) => match tag {
Tag::Paragraph => new_paragraph(&mut text, &mut list_stack),
Tag::Heading(_, _, _) => {
new_paragraph(&mut text, &mut list_stack);
bold_depth += 1;
}
Tag::CodeBlock(kind) => {
new_paragraph(&mut text, &mut list_stack);
current_language = if let CodeBlockKind::Fenced(language) = kind {
language_registry
.language_for_name(language.as_ref())
.now_or_never()
.and_then(Result::ok)
} else {
language.cloned()
}
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Link(_, url, _) => link_url = Some(url.to_string()),
Tag::List(number) => {
list_stack.push((number, false));
}
Tag::Item => {
let len = list_stack.len();
if let Some((list_number, has_content)) = list_stack.last_mut() {
*has_content = false;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
for _ in 0..len - 1 {
text.push_str(" ");
}
if let Some(number) = list_number {
text.push_str(&format!("{}. ", number));
*number += 1;
*has_content = false;
} else {
text.push_str("- ");
}
}
}
_ => {}
},
Event::End(tag) => match tag {
Tag::Heading(_, _, _) => bold_depth -= 1,
Tag::CodeBlock(_) => current_language = None,
Tag::Emphasis => italic_depth -= 1,
Tag::Strong => bold_depth -= 1,
Tag::Link(_, _, _) => link_url = None,
Tag::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::HardBreak => text.push('\n'),
Event::SoftBreak => text.push(' '),
_ => {}
}
}
} }
HoverBlockKind::Code { language } => { HoverBlockKind::Code { language } => {
if let Some(language) = language_registry if let Some(language) = language_registry
@ -505,62 +372,17 @@ fn render_blocks(
.now_or_never() .now_or_never()
.and_then(Result::ok) .and_then(Result::ok)
{ {
render_code(&mut text, &mut highlights, &block.text, &language, style); render_code(&mut data.text, &mut data.highlights, &block.text, &language);
} else { } else {
text.push_str(&block.text); data.text.push_str(&block.text);
} }
} }
} }
} }
RenderedInfo { data.text = data.text.trim().to_string();
theme_id,
text: text.trim().to_string(),
highlights,
region_ranges,
regions,
}
}
fn render_code( data
text: &mut String,
highlights: &mut Vec<(Range<usize>, HighlightStyle)>,
content: &str,
language: &Arc<Language>,
style: &EditorStyle,
) {
let prev_len = text.len();
text.push_str(content);
for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
if let Some(style) = highlight_id.style(&style.syntax) {
highlights.push((prev_len + range.start..prev_len + range.end, style));
}
}
}
fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
} else {
*has_content = true;
return;
}
}
if !text.is_empty() {
if !text.ends_with('\n') {
text.push('\n');
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
} }
#[derive(Default)] #[derive(Default)]
@ -623,22 +445,7 @@ pub struct InfoPopover {
symbol_range: RangeInEditor, symbol_range: RangeInEditor,
pub blocks: Vec<HoverBlock>, pub blocks: Vec<HoverBlock>,
language: Option<Arc<Language>>, language: Option<Arc<Language>>,
rendered_content: Option<RenderedInfo>, rendered_content: Option<RichText>,
}
#[derive(Debug, Clone)]
struct RenderedInfo {
theme_id: usize,
text: String,
highlights: Vec<(Range<usize>, HighlightStyle)>,
region_ranges: Vec<Range<usize>>,
regions: Vec<RenderedRegion>,
}
#[derive(Debug, Clone)]
struct RenderedRegion {
code: bool,
link_url: Option<String>,
} }
impl InfoPopover { impl InfoPopover {
@ -647,63 +454,24 @@ impl InfoPopover {
style: &EditorStyle, style: &EditorStyle,
cx: &mut ViewContext<Editor>, cx: &mut ViewContext<Editor>,
) -> AnyElement<Editor> { ) -> AnyElement<Editor> {
if let Some(rendered) = &self.rendered_content {
if rendered.theme_id != style.theme_id {
self.rendered_content = None;
}
}
let rendered_content = self.rendered_content.get_or_insert_with(|| { let rendered_content = self.rendered_content.get_or_insert_with(|| {
render_blocks( render_blocks(
style.theme_id,
&self.blocks, &self.blocks,
self.project.read(cx).languages(), self.project.read(cx).languages(),
self.language.as_ref(), self.language.as_ref(),
style,
) )
}); });
MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| { MouseEventHandler::new::<InfoPopover, _>(0, cx, move |_, cx| {
let mut region_id = 0;
let view_id = cx.view_id();
let code_span_background_color = style.document_highlight_read_background; let code_span_background_color = style.document_highlight_read_background;
let regions = rendered_content.regions.clone();
Flex::column() Flex::column()
.scrollable::<HoverBlock>(1, None, cx) .scrollable::<HoverBlock>(1, None, cx)
.with_child( .with_child(rendered_content.element(
Text::new(rendered_content.text.clone(), style.text.clone()) style.syntax.clone(),
.with_highlights(rendered_content.highlights.clone()) style.text.clone(),
.with_custom_runs( code_span_background_color,
rendered_content.region_ranges.clone(), cx,
move |ix, bounds, cx| { ))
region_id += 1;
let region = regions[ix].clone();
if let Some(url) = region.link_url {
cx.scene().push_cursor_region(CursorRegion {
bounds,
style: CursorStyle::PointingHand,
});
cx.scene().push_mouse_region(
MouseRegion::new::<Self>(view_id, region_id, bounds)
.on_click::<Editor, _>(
MouseButton::Left,
move |_, _, cx| cx.platform().open_url(&url),
),
);
}
if region.code {
cx.scene().push_quad(gpui::Quad {
bounds,
background: Some(code_span_background_color),
border: Default::default(),
corner_radii: (2.0).into(),
});
}
},
)
.with_soft_wrap(true),
)
.contained() .contained()
.with_style(style.hover_popover.container) .with_style(style.hover_popover.container)
}) })
@ -799,11 +567,12 @@ mod tests {
InlayId, InlayId,
}; };
use collections::BTreeSet; use collections::BTreeSet;
use gpui::fonts::Weight; use gpui::fonts::{HighlightStyle, Underline, Weight};
use indoc::indoc; use indoc::indoc;
use language::{language_settings::InlayHintSettings, Diagnostic, DiagnosticSet}; use language::{language_settings::InlayHintSettings, Diagnostic, DiagnosticSet};
use lsp::LanguageServerId; use lsp::LanguageServerId;
use project::{HoverBlock, HoverBlockKind}; use project::{HoverBlock, HoverBlockKind};
use rich_text::Highlight;
use smol::stream::StreamExt; use smol::stream::StreamExt;
use unindent::Unindent; use unindent::Unindent;
use util::test::marked_text_ranges; use util::test::marked_text_ranges;
@ -1014,7 +783,7 @@ mod tests {
.await; .await;
cx.condition(|editor, _| editor.hover_state.visible()).await; cx.condition(|editor, _| editor.hover_state.visible()).await;
cx.editor(|editor, cx| { cx.editor(|editor, _| {
let blocks = editor.hover_state.info_popover.clone().unwrap().blocks; let blocks = editor.hover_state.info_popover.clone().unwrap().blocks;
assert_eq!( assert_eq!(
blocks, blocks,
@ -1024,8 +793,7 @@ mod tests {
}], }],
); );
let style = editor.style(cx); let rendered = render_blocks(&blocks, &Default::default(), None);
let rendered = render_blocks(0, &blocks, &Default::default(), None, &style);
assert_eq!( assert_eq!(
rendered.text, rendered.text,
code_str.trim(), code_str.trim(),
@ -1217,7 +985,7 @@ mod tests {
expected_styles, expected_styles,
} in &rows[0..] } in &rows[0..]
{ {
let rendered = render_blocks(0, &blocks, &Default::default(), None, &style); let rendered = render_blocks(&blocks, &Default::default(), None);
let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false); let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false);
let expected_highlights = ranges let expected_highlights = ranges
@ -1228,8 +996,21 @@ mod tests {
rendered.text, expected_text, rendered.text, expected_text,
"wrong text for input {blocks:?}" "wrong text for input {blocks:?}"
); );
let rendered_highlights: Vec<_> = rendered
.highlights
.iter()
.filter_map(|(range, highlight)| {
let style = match highlight {
Highlight::Id(id) => id.style(&style.syntax)?,
Highlight::Highlight(style) => style.clone(),
};
Some((range.clone(), style))
})
.collect();
assert_eq!( assert_eq!(
rendered.highlights, expected_highlights, rendered_highlights, expected_highlights,
"wrong highlights for input {blocks:?}" "wrong highlights for input {blocks:?}"
); );
} }

View file

@ -107,13 +107,23 @@ fn matching_history_item_paths(
) -> HashMap<Arc<Path>, PathMatch> { ) -> HashMap<Arc<Path>, PathMatch> {
let history_items_by_worktrees = history_items let history_items_by_worktrees = history_items
.iter() .iter()
.map(|found_path| { .filter_map(|found_path| {
let path = &found_path.project.path;
let candidate = PathMatchCandidate { let candidate = PathMatchCandidate {
path, path: &found_path.project.path,
char_bag: CharBag::from_iter(path.to_string_lossy().to_lowercase().chars()), // Only match history items names, otherwise their paths may match too many queries, producing false positives.
// E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
// it would be shown first always, despite the latter being a better match.
char_bag: CharBag::from_iter(
found_path
.project
.path
.file_name()?
.to_string_lossy()
.to_lowercase()
.chars(),
),
}; };
(found_path.project.worktree_id, candidate) Some((found_path.project.worktree_id, candidate))
}) })
.fold( .fold(
HashMap::default(), HashMap::default(),
@ -212,6 +222,10 @@ fn toggle_or_cycle_file_finder(
.as_ref() .as_ref()
.and_then(|found_path| found_path.absolute.as_ref()) .and_then(|found_path| found_path.absolute.as_ref())
}) })
.filter(|(_, history_abs_path)| match history_abs_path {
Some(abs_path) => history_file_exists(abs_path),
None => true,
})
.map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)), .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
) )
.collect(); .collect();
@ -236,6 +250,16 @@ fn toggle_or_cycle_file_finder(
} }
} }
#[cfg(not(test))]
fn history_file_exists(abs_path: &PathBuf) -> bool {
abs_path.exists()
}
#[cfg(test)]
fn history_file_exists(abs_path: &PathBuf) -> bool {
!abs_path.ends_with("nonexistent.rs")
}
pub enum Event { pub enum Event {
Selected(ProjectPath), Selected(ProjectPath),
Dismissed, Dismissed,
@ -505,12 +529,7 @@ impl PickerDelegate for FileFinderDelegate {
project project
.worktree_for_id(history_item.project.worktree_id, cx) .worktree_for_id(history_item.project.worktree_id, cx)
.is_some() .is_some()
|| (project.is_local() || (project.is_local() && history_item.absolute.is_some())
&& history_item
.absolute
.as_ref()
.filter(|abs_path| abs_path.exists())
.is_some())
}) })
.cloned() .cloned()
.map(|p| (p, None)) .map(|p| (p, None))
@ -1803,6 +1822,202 @@ mod tests {
}); });
} }
#[gpui::test]
async fn test_history_items_vs_very_good_external_match(
deterministic: Arc<gpui::executor::Deterministic>,
cx: &mut gpui::TestAppContext,
) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
"/src",
json!({
"collab_ui": {
"first.rs": "// First Rust file",
"second.rs": "// Second Rust file",
"third.rs": "// Third Rust file",
"collab_ui.rs": "// Fourth Rust file",
}
}),
)
.await;
let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
let window = cx.add_window(|cx| Workspace::test_new(project, cx));
let workspace = window.root(cx);
// generate some history to select from
open_close_queried_buffer(
"fir",
1,
"first.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
open_close_queried_buffer(
"sec",
1,
"second.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
open_close_queried_buffer(
"thi",
1,
"third.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
open_close_queried_buffer(
"sec",
1,
"second.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
cx.dispatch_action(window.into(), Toggle);
let query = "collab_ui";
let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
finder
.update(cx, |finder, cx| {
finder.delegate_mut().update_matches(query.to_string(), cx)
})
.await;
finder.read_with(cx, |finder, _| {
let delegate = finder.delegate();
assert!(
delegate.matches.history.is_empty(),
"History items should not math query {query}, they should be matched by name only"
);
let search_entries = delegate
.matches
.search
.iter()
.map(|path_match| path_match.path.to_path_buf())
.collect::<Vec<_>>();
assert_eq!(
search_entries,
vec![
PathBuf::from("collab_ui/collab_ui.rs"),
PathBuf::from("collab_ui/third.rs"),
PathBuf::from("collab_ui/first.rs"),
PathBuf::from("collab_ui/second.rs"),
],
"Despite all search results having the same directory name, the most matching one should be on top"
);
});
}
#[gpui::test]
async fn test_nonexistent_history_items_not_shown(
deterministic: Arc<gpui::executor::Deterministic>,
cx: &mut gpui::TestAppContext,
) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
"/src",
json!({
"test": {
"first.rs": "// First Rust file",
"nonexistent.rs": "// Second Rust file",
"third.rs": "// Third Rust file",
}
}),
)
.await;
let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
let window = cx.add_window(|cx| Workspace::test_new(project, cx));
let workspace = window.root(cx);
// generate some history to select from
open_close_queried_buffer(
"fir",
1,
"first.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
open_close_queried_buffer(
"non",
1,
"nonexistent.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
open_close_queried_buffer(
"thi",
1,
"third.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
open_close_queried_buffer(
"fir",
1,
"first.rs",
window.into(),
&workspace,
&deterministic,
cx,
)
.await;
cx.dispatch_action(window.into(), Toggle);
let query = "rs";
let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
finder
.update(cx, |finder, cx| {
finder.delegate_mut().update_matches(query.to_string(), cx)
})
.await;
finder.read_with(cx, |finder, _| {
let delegate = finder.delegate();
let history_entries = delegate
.matches
.history
.iter()
.map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
.collect::<Vec<_>>();
assert_eq!(
history_entries,
vec![
PathBuf::from("test/first.rs"),
PathBuf::from("test/third.rs"),
],
"Should have all opened files in the history, except the ones that do not exist on disk"
);
});
}
async fn open_close_queried_buffer( async fn open_close_queried_buffer(
input: &str, input: &str,
expected_matches: usize, expected_matches: usize,

View file

@ -441,7 +441,7 @@ mod tests {
score, score,
worktree_id: 0, worktree_id: 0,
positions: Vec::new(), positions: Vec::new(),
path: candidate.path.clone(), path: Arc::from(candidate.path),
path_prefix: "".into(), path_prefix: "".into(),
distance_to_relative_ancestor: usize::MAX, distance_to_relative_ancestor: usize::MAX,
}, },

View file

@ -14,7 +14,7 @@ use crate::{
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PathMatchCandidate<'a> { pub struct PathMatchCandidate<'a> {
pub path: &'a Arc<Path>, pub path: &'a Path,
pub char_bag: CharBag, pub char_bag: CharBag,
} }
@ -120,7 +120,7 @@ pub fn match_fixed_path_set(
score, score,
worktree_id, worktree_id,
positions: Vec::new(), positions: Vec::new(),
path: candidate.path.clone(), path: Arc::from(candidate.path),
path_prefix: Arc::from(""), path_prefix: Arc::from(""),
distance_to_relative_ancestor: usize::MAX, distance_to_relative_ancestor: usize::MAX,
}, },
@ -195,7 +195,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
score, score,
worktree_id, worktree_id,
positions: Vec::new(), positions: Vec::new(),
path: candidate.path.clone(), path: Arc::from(candidate.path),
path_prefix: candidate_set.prefix(), path_prefix: candidate_set.prefix(),
distance_to_relative_ancestor: relative_to.as_ref().map_or( distance_to_relative_ancestor: relative_to.as_ref().map_or(
usize::MAX, usize::MAX,

View file

@ -0,0 +1,30 @@
[package]
name = "rich_text"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
path = "src/rich_text.rs"
doctest = false
[features]
test-support = [
"gpui/test-support",
"util/test-support",
]
[dependencies]
collections = { path = "../collections" }
gpui = { path = "../gpui" }
sum_tree = { path = "../sum_tree" }
theme = { path = "../theme" }
language = { path = "../language" }
util = { path = "../util" }
anyhow.workspace = true
futures.workspace = true
lazy_static.workspace = true
pulldown-cmark = { version = "0.9.2", default-features = false }
smallvec.workspace = true
smol.workspace = true

View file

@ -0,0 +1,287 @@
use std::{ops::Range, sync::Arc};
use futures::FutureExt;
use gpui::{
color::Color,
elements::Text,
fonts::{HighlightStyle, TextStyle, Underline, Weight},
platform::{CursorStyle, MouseButton},
AnyElement, CursorRegion, Element, MouseRegion, ViewContext,
};
use language::{HighlightId, Language, LanguageRegistry};
use theme::SyntaxTheme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Id(HighlightId),
Highlight(HighlightStyle),
}
#[derive(Debug, Clone)]
pub struct RichText {
pub text: String,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub region_ranges: Vec<Range<usize>>,
pub regions: Vec<RenderedRegion>,
}
#[derive(Debug, Clone)]
pub struct RenderedRegion {
code: bool,
link_url: Option<String>,
}
impl RichText {
pub fn element<V: 'static>(
&self,
syntax: Arc<SyntaxTheme>,
style: TextStyle,
code_span_background_color: Color,
cx: &mut ViewContext<V>,
) -> AnyElement<V> {
let mut region_id = 0;
let view_id = cx.view_id();
let regions = self.regions.clone();
enum Markdown {}
Text::new(self.text.clone(), style.clone())
.with_highlights(
self.highlights
.iter()
.filter_map(|(range, highlight)| {
let style = match highlight {
Highlight::Id(id) => id.style(&syntax)?,
Highlight::Highlight(style) => style.clone(),
};
Some((range.clone(), style))
})
.collect::<Vec<_>>(),
)
.with_custom_runs(self.region_ranges.clone(), move |ix, bounds, cx| {
region_id += 1;
let region = regions[ix].clone();
if let Some(url) = region.link_url {
cx.scene().push_cursor_region(CursorRegion {
bounds,
style: CursorStyle::PointingHand,
});
cx.scene().push_mouse_region(
MouseRegion::new::<Markdown>(view_id, region_id, bounds)
.on_click::<V, _>(MouseButton::Left, move |_, _, cx| {
cx.platform().open_url(&url)
}),
);
}
if region.code {
cx.scene().push_quad(gpui::Quad {
bounds,
background: Some(code_span_background_color),
border: Default::default(),
corner_radii: (2.0).into(),
});
}
})
.with_soft_wrap(true)
.into_any()
}
}
pub fn render_markdown_mut(
block: &str,
language_registry: &Arc<LanguageRegistry>,
language: Option<&Arc<Language>>,
data: &mut RichText,
) {
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
let mut bold_depth = 0;
let mut italic_depth = 0;
let mut link_url = None;
let mut current_language = None;
let mut list_stack = Vec::new();
for event in Parser::new_ext(&block, Options::all()) {
let prev_len = data.text.len();
match event {
Event::Text(t) => {
if let Some(language) = &current_language {
render_code(&mut data.text, &mut data.highlights, t.as_ref(), language);
} else {
data.text.push_str(t.as_ref());
let mut style = HighlightStyle::default();
if bold_depth > 0 {
style.weight = Some(Weight::BOLD);
}
if italic_depth > 0 {
style.italic = Some(true);
}
if let Some(link_url) = link_url.clone() {
data.region_ranges.push(prev_len..data.text.len());
data.regions.push(RenderedRegion {
link_url: Some(link_url),
code: false,
});
style.underline = Some(Underline {
thickness: 1.0.into(),
..Default::default()
});
}
if style != HighlightStyle::default() {
let mut new_highlight = true;
if let Some((last_range, last_style)) = data.highlights.last_mut() {
if last_range.end == prev_len
&& last_style == &Highlight::Highlight(style)
{
last_range.end = data.text.len();
new_highlight = false;
}
}
if new_highlight {
data.highlights
.push((prev_len..data.text.len(), Highlight::Highlight(style)));
}
}
}
}
Event::Code(t) => {
data.text.push_str(t.as_ref());
data.region_ranges.push(prev_len..data.text.len());
if link_url.is_some() {
data.highlights.push((
prev_len..data.text.len(),
Highlight::Highlight(HighlightStyle {
underline: Some(Underline {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
}
data.regions.push(RenderedRegion {
code: true,
link_url: link_url.clone(),
});
}
Event::Start(tag) => match tag {
Tag::Paragraph => new_paragraph(&mut data.text, &mut list_stack),
Tag::Heading(_, _, _) => {
new_paragraph(&mut data.text, &mut list_stack);
bold_depth += 1;
}
Tag::CodeBlock(kind) => {
new_paragraph(&mut data.text, &mut list_stack);
current_language = if let CodeBlockKind::Fenced(language) = kind {
language_registry
.language_for_name(language.as_ref())
.now_or_never()
.and_then(Result::ok)
} else {
language.cloned()
}
}
Tag::Emphasis => italic_depth += 1,
Tag::Strong => bold_depth += 1,
Tag::Link(_, url, _) => link_url = Some(url.to_string()),
Tag::List(number) => {
list_stack.push((number, false));
}
Tag::Item => {
let len = list_stack.len();
if let Some((list_number, has_content)) = list_stack.last_mut() {
*has_content = false;
if !data.text.is_empty() && !data.text.ends_with('\n') {
data.text.push('\n');
}
for _ in 0..len - 1 {
data.text.push_str(" ");
}
if let Some(number) = list_number {
data.text.push_str(&format!("{}. ", number));
*number += 1;
*has_content = false;
} else {
data.text.push_str("- ");
}
}
}
_ => {}
},
Event::End(tag) => match tag {
Tag::Heading(_, _, _) => bold_depth -= 1,
Tag::CodeBlock(_) => current_language = None,
Tag::Emphasis => italic_depth -= 1,
Tag::Strong => bold_depth -= 1,
Tag::Link(_, _, _) => link_url = None,
Tag::List(_) => drop(list_stack.pop()),
_ => {}
},
Event::HardBreak => data.text.push('\n'),
Event::SoftBreak => data.text.push(' '),
_ => {}
}
}
}
pub fn render_markdown(
block: String,
language_registry: &Arc<LanguageRegistry>,
language: Option<&Arc<Language>>,
) -> RichText {
let mut data = RichText {
text: Default::default(),
highlights: Default::default(),
region_ranges: Default::default(),
regions: Default::default(),
};
render_markdown_mut(&block, language_registry, language, &mut data);
data.text = data.text.trim().to_string();
data
}
pub fn render_code(
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
content: &str,
language: &Arc<Language>,
) {
let prev_len = text.len();
text.push_str(content);
for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
highlights.push((
prev_len + range.start..prev_len + range.end,
Highlight::Id(highlight_id),
));
}
}
pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
let mut is_subsequent_paragraph_of_list = false;
if let Some((_, has_content)) = list_stack.last_mut() {
if *has_content {
is_subsequent_paragraph_of_list = true;
} else {
*has_content = true;
return;
}
}
if !text.is_empty() {
if !text.ends_with('\n') {
text.push('\n');
}
text.push('\n');
}
for _ in 0..list_stack.len().saturating_sub(1) {
text.push_str(" ");
}
if is_subsequent_paragraph_of_list {
text.push_str(" ");
}
}

View file

@ -634,7 +634,11 @@ pub struct ChatPanel {
pub list: ContainerStyle, pub list: ContainerStyle,
pub channel_select: ChannelSelect, pub channel_select: ChannelSelect,
pub input_editor: FieldEditor, pub input_editor: FieldEditor,
pub avatar: AvatarStyle,
pub avatar_container: ContainerStyle,
pub message: ChatMessage, pub message: ChatMessage,
pub continuation_message: ChatMessage,
pub last_message_bottom_spacing: f32,
pub pending_message: ChatMessage, pub pending_message: ChatMessage,
pub sign_in_prompt: Interactive<TextStyle>, pub sign_in_prompt: Interactive<TextStyle>,
pub icon_button: Interactive<IconButton>, pub icon_button: Interactive<IconButton>,
@ -643,7 +647,7 @@ pub struct ChatPanel {
#[derive(Deserialize, Default, JsonSchema)] #[derive(Deserialize, Default, JsonSchema)]
pub struct ChatMessage { pub struct ChatMessage {
#[serde(flatten)] #[serde(flatten)]
pub container: ContainerStyle, pub container: Interactive<ContainerStyle>,
pub body: TextStyle, pub body: TextStyle,
pub sender: ContainedText, pub sender: ContainedText,
pub timestamp: ContainedText, pub timestamp: ContainedText,

View file

@ -139,6 +139,12 @@ impl<P> PathLikeWithPosition<P> {
column: None, column: None,
}) })
} else { } else {
let maybe_col_str =
if maybe_col_str.ends_with(FILE_ROW_COLUMN_DELIMITER) {
&maybe_col_str[..maybe_col_str.len() - 1]
} else {
maybe_col_str
};
match maybe_col_str.parse::<u32>() { match maybe_col_str.parse::<u32>() {
Ok(col) => Ok(Self { Ok(col) => Ok(Self {
path_like: parse_path_like_str(path_like_str)?, path_like: parse_path_like_str(path_like_str)?,
@ -241,7 +247,6 @@ mod tests {
"test_file.rs:1::", "test_file.rs:1::",
"test_file.rs::1:2", "test_file.rs::1:2",
"test_file.rs:1::2", "test_file.rs:1::2",
"test_file.rs:1:2:",
"test_file.rs:1:2:3", "test_file.rs:1:2:3",
] { ] {
let actual = parse_str(input); let actual = parse_str(input);
@ -277,6 +282,14 @@ mod tests {
column: None, column: None,
}, },
), ),
(
"crates/file_finder/src/file_finder.rs:1902:13:",
PathLikeWithPosition {
path_like: "crates/file_finder/src/file_finder.rs".to_string(),
row: Some(1902),
column: Some(13),
},
),
]; ];
for (input, expected) in input_and_expected { for (input, expected) in input_and_expected {

View file

@ -78,10 +78,14 @@ fn increment(vim: &mut Vim, mut delta: i32, step: i32, cx: &mut WindowContext) {
2 => format!("{:b}", result), 2 => format!("{:b}", result),
_ => unreachable!(), _ => unreachable!(),
}; };
if selection.is_empty() { edits.push((range.clone(), replace));
new_anchors.push((false, snapshot.anchor_after(range.end))) }
} if selection.is_empty() {
edits.push((range, replace)); new_anchors.push((false, snapshot.anchor_after(range.end)))
}
} else {
if selection.is_empty() {
new_anchors.push((true, snapshot.anchor_after(start)))
} }
} }
} }
@ -226,6 +230,8 @@ mod test {
cx.assert_matches_neovim("(ˇ0b10f)", ["ctrl-a"], "(0b1ˇ1f)") cx.assert_matches_neovim("(ˇ0b10f)", ["ctrl-a"], "(0b1ˇ1f)")
.await; .await;
cx.assert_matches_neovim("ˇ-1", ["ctrl-a"], "ˇ0").await; cx.assert_matches_neovim("ˇ-1", ["ctrl-a"], "ˇ0").await;
cx.assert_matches_neovim("banˇana", ["ctrl-a"], "banˇana")
.await;
} }
#[gpui::test] #[gpui::test]

View file

@ -13,3 +13,6 @@
{"Put":{"state":"ˇ-1"}} {"Put":{"state":"ˇ-1"}}
{"Key":"ctrl-a"} {"Key":"ctrl-a"}
{"Get":{"state":"ˇ0","mode":"Normal"}} {"Get":{"state":"ˇ0","mode":"Normal"}}
{"Put":{"state":"banˇana"}}
{"Key":"ctrl-a"}
{"Get":{"state":"banˇana","mode":"Normal"}}

View file

@ -1,10 +1,7 @@
use std::{cell::RefCell, rc::Rc, sync::Arc}; use crate::{pane_group::element::PaneAxisElement, AppState, FollowerState, Pane, Workspace};
use crate::{
pane_group::element::PaneAxisElement, AppState, FollowerStatesByLeader, Pane, Workspace,
};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use call::{ActiveCall, ParticipantLocation}; use call::{ActiveCall, ParticipantLocation};
use collections::HashMap;
use gpui::{ use gpui::{
elements::*, elements::*,
geometry::{rect::RectF, vector::Vector2F}, geometry::{rect::RectF, vector::Vector2F},
@ -13,6 +10,7 @@ use gpui::{
}; };
use project::Project; use project::Project;
use serde::Deserialize; use serde::Deserialize;
use std::{cell::RefCell, rc::Rc, sync::Arc};
use theme::Theme; use theme::Theme;
const HANDLE_HITBOX_SIZE: f32 = 4.0; const HANDLE_HITBOX_SIZE: f32 = 4.0;
@ -95,7 +93,7 @@ impl PaneGroup {
&self, &self,
project: &ModelHandle<Project>, project: &ModelHandle<Project>,
theme: &Theme, theme: &Theme,
follower_states: &FollowerStatesByLeader, follower_states: &HashMap<ViewHandle<Pane>, FollowerState>,
active_call: Option<&ModelHandle<ActiveCall>>, active_call: Option<&ModelHandle<ActiveCall>>,
active_pane: &ViewHandle<Pane>, active_pane: &ViewHandle<Pane>,
zoomed: Option<&AnyViewHandle>, zoomed: Option<&AnyViewHandle>,
@ -162,7 +160,7 @@ impl Member {
project: &ModelHandle<Project>, project: &ModelHandle<Project>,
basis: usize, basis: usize,
theme: &Theme, theme: &Theme,
follower_states: &FollowerStatesByLeader, follower_states: &HashMap<ViewHandle<Pane>, FollowerState>,
active_call: Option<&ModelHandle<ActiveCall>>, active_call: Option<&ModelHandle<ActiveCall>>,
active_pane: &ViewHandle<Pane>, active_pane: &ViewHandle<Pane>,
zoomed: Option<&AnyViewHandle>, zoomed: Option<&AnyViewHandle>,
@ -179,19 +177,10 @@ impl Member {
ChildView::new(pane, cx).into_any() ChildView::new(pane, cx).into_any()
}; };
let leader = follower_states let leader = follower_states.get(pane).and_then(|state| {
.iter() let room = active_call?.read(cx).room()?.read(cx);
.find_map(|(leader_id, follower_states)| { room.remote_participant_for_peer_id(state.leader_id)
if follower_states.contains_key(pane) { });
Some(leader_id)
} else {
None
}
})
.and_then(|leader_id| {
let room = active_call?.read(cx).room()?.read(cx);
room.remote_participant_for_peer_id(*leader_id)
});
let mut leader_border = Border::default(); let mut leader_border = Border::default();
let mut leader_status_box = None; let mut leader_status_box = None;
@ -486,7 +475,7 @@ impl PaneAxis {
project: &ModelHandle<Project>, project: &ModelHandle<Project>,
basis: usize, basis: usize,
theme: &Theme, theme: &Theme,
follower_state: &FollowerStatesByLeader, follower_states: &HashMap<ViewHandle<Pane>, FollowerState>,
active_call: Option<&ModelHandle<ActiveCall>>, active_call: Option<&ModelHandle<ActiveCall>>,
active_pane: &ViewHandle<Pane>, active_pane: &ViewHandle<Pane>,
zoomed: Option<&AnyViewHandle>, zoomed: Option<&AnyViewHandle>,
@ -515,7 +504,7 @@ impl PaneAxis {
project, project,
(basis + ix) * 10, (basis + ix) * 10,
theme, theme,
follower_state, follower_states,
active_call, active_call,
active_pane, active_pane,
zoomed, zoomed,

View file

@ -79,7 +79,7 @@ use status_bar::StatusBar;
pub use status_bar::StatusItemView; pub use status_bar::StatusItemView;
use theme::{Theme, ThemeSettings}; use theme::{Theme, ThemeSettings};
pub use toolbar::{ToolbarItemLocation, ToolbarItemView}; pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
use util::{async_iife, ResultExt}; use util::ResultExt;
pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings}; pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings};
lazy_static! { lazy_static! {
@ -573,11 +573,12 @@ pub struct Workspace {
panes_by_item: HashMap<usize, WeakViewHandle<Pane>>, panes_by_item: HashMap<usize, WeakViewHandle<Pane>>,
active_pane: ViewHandle<Pane>, active_pane: ViewHandle<Pane>,
last_active_center_pane: Option<WeakViewHandle<Pane>>, last_active_center_pane: Option<WeakViewHandle<Pane>>,
last_active_view_id: Option<proto::ViewId>,
status_bar: ViewHandle<StatusBar>, status_bar: ViewHandle<StatusBar>,
titlebar_item: Option<AnyViewHandle>, titlebar_item: Option<AnyViewHandle>,
notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>, notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
project: ModelHandle<Project>, project: ModelHandle<Project>,
follower_states_by_leader: FollowerStatesByLeader, follower_states: HashMap<ViewHandle<Pane>, FollowerState>,
last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>, last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
window_edited: bool, window_edited: bool,
active_call: Option<(ModelHandle<ActiveCall>, Vec<Subscription>)>, active_call: Option<(ModelHandle<ActiveCall>, Vec<Subscription>)>,
@ -602,10 +603,9 @@ pub struct ViewId {
pub id: u64, pub id: u64,
} }
type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
#[derive(Default)] #[derive(Default)]
struct FollowerState { struct FollowerState {
leader_id: PeerId,
active_view_id: Option<ViewId>, active_view_id: Option<ViewId>,
items_by_leader_view_id: HashMap<ViewId, Box<dyn FollowableItemHandle>>, items_by_leader_view_id: HashMap<ViewId, Box<dyn FollowableItemHandle>>,
} }
@ -786,6 +786,7 @@ impl Workspace {
panes_by_item: Default::default(), panes_by_item: Default::default(),
active_pane: center_pane.clone(), active_pane: center_pane.clone(),
last_active_center_pane: Some(center_pane.downgrade()), last_active_center_pane: Some(center_pane.downgrade()),
last_active_view_id: None,
status_bar, status_bar,
titlebar_item: None, titlebar_item: None,
notifications: Default::default(), notifications: Default::default(),
@ -793,7 +794,7 @@ impl Workspace {
bottom_dock, bottom_dock,
right_dock, right_dock,
project: project.clone(), project: project.clone(),
follower_states_by_leader: Default::default(), follower_states: Default::default(),
last_leaders_by_pane: Default::default(), last_leaders_by_pane: Default::default(),
window_edited: false, window_edited: false,
active_call, active_call,
@ -934,7 +935,8 @@ impl Workspace {
app_state, app_state,
cx, cx,
) )
.await; .await
.unwrap_or_default();
(workspace, opened_items) (workspace, opened_items)
}) })
@ -2510,13 +2512,16 @@ impl Workspace {
} }
fn collaborator_left(&mut self, peer_id: PeerId, cx: &mut ViewContext<Self>) { fn collaborator_left(&mut self, peer_id: PeerId, cx: &mut ViewContext<Self>) {
if let Some(states_by_pane) = self.follower_states_by_leader.remove(&peer_id) { self.follower_states.retain(|_, state| {
for state in states_by_pane.into_values() { if state.leader_id == peer_id {
for item in state.items_by_leader_view_id.into_values() { for item in state.items_by_leader_view_id.values() {
item.set_leader_peer_id(None, cx); item.set_leader_peer_id(None, cx);
} }
false
} else {
true
} }
} });
cx.notify(); cx.notify();
} }
@ -2529,10 +2534,15 @@ impl Workspace {
self.last_leaders_by_pane self.last_leaders_by_pane
.insert(pane.downgrade(), leader_id); .insert(pane.downgrade(), leader_id);
self.follower_states_by_leader self.unfollow(&pane, cx);
.entry(leader_id) self.follower_states.insert(
.or_default() pane.clone(),
.insert(pane.clone(), Default::default()); FollowerState {
leader_id,
active_view_id: None,
items_by_leader_view_id: Default::default(),
},
);
cx.notify(); cx.notify();
let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
@ -2547,9 +2557,8 @@ impl Workspace {
let response = request.await?; let response = request.await?;
this.update(&mut cx, |this, _| { this.update(&mut cx, |this, _| {
let state = this let state = this
.follower_states_by_leader .follower_states
.get_mut(&leader_id) .get_mut(&pane)
.and_then(|states_by_pane| states_by_pane.get_mut(&pane))
.ok_or_else(|| anyhow!("following interrupted"))?; .ok_or_else(|| anyhow!("following interrupted"))?;
state.active_view_id = if let Some(active_view_id) = response.active_view_id { state.active_view_id = if let Some(active_view_id) = response.active_view_id {
Some(ViewId::from_proto(active_view_id)?) Some(ViewId::from_proto(active_view_id)?)
@ -2644,12 +2653,10 @@ impl Workspace {
} }
// if you're already following, find the right pane and focus it. // if you're already following, find the right pane and focus it.
for (existing_leader_id, states_by_pane) in &mut self.follower_states_by_leader { for (pane, state) in &self.follower_states {
if leader_id == *existing_leader_id { if leader_id == state.leader_id {
for (pane, _) in states_by_pane { cx.focus(pane);
cx.focus(pane); return None;
return None;
}
} }
} }
@ -2662,36 +2669,37 @@ impl Workspace {
pane: &ViewHandle<Pane>, pane: &ViewHandle<Pane>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Option<PeerId> { ) -> Option<PeerId> {
for (leader_id, states_by_pane) in &mut self.follower_states_by_leader { let state = self.follower_states.remove(pane)?;
let leader_id = *leader_id; let leader_id = state.leader_id;
if let Some(state) = states_by_pane.remove(pane) { for (_, item) in state.items_by_leader_view_id {
for (_, item) in state.items_by_leader_view_id { item.set_leader_peer_id(None, cx);
item.set_leader_peer_id(None, cx);
}
if states_by_pane.is_empty() {
self.follower_states_by_leader.remove(&leader_id);
let project_id = self.project.read(cx).remote_id();
let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
self.app_state
.client
.send(proto::Unfollow {
room_id,
project_id,
leader_id: Some(leader_id),
})
.log_err();
}
cx.notify();
return Some(leader_id);
}
} }
None
if self
.follower_states
.values()
.all(|state| state.leader_id != state.leader_id)
{
let project_id = self.project.read(cx).remote_id();
let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
self.app_state
.client
.send(proto::Unfollow {
room_id,
project_id,
leader_id: Some(leader_id),
})
.log_err();
}
cx.notify();
Some(leader_id)
} }
pub fn is_being_followed(&self, peer_id: PeerId) -> bool { pub fn is_being_followed(&self, peer_id: PeerId) -> bool {
self.follower_states_by_leader.contains_key(&peer_id) self.follower_states
.values()
.any(|state| state.leader_id == peer_id)
} }
fn render_titlebar(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> { fn render_titlebar(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
@ -2862,6 +2870,7 @@ impl Workspace {
cx.notify(); cx.notify();
self.last_active_view_id = active_view_id.clone();
proto::FollowResponse { proto::FollowResponse {
active_view_id, active_view_id,
views: self views: self
@ -2873,8 +2882,7 @@ impl Workspace {
let cx = &cx; let cx = &cx;
move |item| { move |item| {
let item = item.to_followable_item_handle(cx)?; let item = item.to_followable_item_handle(cx)?;
if project_id.is_some() if (project_id.is_none() || project_id != follower_project_id)
&& project_id != follower_project_id
&& item.is_project_item(cx) && item.is_project_item(cx)
{ {
return None; return None;
@ -2913,8 +2921,8 @@ impl Workspace {
match update.variant.ok_or_else(|| anyhow!("invalid update"))? { match update.variant.ok_or_else(|| anyhow!("invalid update"))? {
proto::update_followers::Variant::UpdateActiveView(update_active_view) => { proto::update_followers::Variant::UpdateActiveView(update_active_view) => {
this.update(cx, |this, _| { this.update(cx, |this, _| {
if let Some(state) = this.follower_states_by_leader.get_mut(&leader_id) { for (_, state) in &mut this.follower_states {
for state in state.values_mut() { if state.leader_id == leader_id {
state.active_view_id = state.active_view_id =
if let Some(active_view_id) = update_active_view.id.clone() { if let Some(active_view_id) = update_active_view.id.clone() {
Some(ViewId::from_proto(active_view_id)?) Some(ViewId::from_proto(active_view_id)?)
@ -2936,8 +2944,8 @@ impl Workspace {
let mut tasks = Vec::new(); let mut tasks = Vec::new();
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let project = this.project.clone(); let project = this.project.clone();
if let Some(state) = this.follower_states_by_leader.get_mut(&leader_id) { for (_, state) in &mut this.follower_states {
for state in state.values_mut() { if state.leader_id == leader_id {
let view_id = ViewId::from_proto(id.clone())?; let view_id = ViewId::from_proto(id.clone())?;
if let Some(item) = state.items_by_leader_view_id.get(&view_id) { if let Some(item) = state.items_by_leader_view_id.get(&view_id) {
tasks.push(item.apply_update_proto(&project, variant.clone(), cx)); tasks.push(item.apply_update_proto(&project, variant.clone(), cx));
@ -2950,10 +2958,9 @@ impl Workspace {
} }
proto::update_followers::Variant::CreateView(view) => { proto::update_followers::Variant::CreateView(view) => {
let panes = this.read_with(cx, |this, _| { let panes = this.read_with(cx, |this, _| {
this.follower_states_by_leader this.follower_states
.get(&leader_id) .iter()
.into_iter() .filter_map(|(pane, state)| (state.leader_id == leader_id).then_some(pane))
.flat_map(|states_by_pane| states_by_pane.keys())
.cloned() .cloned()
.collect() .collect()
})?; })?;
@ -3012,11 +3019,7 @@ impl Workspace {
for (pane, (item_tasks, leader_view_ids)) in item_tasks_by_pane { for (pane, (item_tasks, leader_view_ids)) in item_tasks_by_pane {
let items = futures::future::try_join_all(item_tasks).await?; let items = futures::future::try_join_all(item_tasks).await?;
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
let state = this let state = this.follower_states.get_mut(&pane)?;
.follower_states_by_leader
.get_mut(&leader_id)?
.get_mut(&pane)?;
for (id, item) in leader_view_ids.into_iter().zip(items) { for (id, item) in leader_view_ids.into_iter().zip(items) {
item.set_leader_peer_id(Some(leader_id), cx); item.set_leader_peer_id(Some(leader_id), cx);
state.items_by_leader_view_id.insert(id, item); state.items_by_leader_view_id.insert(id, item);
@ -3028,7 +3031,7 @@ impl Workspace {
Ok(()) Ok(())
} }
fn update_active_view_for_followers(&self, cx: &AppContext) { fn update_active_view_for_followers(&mut self, cx: &AppContext) {
let mut is_project_item = true; let mut is_project_item = true;
let mut update = proto::UpdateActiveView::default(); let mut update = proto::UpdateActiveView::default();
if self.active_pane.read(cx).has_focus() { if self.active_pane.read(cx).has_focus() {
@ -3046,11 +3049,14 @@ impl Workspace {
} }
} }
self.update_followers( if update.id != self.last_active_view_id {
is_project_item, self.last_active_view_id = update.id.clone();
proto::update_followers::Variant::UpdateActiveView(update), self.update_followers(
cx, is_project_item,
); proto::update_followers::Variant::UpdateActiveView(update),
cx,
);
}
} }
fn update_followers( fn update_followers(
@ -3070,15 +3076,7 @@ impl Workspace {
} }
pub fn leader_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<PeerId> { pub fn leader_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<PeerId> {
self.follower_states_by_leader self.follower_states.get(pane).map(|state| state.leader_id)
.iter()
.find_map(|(leader_id, state)| {
if state.contains_key(pane) {
Some(*leader_id)
} else {
None
}
})
} }
fn leader_updated(&mut self, leader_id: PeerId, cx: &mut ViewContext<Self>) -> Option<()> { fn leader_updated(&mut self, leader_id: PeerId, cx: &mut ViewContext<Self>) -> Option<()> {
@ -3106,17 +3104,23 @@ impl Workspace {
} }
}; };
for (pane, state) in self.follower_states_by_leader.get(&leader_id)? { for (pane, state) in &self.follower_states {
if leader_in_this_app { if state.leader_id != leader_id {
let item = state continue;
.active_view_id }
.and_then(|id| state.items_by_leader_view_id.get(&id)); if let (Some(active_view_id), true) = (state.active_view_id, leader_in_this_app) {
if let Some(item) = item { if let Some(item) = state.items_by_leader_view_id.get(&active_view_id) {
if leader_in_this_project || !item.is_project_item(cx) { if leader_in_this_project || !item.is_project_item(cx) {
items_to_activate.push((pane.clone(), item.boxed_clone())); items_to_activate.push((pane.clone(), item.boxed_clone()));
} }
continue; } else {
log::warn!(
"unknown view id {:?} for leader {:?}",
active_view_id,
leader_id
);
} }
continue;
} }
if let Some(shared_screen) = self.shared_screen_for_peer(leader_id, pane, cx) { if let Some(shared_screen) = self.shared_screen_for_peer(leader_id, pane, cx) {
items_to_activate.push((pane.clone(), Box::new(shared_screen))); items_to_activate.push((pane.clone(), Box::new(shared_screen)));
@ -3394,140 +3398,124 @@ impl Workspace {
serialized_workspace: SerializedWorkspace, serialized_workspace: SerializedWorkspace,
paths_to_open: Vec<Option<ProjectPath>>, paths_to_open: Vec<Option<ProjectPath>>,
cx: &mut AppContext, cx: &mut AppContext,
) -> Task<Vec<Option<Result<Box<dyn ItemHandle>, anyhow::Error>>>> { ) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
let result = async_iife! {{ let (project, old_center_pane) = workspace.read_with(&cx, |workspace, _| {
let (project, old_center_pane) = (
workspace.read_with(&cx, |workspace, _| { workspace.project().clone(),
( workspace.last_active_center_pane.clone(),
workspace.project().clone(), )
workspace.last_active_center_pane.clone(), })?;
)
})?;
let mut center_items = None; let mut center_group = None;
let mut center_group = None; let mut center_items = None;
// Traverse the splits tree and add to things // Traverse the splits tree and add to things
if let Some((group, active_pane, items)) = serialized_workspace if let Some((group, active_pane, items)) = serialized_workspace
.center_group .center_group
.deserialize(&project, serialized_workspace.id, &workspace, &mut cx) .deserialize(&project, serialized_workspace.id, &workspace, &mut cx)
.await { .await
center_items = Some(items); {
center_group = Some((group, active_pane)) center_items = Some(items);
center_group = Some((group, active_pane))
}
let mut items_by_project_path = cx.read(|cx| {
center_items
.unwrap_or_default()
.into_iter()
.filter_map(|item| {
let item = item?;
let project_path = item.project_path(cx)?;
Some((project_path, item))
})
.collect::<HashMap<_, _>>()
});
let opened_items = paths_to_open
.into_iter()
.map(|path_to_open| {
path_to_open
.and_then(|path_to_open| items_by_project_path.remove(&path_to_open))
})
.collect::<Vec<_>>();
// Remove old panes from workspace panes list
workspace.update(&mut cx, |workspace, cx| {
if let Some((center_group, active_pane)) = center_group {
workspace.remove_panes(workspace.center.root.clone(), cx);
// Swap workspace center group
workspace.center = PaneGroup::with_root(center_group);
// Change the focus to the workspace first so that we retrigger focus in on the pane.
cx.focus_self();
if let Some(active_pane) = active_pane {
cx.focus(&active_pane);
} else {
cx.focus(workspace.panes.last().unwrap());
}
} else {
let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx));
if let Some(old_center_handle) = old_center_handle {
cx.focus(&old_center_handle)
} else {
cx.focus_self()
}
} }
let resulting_list = cx.read(|cx| { let docks = serialized_workspace.docks;
let mut opened_items = center_items workspace.left_dock.update(cx, |dock, cx| {
.unwrap_or_default() dock.set_open(docks.left.visible, cx);
.into_iter() if let Some(active_panel) = docks.left.active_panel {
.filter_map(|item| { if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
let item = item?; dock.activate_panel(ix, cx);
let project_path = item.project_path(cx)?;
Some((project_path, item))
})
.collect::<HashMap<_, _>>();
paths_to_open
.into_iter()
.map(|path_to_open| {
path_to_open.map(|path_to_open| {
Ok(opened_items.remove(&path_to_open))
})
.transpose()
.map(|item| item.flatten())
.transpose()
})
.collect::<Vec<_>>()
});
// Remove old panes from workspace panes list
workspace.update(&mut cx, |workspace, cx| {
if let Some((center_group, active_pane)) = center_group {
workspace.remove_panes(workspace.center.root.clone(), cx);
// Swap workspace center group
workspace.center = PaneGroup::with_root(center_group);
// Change the focus to the workspace first so that we retrigger focus in on the pane.
cx.focus_self();
if let Some(active_pane) = active_pane {
cx.focus(&active_pane);
} else {
cx.focus(workspace.panes.last().unwrap());
} }
} else { }
let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx)); dock.active_panel()
if let Some(old_center_handle) = old_center_handle { .map(|panel| panel.set_zoomed(docks.left.zoom, cx));
cx.focus(&old_center_handle) if docks.left.visible && docks.left.zoom {
} else { cx.focus_self()
cx.focus_self() }
});
// TODO: I think the bug is that setting zoom or active undoes the bottom zoom or something
workspace.right_dock.update(cx, |dock, cx| {
dock.set_open(docks.right.visible, cx);
if let Some(active_panel) = docks.right.active_panel {
if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
dock.activate_panel(ix, cx);
}
}
dock.active_panel()
.map(|panel| panel.set_zoomed(docks.right.zoom, cx));
if docks.right.visible && docks.right.zoom {
cx.focus_self()
}
});
workspace.bottom_dock.update(cx, |dock, cx| {
dock.set_open(docks.bottom.visible, cx);
if let Some(active_panel) = docks.bottom.active_panel {
if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
dock.activate_panel(ix, cx);
} }
} }
let docks = serialized_workspace.docks; dock.active_panel()
workspace.left_dock.update(cx, |dock, cx| { .map(|panel| panel.set_zoomed(docks.bottom.zoom, cx));
dock.set_open(docks.left.visible, cx);
if let Some(active_panel) = docks.left.active_panel {
if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
dock.activate_panel(ix, cx);
}
}
dock.active_panel()
.map(|panel| {
panel.set_zoomed(docks.left.zoom, cx)
});
if docks.left.visible && docks.left.zoom {
cx.focus_self()
}
});
// TODO: I think the bug is that setting zoom or active undoes the bottom zoom or something
workspace.right_dock.update(cx, |dock, cx| {
dock.set_open(docks.right.visible, cx);
if let Some(active_panel) = docks.right.active_panel {
if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
dock.activate_panel(ix, cx);
} if docks.bottom.visible && docks.bottom.zoom {
} cx.focus_self()
dock.active_panel() }
.map(|panel| { });
panel.set_zoomed(docks.right.zoom, cx)
});
if docks.right.visible && docks.right.zoom { cx.notify();
cx.focus_self() })?;
}
});
workspace.bottom_dock.update(cx, |dock, cx| {
dock.set_open(docks.bottom.visible, cx);
if let Some(active_panel) = docks.bottom.active_panel {
if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
dock.activate_panel(ix, cx);
}
}
dock.active_panel() // Serialize ourself to make sure our timestamps and any pane / item changes are replicated
.map(|panel| { workspace.read_with(&cx, |workspace, cx| workspace.serialize_workspace(cx))?;
panel.set_zoomed(docks.bottom.zoom, cx)
});
if docks.bottom.visible && docks.bottom.zoom { Ok(opened_items)
cx.focus_self()
}
});
cx.notify();
})?;
// Serialize ourself to make sure our timestamps and any pane / item changes are replicated
workspace.read_with(&cx, |workspace, cx| workspace.serialize_workspace(cx))?;
Ok::<_, anyhow::Error>(resulting_list)
}};
result.await.unwrap_or_default()
}) })
} }
@ -3601,7 +3589,7 @@ async fn open_items(
mut project_paths_to_open: Vec<(PathBuf, Option<ProjectPath>)>, mut project_paths_to_open: Vec<(PathBuf, Option<ProjectPath>)>,
app_state: Arc<AppState>, app_state: Arc<AppState>,
mut cx: AsyncAppContext, mut cx: AsyncAppContext,
) -> Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>> { ) -> Result<Vec<Option<Result<Box<dyn ItemHandle>>>>> {
let mut opened_items = Vec::with_capacity(project_paths_to_open.len()); let mut opened_items = Vec::with_capacity(project_paths_to_open.len());
if let Some(serialized_workspace) = serialized_workspace { if let Some(serialized_workspace) = serialized_workspace {
@ -3619,16 +3607,19 @@ async fn open_items(
cx, cx,
) )
}) })
.await; .await?;
let restored_project_paths = cx.read(|cx| { let restored_project_paths = cx.read(|cx| {
restored_items restored_items
.iter() .iter()
.filter_map(|item| item.as_ref()?.as_ref().ok()?.project_path(cx)) .filter_map(|item| item.as_ref()?.project_path(cx))
.collect::<HashSet<_>>() .collect::<HashSet<_>>()
}); });
opened_items = restored_items; for restored_item in restored_items {
opened_items.push(restored_item.map(Ok));
}
project_paths_to_open project_paths_to_open
.iter_mut() .iter_mut()
.for_each(|(_, project_path)| { .for_each(|(_, project_path)| {
@ -3681,7 +3672,7 @@ async fn open_items(
} }
} }
opened_items Ok(opened_items)
} }
fn notify_of_new_dock(workspace: &WeakViewHandle<Workspace>, cx: &mut AsyncAppContext) { fn notify_of_new_dock(workspace: &WeakViewHandle<Workspace>, cx: &mut AsyncAppContext) {
@ -3817,7 +3808,7 @@ impl View for Workspace {
self.center.render( self.center.render(
&project, &project,
&theme, &theme,
&self.follower_states_by_leader, &self.follower_states,
self.active_call(), self.active_call(),
self.active_pane(), self.active_pane(),
self.zoomed self.zoomed

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor." description = "The fast, collaborative code editor."
edition = "2021" edition = "2021"
name = "zed" name = "zed"
version = "0.107.0" version = "0.107.7"
publish = false publish = false
[lib] [lib]

View file

@ -1 +1 @@
dev stable

View file

@ -74,7 +74,8 @@ fn main() {
let mut app = gpui::App::new(Assets).unwrap(); let mut app = gpui::App::new(Assets).unwrap();
let installation_id = app.background().block(installation_id()).ok(); let installation_id = app.background().block(installation_id()).ok();
init_panic_hook(&app, installation_id.clone()); let session_id = Uuid::new_v4().to_string();
init_panic_hook(&app, installation_id.clone(), session_id.clone());
load_embedded_fonts(&app); load_embedded_fonts(&app);
@ -177,7 +178,7 @@ fn main() {
}) })
.detach(); .detach();
client.telemetry().start(installation_id, cx); client.telemetry().start(installation_id, session_id, cx);
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
languages, languages,
@ -402,6 +403,7 @@ struct Panic {
panicked_on: u128, panicked_on: u128,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
installation_id: Option<String>, installation_id: Option<String>,
session_id: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -412,7 +414,7 @@ struct PanicRequest {
static PANIC_COUNT: AtomicU32 = AtomicU32::new(0); static PANIC_COUNT: AtomicU32 = AtomicU32::new(0);
fn init_panic_hook(app: &App, installation_id: Option<String>) { fn init_panic_hook(app: &App, installation_id: Option<String>, session_id: String) {
let is_pty = stdout_is_a_pty(); let is_pty = stdout_is_a_pty();
let platform = app.platform(); let platform = app.platform();
@ -477,7 +479,7 @@ fn init_panic_hook(app: &App, installation_id: Option<String>) {
line: location.line(), line: location.line(),
}), }),
app_version: app_version.clone(), app_version: app_version.clone(),
release_channel: RELEASE_CHANNEL.dev_name().into(), release_channel: RELEASE_CHANNEL.display_name().into(),
os_name: platform.os_name().into(), os_name: platform.os_name().into(),
os_version: platform os_version: platform
.os_version() .os_version()
@ -490,13 +492,14 @@ fn init_panic_hook(app: &App, installation_id: Option<String>) {
.as_millis(), .as_millis(),
backtrace, backtrace,
installation_id: installation_id.clone(), installation_id: installation_id.clone(),
session_id: session_id.clone(),
}; };
if is_pty { if let Some(panic_data_json) = serde_json::to_string_pretty(&panic_data).log_err() {
if let Some(panic_data_json) = serde_json::to_string_pretty(&panic_data).log_err() { log::error!("{}", panic_data_json);
eprintln!("{}", panic_data_json); }
}
} else { if !is_pty {
if let Some(panic_data_json) = serde_json::to_string(&panic_data).log_err() { if let Some(panic_data_json) = serde_json::to_string(&panic_data).log_err() {
let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string(); let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string();
let panic_file_path = paths::LOGS_DIR.join(format!("zed-{}.panic", timestamp)); let panic_file_path = paths::LOGS_DIR.join(format!("zed-{}.panic", timestamp));

View file

@ -5,6 +5,7 @@ import {
} from "./components" } from "./components"
import { icon_button } from "../component/icon_button" import { icon_button } from "../component/icon_button"
import { useTheme } from "../theme" import { useTheme } from "../theme"
import { interactive } from "../element"
export default function chat_panel(): any { export default function chat_panel(): any {
const theme = useTheme() const theme = useTheme()
@ -27,11 +28,23 @@ export default function chat_panel(): any {
return { return {
background: background(layer), background: background(layer),
list: { avatar: {
margin: { icon_width: 24,
left: SPACING, icon_height: 24,
right: SPACING, corner_radius: 4,
outer_width: 24,
outer_corner_radius: 16,
},
avatar_container: {
padding: {
right: 6,
left: 2,
top: 2,
bottom: 2,
} }
},
list: {
}, },
channel_select: { channel_select: {
header: { header: {
@ -79,6 +92,22 @@ export default function chat_panel(): any {
}, },
}, },
message: { message: {
...interactive({
base: {
margin: { top: SPACING },
padding: {
top: 4,
bottom: 4,
left: SPACING / 2,
right: SPACING / 3,
}
},
state: {
hovered: {
background: background(layer, "hovered"),
},
},
}),
body: text(layer, "sans", "base"), body: text(layer, "sans", "base"),
sender: { sender: {
margin: { margin: {
@ -87,7 +116,32 @@ export default function chat_panel(): any {
...text(layer, "sans", "base", { weight: "bold" }), ...text(layer, "sans", "base", { weight: "bold" }),
}, },
timestamp: text(layer, "sans", "base", "disabled"), timestamp: text(layer, "sans", "base", "disabled"),
margin: { bottom: SPACING } },
last_message_bottom_spacing: SPACING,
continuation_message: {
body: text(layer, "sans", "base"),
sender: {
margin: {
right: 8,
},
...text(layer, "sans", "base", { weight: "bold" }),
},
timestamp: text(layer, "sans", "base", "disabled"),
...interactive({
base: {
padding: {
top: 4,
bottom: 4,
left: SPACING / 2,
right: SPACING / 3,
}
},
state: {
hovered: {
background: background(layer, "hovered"),
},
},
}),
}, },
pending_message: { pending_message: {
body: text(layer, "sans", "base"), body: text(layer, "sans", "base"),
@ -98,6 +152,21 @@ export default function chat_panel(): any {
...text(layer, "sans", "base", "disabled"), ...text(layer, "sans", "base", "disabled"),
}, },
timestamp: text(layer, "sans", "base"), timestamp: text(layer, "sans", "base"),
...interactive({
base: {
padding: {
top: 4,
bottom: 4,
left: SPACING / 2,
right: SPACING / 3,
}
},
state: {
hovered: {
background: background(layer, "hovered"),
},
},
}),
}, },
sign_in_prompt: { sign_in_prompt: {
default: text(layer, "sans", "base"), default: text(layer, "sans", "base"),

View file

@ -21,6 +21,7 @@ export default function contacts_panel(): any {
...text(theme.lowest, "sans", "base"), ...text(theme.lowest, "sans", "base"),
button: icon_button({ variant: "ghost" }), button: icon_button({ variant: "ghost" }),
spacing: 4, spacing: 4,
padding: 4,
}, },
} }
} }