Merge branch 'main' into feat/copyable-errors

This commit is contained in:
ozacod 2026-05-01 21:21:32 +03:00 committed by GitHub
commit dde52301f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 622 additions and 44 deletions

1
Cargo.lock generated
View file

@ -3279,6 +3279,7 @@ dependencies = [
"collections", "collections",
"db", "db",
"editor", "editor",
"feature_flags",
"futures 0.3.32", "futures 0.3.32",
"fuzzy", "fuzzy",
"gpui", "gpui",

View file

@ -112,6 +112,13 @@ impl AnyActiveCall for ActiveCallEntity {
.map_or(false, |room| room.read(cx).is_sharing_project()) .map_or(false, |room| room.read(cx).is_sharing_project())
} }
fn is_sharing_screen(&self, cx: &App) -> bool {
self.0
.read(cx)
.room()
.map_or(false, |room| room.read(cx).is_sharing_screen())
}
fn has_remote_participants(&self, cx: &App) -> bool { fn has_remote_participants(&self, cx: &App) -> bool {
self.0.read(cx).room().map_or(false, |room| { self.0.read(cx).room().map_or(false, |room| {
!room.read(cx).remote_participants().is_empty() !room.read(cx).remote_participants().is_empty()
@ -209,6 +216,12 @@ impl AnyActiveCall for ActiveCallEntity {
participant_id: *participant_id, participant_id: *participant_id,
}) })
} }
room::Event::LocalScreenShareStarted => {
Some(ActiveCallEvent::LocalScreenShareStarted)
}
room::Event::LocalScreenShareStopped => {
Some(ActiveCallEvent::LocalScreenShareStopped)
}
_ => None, _ => None,
}; };
if let Some(event) = mapped { if let Some(event) = mapped {
@ -297,6 +310,18 @@ impl AnyActiveCall for ActiveCallEntity {
) )
})) }))
} }
fn peer_ids_with_video_tracks(&self, cx: &App) -> Vec<proto::PeerId> {
let Some(room) = self.0.read(cx).room() else {
return Vec::new();
};
room.read(cx)
.remote_participants()
.values()
.filter(|p| p.has_video_tracks())
.map(|p| p.peer_id)
.collect()
}
} }
pub struct OneAtATime { pub struct OneAtATime {

View file

@ -66,6 +66,8 @@ pub enum Event {
RoomLeft { RoomLeft {
channel_id: Option<ChannelId>, channel_id: Option<ChannelId>,
}, },
LocalScreenShareStarted,
LocalScreenShareStopped,
} }
pub struct Room { pub struct Room {
@ -1513,6 +1515,7 @@ impl Room {
track_publication: publication, track_publication: publication,
_stream: stream, _stream: stream,
}; };
cx.emit(Event::LocalScreenShareStarted);
cx.notify(); cx.notify();
} }
@ -1674,6 +1677,7 @@ impl Room {
let sid = track_publication.sid(); let sid = track_publication.sid();
cx.spawn(async move |_, cx| local_participant.unpublish_track(sid, cx).await) cx.spawn(async move |_, cx| local_participant.unpublish_track(sid, cx).await)
.detach_and_log_err(cx); .detach_and_log_err(cx);
cx.emit(Event::LocalScreenShareStopped);
cx.notify(); cx.notify();
} }

View file

@ -7,17 +7,16 @@ use cloud_api_client::{
}; };
use cloud_llm_client::{CurrentUsage, UsageData, UsageLimit}; use cloud_llm_client::{CurrentUsage, UsageData, UsageLimit};
use futures::{StreamExt, stream::BoxStream}; use futures::{StreamExt, stream::BoxStream};
use gpui::{AppContext as _, Entity, TestAppContext}; use gpui::{AppContext as _, TestAppContext};
use http_client::{AsyncBody, Method, Request, http}; use http_client::{AsyncBody, Method, Request, http};
use parking_lot::Mutex; use parking_lot::Mutex;
use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto}; use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto};
use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; use crate::{Client, Connection, Credentials, EstablishConnectionError};
pub struct FakeServer { pub struct FakeServer {
peer: Arc<Peer>, peer: Arc<Peer>,
state: Arc<Mutex<FakeServerState>>, state: Arc<Mutex<FakeServerState>>,
user_id: u64,
} }
#[derive(Default)] #[derive(Default)]
@ -38,7 +37,6 @@ impl FakeServer {
let server = Self { let server = Self {
peer: Peer::new(0), peer: Peer::new(0),
state: Default::default(), state: Default::default(),
user_id: client_user_id,
}; };
client.http_client().as_fake().replace_handler({ client.http_client().as_fake().replace_handler({
@ -213,23 +211,6 @@ impl FakeServer {
fn connection_id(&self) -> ConnectionId { fn connection_id(&self) -> ConnectionId {
self.state.lock().connection_id.expect("not connected") self.state.lock().connection_id.expect("not connected")
} }
pub async fn build_user_store(
&self,
client: Arc<Client>,
cx: &mut TestAppContext,
) -> Entity<UserStore> {
let user_store = cx.new(|cx| UserStore::new(client, cx));
assert_eq!(
self.receive::<proto::GetUsers>()
.await
.unwrap()
.payload
.user_ids,
&[self.user_id]
);
user_store
}
} }
impl Drop for FakeServer { impl Drop for FakeServer {

View file

@ -74,7 +74,7 @@ pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl Into
.await? .await?
.with_context(|| format!("user {user_id} not found"))?; .with_context(|| format!("user {user_id} not found"))?;
req.extensions_mut().insert(Principal::User(user)); req.extensions_mut().insert(Principal::User(user.into()));
return Ok::<_, Error>(next.run(req).await); return Ok::<_, Error>(next.run(req).await);
} }

View file

@ -37,7 +37,6 @@ use worktree_settings_file::LocalSettingsKind;
pub use ids::*; pub use ids::*;
pub use sea_orm::ConnectOptions; pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User;
pub use tables::*; pub use tables::*;
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]

View file

@ -60,7 +60,10 @@ impl Database {
} }
/// Returns a user by GitHub login. There are no access checks here, so this should only be used internally. /// Returns a user by GitHub login. There are no access checks here, so this should only be used internally.
pub async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> { pub async fn get_user_by_github_login(
&self,
github_login: &str,
) -> Result<Option<user::Model>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
Ok(user::Entity::find() Ok(user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login)) .filter(user::Column::GithubLogin.eq(github_login))
@ -78,7 +81,7 @@ impl Database {
github_name: Option<&str>, github_name: Option<&str>,
github_user_created_at: DateTimeUtc, github_user_created_at: DateTimeUtc,
initial_channel_id: Option<ChannelId>, initial_channel_id: Option<ChannelId>,
) -> Result<User> { ) -> Result<user::Model> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
self.update_or_create_user_by_github_account_tx( self.update_or_create_user_by_github_account_tx(
github_login, github_login,
@ -103,7 +106,7 @@ impl Database {
github_user_created_at: NaiveDateTime, github_user_created_at: NaiveDateTime,
initial_channel_id: Option<ChannelId>, initial_channel_id: Option<ChannelId>,
tx: &DatabaseTransaction, tx: &DatabaseTransaction,
) -> Result<User> { ) -> Result<user::Model> {
if let Some(existing_user) = self if let Some(existing_user) = self
.get_user_by_github_user_id_or_github_login(github_user_id, github_login, tx) .get_user_by_github_user_id_or_github_login(github_user_id, github_login, tx)
.await? .await?
@ -156,7 +159,7 @@ impl Database {
github_user_id: i32, github_user_id: i32,
github_login: &str, github_login: &str,
tx: &DatabaseTransaction, tx: &DatabaseTransaction,
) -> Result<Option<User>> { ) -> Result<Option<user::Model>> {
if let Some(user_by_github_user_id) = user::Entity::find() if let Some(user_by_github_user_id) = user::Entity::find()
.filter(user::Column::GithubUserId.eq(github_user_id)) .filter(user::Column::GithubUserId.eq(github_user_id))
.one(tx) .one(tx)
@ -178,7 +181,7 @@ impl Database {
/// get_all_users returns the next page of users. To get more call again with /// get_all_users returns the next page of users. To get more call again with
/// the same limit and the page incremented by 1. /// the same limit and the page incremented by 1.
pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> { pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<user::Model>> {
self.transaction(|tx| async move { self.transaction(|tx| async move {
Ok(user::Entity::find() Ok(user::Entity::find()
.order_by_asc(user::Column::GithubLogin) .order_by_asc(user::Column::GithubLogin)
@ -207,7 +210,11 @@ impl Database {
} }
/// Find users where github_login ILIKE name_query. /// Find users where github_login ILIKE name_query.
pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> { pub async fn fuzzy_search_users(
&self,
name_query: &str,
limit: u32,
) -> Result<Vec<user::Model>> {
self.transaction(|tx| async { self.transaction(|tx| async {
let tx = tx; let tx = tx;
let like_string = Self::fuzzy_like_string(name_query); let like_string = Self::fuzzy_like_string(name_query);

View file

@ -19,6 +19,17 @@ pub struct Model {
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
} }
impl From<Model> for crate::entities::User {
fn from(user: Model) -> Self {
crate::entities::User {
id: user.id,
github_login: user.github_login,
admin: user.admin,
connected_once: user.connected_once,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation { pub enum Relation {
#[sea_orm(has_one = "super::room_participant::Entity")] #[sea_orm(has_one = "super::room_participant::Entity")]

View file

@ -0,0 +1,3 @@
mod user;
pub use user::*;

View file

@ -0,0 +1,9 @@
use crate::db::UserId;
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub github_login: String,
pub admin: bool,
pub connected_once: bool,
}

View file

@ -1,6 +1,7 @@
pub mod api; pub mod api;
pub mod auth; pub mod auth;
pub mod db; pub mod db;
pub mod entities;
pub mod env; pub mod env;
pub mod executor; pub mod executor;
pub mod rpc; pub mod rpc;

View file

@ -1,12 +1,13 @@
mod connection_pool; mod connection_pool;
use crate::api::{CloudflareIpCountryHeader, SystemIdHeader}; use crate::api::{CloudflareIpCountryHeader, SystemIdHeader};
use crate::entities::User;
use crate::{ use crate::{
AppState, Error, Result, auth, AppState, Error, Result, auth,
db::{ db::{
self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser, Database, self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser, Database,
InviteMemberResult, MembershipUpdated, NotificationId, ProjectId, RejoinedProject, InviteMemberResult, MembershipUpdated, NotificationId, ProjectId, RejoinedProject,
RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, SharedThreadId, User, RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, SharedThreadId,
UserId, UserId,
}, },
executor::Executor, executor::Executor,

View file

@ -0,0 +1,272 @@
use crate::TestServer;
use call::ActiveCall;
use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource};
use project::Project;
use serde_json::json;
use util::path;
use workspace::Workspace;
use super::TestClient;
struct AutoWatchTestSetup {
client_a: TestClient,
_client_b: TestClient,
_client_c: TestClient,
project_a: Entity<Project>,
}
async fn setup_auto_watch_test(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
let client_a = server.create_client(user_a, "user_a").await;
let client_b = server.create_client(user_b, "user_b").await;
let client_c = server.create_client(user_c, "user_c").await;
server
.create_room(&mut [
(&client_a, user_a),
(&client_b, user_b),
(&client_c, user_c),
])
.await;
let active_call_a = user_a.read(ActiveCall::global);
client_a
.fs()
.insert_tree(path!("/a"), json!({ "file.txt": "content" }))
.await;
let (project_a, _worktree_id) = client_a.build_local_project(path!("/a"), user_a).await;
active_call_a
.update(user_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
AutoWatchTestSetup {
client_a,
_client_b: client_b,
_client_c: client_c,
project_a,
}
}
#[gpui::test]
async fn test_auto_watch_opens_existing_share_on_toggle(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
executor.run_until_parked();
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
});
}
#[gpui::test]
async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
});
}
#[gpui::test]
async fn test_auto_watch_switches_to_next_share_on_share_end(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
});
start_screen_share(user_c).await;
executor.run_until_parked();
stop_screen_share(user_b);
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_c's screen", cx);
});
}
#[gpui::test]
async fn test_auto_watch_ignores_shares_while_user_is_sharing(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
start_screen_share(user_a).await;
executor.run_until_parked();
start_screen_share(user_b).await;
executor.run_until_parked();
// Should NOT open B's screen cause we are sharing
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
// Ensure that no screen share is found in user a's tab bar
workspace_a.update(user_a, |workspace, cx| {
let has_shared_screen_tab = workspace
.active_pane()
.read(cx)
.items()
.any(|item| item.tab_content_text(0, cx).contains("screen"));
assert!(
!has_shared_screen_tab,
"should not open anyone's screen share when toggling on while sharing"
);
});
}
#[gpui::test]
async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_a).await;
executor.run_until_parked();
start_screen_share(user_b).await;
executor.run_until_parked();
stop_screen_share(user_a);
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
});
}
#[gpui::test]
async fn test_auto_watch_toggle_off_leaves_tabs_open(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup.client_a.build_workspace(&setup.project_a, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
workspace_a.update(user_a, |workspace, cx| {
assert_active_matches_title(workspace, "user_b's screen", cx);
});
}
#[track_caller]
fn assert_active_matches_title(workspace: &Workspace, expected_title: &str, cx: &App) {
let active_item = workspace.active_item(cx).expect("no active item");
assert_eq!(
active_item.tab_content_text(0, cx),
expected_title,
"expected active item to be '{}'",
expected_title
);
}
async fn start_screen_share(cx: &mut TestAppContext) {
let display = TestScreenCaptureSource::new();
cx.set_screen_capture_sources(vec![display]);
let screen = cx
.update(|cx| cx.screen_capture_sources())
.await
.unwrap()
.unwrap()
.into_iter()
.next()
.unwrap();
let active_call = cx.read(ActiveCall::global);
active_call
.update(cx, |call, cx| {
call.room()
.unwrap()
.update(cx, |room, cx| room.share_screen(screen, cx))
})
.await
.unwrap();
}
fn stop_screen_share(cx: &mut TestAppContext) {
let active_call = cx.read(ActiveCall::global);
active_call
.update(cx, |call, cx| {
call.room()
.unwrap()
.update(cx, |room, cx| room.unshare_screen(true, cx))
})
.unwrap();
}

View file

@ -3,6 +3,7 @@ use client::ChannelId;
use gpui::{Entity, TestAppContext}; use gpui::{Entity, TestAppContext};
mod agent_sharing_tests; mod agent_sharing_tests;
mod auto_watch_tests;
mod channel_buffer_tests; mod channel_buffer_tests;
mod channel_guest_tests; mod channel_guest_tests;
mod channel_tests; mod channel_tests;

View file

@ -294,7 +294,7 @@ impl TestServer {
cx.background_spawn(server.handle_connection( cx.background_spawn(server.handle_connection(
server_conn, server_conn,
client_name, client_name,
Principal::User(user), Principal::User(user.into()),
ZedVersion(semver::Version::new(1, 0, 0)), ZedVersion(semver::Version::new(1, 0, 0)),
Some("test".to_string()), Some("test".to_string()),
None, None,

View file

@ -36,6 +36,7 @@ client.workspace = true
collections.workspace = true collections.workspace = true
db.workspace = true db.workspace = true
editor.workspace = true editor.workspace = true
feature_flags.workspace = true
futures.workspace = true futures.workspace = true
fuzzy.workspace = true fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true

View file

@ -11,6 +11,7 @@ use collections::{HashMap, HashSet};
use contact_finder::ContactFinder; use contact_finder::ContactFinder;
use db::kvp::KeyValueStore; use db::kvp::KeyValueStore;
use editor::{Editor, EditorElement, EditorStyle}; use editor::{Editor, EditorElement, EditorStyle};
use feature_flags::{AutoWatchFeatureFlag, FeatureFlagAppExt as _};
use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{ use gpui::{
AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div, AnyElement, App, AsyncWindowContext, Bounds, ClickEvent, ClipboardItem, DismissEvent, Div,
@ -35,13 +36,13 @@ use theme::ActiveTheme;
use theme_settings::ThemeSettings; use theme_settings::ThemeSettings;
use ui::{ use ui::{
Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile, Avatar, AvatarAvailabilityIndicator, CollabNotification, ContextMenu, CopyButton, Facepile,
HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, TintColor, Tooltip,
tooltip_container, prelude::*, tooltip_container,
}; };
use util::{ResultExt, TryFutureExt, maybe}; use util::{ResultExt, TryFutureExt, maybe};
use workspace::{ use workspace::{
CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById, AutoWatch, CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes,
ScreenShare, ShareProject, Workspace, OpenChannelNotesById, ScreenShare, ShareProject, Workspace,
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
notifications::{ notifications::{
DetachAndPromptErr, Notification as WorkspaceNotification, NotificationId, NotifyResultExt, DetachAndPromptErr, Notification as WorkspaceNotification, NotificationId, NotifyResultExt,
@ -2895,13 +2896,75 @@ impl CollabPanel {
Section::Offline => SharedString::from("Offline"), Section::Offline => SharedString::from("Offline"),
}; };
let auto_watch_state = self
.workspace
.upgrade()
.map_or(AutoWatch::Off, |workspace| {
*workspace.read(cx).auto_watch_state()
});
let is_auto_watching = auto_watch_state.enabled();
let button = match section { let button = match section {
Section::ActiveCall => channel_link.map(|channel_link| { Section::ActiveCall => {
CopyButton::new("copy-channel-link", channel_link) let has_auto_watch_flag = cx.has_flag::<AutoWatchFeatureFlag>();
.visible_on_hover("section-header") let show_auto_watch = has_auto_watch_flag && is_auto_watching;
.tooltip_label("Copy Channel Link") let show_copy = channel_link.is_some();
.into_any_element()
}), if show_auto_watch || show_copy {
Some(
h_flex()
.when(has_auto_watch_flag, |this| {
this.child(
IconButton::new(
"auto-watch-screens",
if is_auto_watching {
IconName::Eye
} else {
IconName::EyeOff
},
)
.icon_size(IconSize::Small)
.toggle_state(is_auto_watching)
.selected_style(match auto_watch_state {
AutoWatch::Paused => {
ButtonStyle::Tinted(TintColor::Warning)
}
_ => ButtonStyle::Tinted(TintColor::Accent),
})
.when(!is_auto_watching, |this| {
this.visible_on_hover("section-header")
})
.tooltip(Tooltip::text(match auto_watch_state {
AutoWatch::Paused => {
"Auto Watch Screens (paused while sharing)"
}
AutoWatch::Active { .. } => "Stop Auto Watching Screens",
AutoWatch::Off => "Auto Watch Screens",
}))
.on_click(cx.listener(
|this, _, window, cx| {
this.workspace
.update(cx, |workspace, cx| {
workspace.toggle_auto_watch(window, cx)
})
.ok();
},
)),
)
})
.when_some(channel_link, |this, channel_link| {
this.child(
CopyButton::new("copy-channel-link", channel_link)
.visible_on_hover("section-header")
.tooltip_label("Copy Channel Link"),
)
})
.into_any_element(),
)
} else {
None
}
}
Section::Contacts => Some( Section::Contacts => Some(
IconButton::new("add-contact", IconName::Plus) IconButton::new("add-contact", IconName::Plus)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)

View file

@ -91,3 +91,11 @@ impl FeatureFlag for AgentThreadWorktreeLabelFlag {
} }
} }
register_feature_flag!(AgentThreadWorktreeLabelFlag); register_feature_flag!(AgentThreadWorktreeLabelFlag);
pub struct AutoWatchFeatureFlag;
impl FeatureFlag for AutoWatchFeatureFlag {
const NAME: &'static str = "auto-watch-screens";
type Value = PresenceFlag;
}
register_feature_flag!(AutoWatchFeatureFlag);

View file

@ -420,7 +420,80 @@ impl TestServer {
Ok(sid) Ok(sid)
} }
pub(crate) async fn unpublish_track(&self, _token: String, _track: &TrackSid) -> Result<()> { pub(crate) async fn unpublish_track(&self, token: String, track_sid: &TrackSid) -> Result<()> {
let claims = livekit_api::token::validate(&token, &self.secret_key)?;
let identity = ParticipantIdentity(claims.sub.unwrap().to_string());
let room_name = claims.video.room.unwrap();
let mut server_rooms = self.rooms.lock();
let room = server_rooms
.get_mut(&*room_name)
.with_context(|| format!("room {room_name} does not exist"))?;
if let Some(video_to_unpublish) = room.video_tracks.iter().position(|t| t.sid == *track_sid)
{
let video_to_unpublish = room.video_tracks.remove(video_to_unpublish);
for client_room in room
.client_rooms
.iter()
.filter(|(id, _)| **id != identity)
.map(|(_, room)| room)
{
let track = RemoteTrack::Video(RemoteVideoTrack {
server_track: video_to_unpublish.clone(),
_room: client_room.downgrade(),
});
let publication = RemoteTrackPublication {
sid: track_sid.clone(),
room: client_room.downgrade(),
track: track.clone(),
};
let participant = RemoteParticipant {
identity: identity.clone(),
room: client_room.downgrade(),
};
let event = RoomEvent::TrackUnsubscribed {
track,
publication,
participant,
};
client_room.0.lock().updates_tx.blocking_send(event).ok();
}
}
if let Some(audio_to_unpublish) = room.audio_tracks.iter().position(|t| t.sid == *track_sid)
{
let audio_to_unpublish = room.audio_tracks.remove(audio_to_unpublish);
for client_room in room
.client_rooms
.iter()
.filter(|(id, _)| **id != identity)
.map(|(_, room)| room)
{
let track = RemoteTrack::Audio(RemoteAudioTrack {
server_track: audio_to_unpublish.clone(),
room: client_room.downgrade(),
});
let publication = RemoteTrackPublication {
sid: track_sid.clone(),
room: client_room.downgrade(),
track: track.clone(),
};
let participant = RemoteParticipant {
identity: identity.clone(),
room: client_room.downgrade(),
};
let event = RoomEvent::TrackUnsubscribed {
track,
publication,
participant,
};
client_room.0.lock().updates_tx.blocking_send(event).ok();
}
}
Ok(()) Ok(())
} }

View file

@ -1363,6 +1363,7 @@ pub struct Workspace {
project: Entity<Project>, project: Entity<Project>,
follower_states: HashMap<CollaboratorId, FollowerState>, follower_states: HashMap<CollaboratorId, FollowerState>,
last_leaders_by_pane: HashMap<WeakEntity<Pane>, CollaboratorId>, last_leaders_by_pane: HashMap<WeakEntity<Pane>, CollaboratorId>,
auto_watch: AutoWatch,
window_edited: bool, window_edited: bool,
last_window_title: Option<String>, last_window_title: Option<String>,
dirty_items: HashMap<EntityId, Subscription>, dirty_items: HashMap<EntityId, Subscription>,
@ -1415,6 +1416,19 @@ pub struct FollowerState {
items_by_leader_view_id: HashMap<ViewId, FollowerView>, items_by_leader_view_id: HashMap<ViewId, FollowerView>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutoWatch {
Off,
Active { watched_peer: Option<PeerId> },
Paused,
}
impl AutoWatch {
pub fn enabled(&self) -> bool {
matches!(self, AutoWatch::Active { .. } | AutoWatch::Paused)
}
}
struct FollowerView { struct FollowerView {
view: Box<dyn FollowableItemHandle>, view: Box<dyn FollowableItemHandle>,
location: Option<proto::PanelId>, location: Option<proto::PanelId>,
@ -1793,6 +1807,7 @@ impl Workspace {
project: project.clone(), project: project.clone(),
follower_states: Default::default(), follower_states: Default::default(),
last_leaders_by_pane: Default::default(), last_leaders_by_pane: Default::default(),
auto_watch: AutoWatch::Off,
dispatching_keystrokes: Default::default(), dispatching_keystrokes: Default::default(),
window_edited: false, window_edited: false,
last_window_title: None, last_window_title: None,
@ -4783,6 +4798,93 @@ impl Workspace {
} }
} }
pub fn auto_watch_state(&self) -> &AutoWatch {
&self.auto_watch
}
fn next_watched_peer(&self, cx: &App) -> Option<PeerId> {
self.active_call()
.and_then(|call| call.peer_ids_with_video_tracks(cx).first().copied())
}
pub fn toggle_auto_watch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.auto_watch.enabled() {
self.auto_watch = AutoWatch::Off;
cx.notify();
return;
}
let active_pane = self.active_pane.clone();
self.unfollow_in_pane(&active_pane, window, cx);
let local_is_sharing = self
.active_call()
.map_or(false, |call| call.is_sharing_screen(cx));
if local_is_sharing {
self.auto_watch = AutoWatch::Paused;
} else {
let watched_peer = self.next_watched_peer(cx);
self.auto_watch = AutoWatch::Active { watched_peer };
if let Some(peer_id) = watched_peer {
self.open_shared_screen(peer_id, window, cx);
}
}
cx.notify();
}
fn handle_auto_watch_video_tracks_changed(
&mut self,
peer_id: PeerId,
window: &mut Window,
cx: &mut Context<Self>,
) {
let AutoWatch::Active { watched_peer } = self.auto_watch else {
return;
};
let peer_is_sharing = self.active_call().map_or(false, |call| {
call.peer_ids_with_video_tracks(cx).contains(&peer_id)
});
let should_watch_peer = peer_is_sharing && watched_peer.is_none();
let watched_peer_stopped_sharing = watched_peer == Some(peer_id) && !peer_is_sharing;
if should_watch_peer || watched_peer_stopped_sharing {
let next_watched_peer = if should_watch_peer {
Some(peer_id)
} else {
self.next_watched_peer(cx)
};
self.auto_watch = AutoWatch::Active {
watched_peer: next_watched_peer,
};
if let Some(next_watched_peer) = next_watched_peer {
self.open_shared_screen(next_watched_peer, window, cx);
}
}
}
fn handle_auto_watch_local_share_stopped(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let AutoWatch::Paused = self.auto_watch else {
return;
};
let watched_peer = self.next_watched_peer(cx);
self.auto_watch = AutoWatch::Active { watched_peer };
if let Some(peer_id) = watched_peer {
self.open_shared_screen(peer_id, window, cx);
}
}
pub fn activate_item( pub fn activate_item(
&mut self, &mut self,
item: &dyn ItemHandle, item: &dyn ItemHandle,
@ -6512,10 +6614,22 @@ impl Workspace {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
match event { match event {
ActiveCallEvent::ParticipantLocationChanged { participant_id } ActiveCallEvent::ParticipantLocationChanged { participant_id } => {
| ActiveCallEvent::RemoteVideoTracksChanged { participant_id } => {
self.leader_updated(participant_id, window, cx); self.leader_updated(participant_id, window, cx);
} }
ActiveCallEvent::RemoteVideoTracksChanged { participant_id } => {
self.leader_updated(participant_id, window, cx);
self.handle_auto_watch_video_tracks_changed(*participant_id, window, cx);
}
ActiveCallEvent::LocalScreenShareStarted => {
if let AutoWatch::Active { .. } = self.auto_watch {
self.auto_watch = AutoWatch::Paused;
cx.notify();
}
}
ActiveCallEvent::LocalScreenShareStopped => {
self.handle_auto_watch_local_share_stopped(window, cx);
}
} }
} }
@ -7879,6 +7993,7 @@ pub trait AnyActiveCall {
fn unshare_project(&self, _: Entity<Project>, _: &mut App) -> Result<()>; fn unshare_project(&self, _: Entity<Project>, _: &mut App) -> Result<()>;
fn remote_participant_for_peer_id(&self, _: PeerId, _: &App) -> Option<RemoteCollaborator>; fn remote_participant_for_peer_id(&self, _: PeerId, _: &App) -> Option<RemoteCollaborator>;
fn is_sharing_project(&self, _: &App) -> bool; fn is_sharing_project(&self, _: &App) -> bool;
fn is_sharing_screen(&self, _: &App) -> bool;
fn has_remote_participants(&self, _: &App) -> bool; fn has_remote_participants(&self, _: &App) -> bool;
fn local_participant_is_guest(&self, _: &App) -> bool; fn local_participant_is_guest(&self, _: &App) -> bool;
fn client(&self, _: &App) -> Arc<Client>; fn client(&self, _: &App) -> Arc<Client>;
@ -7908,6 +8023,7 @@ pub trait AnyActiveCall {
_: &mut Window, _: &mut Window,
_: &mut App, _: &mut App,
) -> Option<Entity<SharedScreen>>; ) -> Option<Entity<SharedScreen>>;
fn peer_ids_with_video_tracks(&self, _: &App) -> Vec<PeerId>;
} }
#[derive(Clone)] #[derive(Clone)]
@ -7961,6 +8077,8 @@ pub struct RemoteCollaborator {
pub enum ActiveCallEvent { pub enum ActiveCallEvent {
ParticipantLocationChanged { participant_id: PeerId }, ParticipantLocationChanged { participant_id: PeerId },
RemoteVideoTracksChanged { participant_id: PeerId }, RemoteVideoTracksChanged { participant_id: PeerId },
LocalScreenShareStarted,
LocalScreenShareStopped,
} }
fn leader_border_for_pane( fn leader_border_for_pane(