Introduce channels and move collab popover contents to a collaboration panel (#2828)

Max Brunsfeld created

### Summary

This PR introduces channels: a new way of starting collaboration
sessions. You can create channels and invite others to join them. You
can then hold a call in a channel, where any member of the channel is
free to join the call without needing to be invited.

Channels are displayed in a new panel called the collaboration panel,
which now also contains the contacts list, and the current call. The
collaboration popover has been removed from the titlebar.

![Screen Shot 2023-08-15 at 9 25 37
AM](https://github.com/zed-industries/zed/assets/326587/0f989dea-7fb7-4d50-9acd-25c8f1c30cd1)


For now, the channels functionality will only be revealed to staff, so
the public-facing change is just the move from the popover to the panel.

### To-do

* User-facing UI
  * [x] signed-out state for collab panel
  * [x] new icon for collab panel
  * [x] for now, channels section only appears for zed staff
* [x] current call section styling
(https://zed-industries.slack.com/archives/C05CJUNF2BU/p1691189389988239?thread_ts=1691189120.403009&cid=C05CJUNF2BU)
* [x] Channel members
* Channels
  * [x] style channel name editor
* [x] decide on a special "empty state" for the panel, when user has no
contacts
* [x] ensure channels are sorted in a consistent way (expose channel id
paths to client)
  * [x] Figure out layered panels UX
  * [x] Change add contacts to be the same kind of tabbed modal
* [x] race condition between channel updates and user fetches
(`ChannelStore::handle_update_contacts`)
* [x] race condition between joining channels and channel update
messages `collab::rpc::channel_updated`)
* [x] don't display mic as muted when microphone share is pending upon
first joining call

Release Notes:

- Moved the collaboration dropdown into its own panel.
- Added settings for disabling the AI assistant panel button.
- Switch to lazily initializing audio output sources
(https://github.com/zed-industries/community/issues/1840,
https://github.com/zed-industries/community/issues/1919)

Change summary

Cargo.lock                                                     |    3 
assets/icons/ai.svg                                            |   23 
assets/icons/arrow_left.svg                                    |    3 
assets/icons/arrow_right.svg                                   |    3 
assets/icons/channel_hash.svg                                  |    6 
assets/icons/check.svg                                         |    3 
assets/icons/check_circle.svg                                  |    4 
assets/icons/chevron_down.svg                                  |    3 
assets/icons/chevron_left.svg                                  |    3 
assets/icons/chevron_right.svg                                 |    3 
assets/icons/chevron_up.svg                                    |    3 
assets/icons/conversations.svg                                 |    2 
assets/icons/copilot.svg                                       |    9 
assets/icons/copy.svg                                          |    5 
assets/icons/ellipsis.svg                                      |    5 
assets/icons/error.svg                                         |    4 
assets/icons/exit.svg                                          |    4 
assets/icons/feedback.svg                                      |    6 
assets/icons/filter.svg                                        |    3 
assets/icons/hash.svg                                          |    6 
assets/icons/html.svg                                          |    5 
assets/icons/kebab.svg                                         |    5 
assets/icons/lock.svg                                          |    6 
assets/icons/magnifying_glass.svg                              |    3 
assets/icons/match_case.svg                                    |    3 
assets/icons/match_word.svg                                    |    2 
assets/icons/maximize.svg                                      |    4 
assets/icons/microphone.svg                                    |    5 
assets/icons/minimize.svg                                      |    4 
assets/icons/plus.svg                                          |    3 
assets/icons/project.svg                                       |    5 
assets/icons/replace.svg                                       |    9 
assets/icons/replace_all.svg                                   |    2 
assets/icons/replace_next.svg                                  |    5 
assets/icons/screen.svg                                        |    4 
assets/icons/split.svg                                         |    5 
assets/icons/success.svg                                       |    4 
assets/icons/terminal.svg                                      |    5 
assets/icons/warning.svg                                       |    5 
assets/icons/x.svg                                             |    3 
assets/keymaps/default.json                                    |   23 
assets/settings/default.json                                   |   10 
crates/ai/src/assistant.rs                                     |    7 
crates/ai/src/assistant_settings.rs                            |    2 
crates/audio/src/audio.rs                                      |   40 
crates/call/src/call.rs                                        |   36 
crates/call/src/room.rs                                        |  114 
crates/client/src/channel_store.rs                             |  548 
crates/client/src/channel_store_tests.rs                       |  165 
crates/client/src/client.rs                                    |   14 
crates/client/src/user.rs                                      |   16 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql |   26 
crates/collab/migrations/20230727150500_add_channels.sql       |   30 
crates/collab/src/bin/seed.rs                                  |   18 
crates/collab/src/db.rs                                        |  908 +
crates/collab/src/db/channel.rs                                |   38 
crates/collab/src/db/channel_member.rs                         |   61 
crates/collab/src/db/channel_path.rs                           |   15 
crates/collab/src/db/room.rs                                   |   17 
crates/collab/src/db/tests.rs                                  |  447 
crates/collab/src/db/user.rs                                   |    8 
crates/collab/src/rpc.rs                                       |  586 
crates/collab/src/tests.rs                                     |  179 
crates/collab/src/tests/channel_tests.rs                       |  820 +
crates/collab/src/tests/integration_tests.rs                   |  259 
crates/collab/src/tests/randomized_integration_tests.rs        |   66 
crates/collab_ui/Cargo.toml                                    |    4 
crates/collab_ui/src/collab_panel.rs                           | 2521 ++++
crates/collab_ui/src/collab_panel/channel_modal.rs             |  615 
crates/collab_ui/src/collab_panel/contact_finder.rs            |  151 
crates/collab_ui/src/collab_panel/panel_settings.rs            |   39 
crates/collab_ui/src/collab_titlebar_item.rs                   |  144 
crates/collab_ui/src/collab_ui.rs                              |   12 
crates/collab_ui/src/contact_list.rs                           | 1385 --
crates/collab_ui/src/contacts_popover.rs                       |  137 
crates/collab_ui/src/face_pile.rs                              |   47 
crates/diagnostics/src/items.rs                                |    6 
crates/feedback/src/deploy_feedback_button.rs                  |    2 
crates/gpui/src/elements.rs                                    |   16 
crates/menu/src/menu.rs                                        |    3 
crates/picker/src/picker.rs                                    |    1 
crates/project_panel/src/project_panel.rs                      |   28 
crates/rpc/proto/zed.proto                                     |  139 
crates/rpc/src/proto.rs                                        |   39 
crates/rpc/src/rpc.rs                                          |    2 
crates/terminal_view/src/terminal_panel.rs                     |    4 
crates/terminal_view/src/terminal_view.rs                      |    2 
crates/theme/src/theme.rs                                      |   84 
crates/theme/src/ui.rs                                         |   10 
crates/vcs_menu/src/lib.rs                                     |    2 
crates/vim/src/test/vim_test_context.rs                        |    1 
crates/workspace/src/dock.rs                                   |  206 
crates/workspace/src/workspace.rs                              |   38 
crates/zed/src/main.rs                                         |    7 
crates/zed/src/zed.rs                                          |   36 
script/start-local-collaboration                               |    2 
script/zed-with-local-servers                                  |    5 
styles/.eslintrc.js                                            |    1 
styles/src/common.ts                                           |    1 
styles/src/component/button.ts                                 |  118 
styles/src/component/icon_button.ts                            |   36 
styles/src/component/indicator.ts                              |    9 
styles/src/component/input.ts                                  |   23 
styles/src/component/label_button.ts                           |   78 
styles/src/component/tab.ts                                    |   73 
styles/src/component/text_button.ts                            |   14 
styles/src/element/index.ts                                    |    4 
styles/src/element/toggle.ts                                   |    2 
styles/src/style_tree/app.ts                                   |    9 
styles/src/style_tree/collab_modals.ts                         |  151 
styles/src/style_tree/collab_panel.ts                          |  406 
styles/src/style_tree/contact_finder.ts                        |   72 
styles/src/style_tree/contact_list.ts                          |  247 
styles/src/style_tree/contacts_popover.ts                      |    9 
styles/src/style_tree/context_menu.ts                          |   12 
styles/src/style_tree/status_bar.ts                            |   33 
styles/src/style_tree/titlebar.ts                              |    4 
117 files changed, 8,970 insertions(+), 2,661 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1552,6 +1552,7 @@ dependencies = [
  "clock",
  "collections",
  "context_menu",
+ "db",
  "editor",
  "feedback",
  "futures 0.3.28",
@@ -1563,9 +1564,11 @@ dependencies = [
  "postage",
  "project",
  "recent_projects",
+ "schemars",
  "serde",
  "serde_derive",
  "settings",
+ "staff_mode",
  "theme",
  "theme_selector",
  "util",

assets/icons/ai.svg 🔗

@@ -0,0 +1,23 @@
+<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 8.94203V11C7.38649 11 6.61351 11 4 11V10.6812L10 5.31884V5H4V7.08696" stroke="black" stroke-width="1.25"/>
+<circle cx="0.5" cy="8" r="0.5" fill="black"/>
+<circle cx="1.49976" cy="5.82825" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="1.49976" cy="10.1719" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="13.5" cy="8.01581" r="0.5" fill="black"/>
+<circle cx="12.5" cy="5.84387" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="12.5" cy="10.1877" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="6.99219" cy="1.48438" r="0.5" fill="black"/>
+<circle cx="4.5" cy="2.5" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="0.5" cy="12.016" r="0.5" fill="black"/>
+<circle cx="0.5" cy="3.98438" r="0.5" fill="black"/>
+<circle cx="13.5" cy="12.016" r="0.5" fill="black"/>
+<circle cx="13.5" cy="3.98438" r="0.5" fill="black"/>
+<circle cx="2.49976" cy="14.516" r="0.5" fill="black"/>
+<circle cx="2.48413" cy="1.48438" r="0.5" fill="black"/>
+<circle cx="11.5" cy="14.516" r="0.5" fill="black"/>
+<circle cx="11.5" cy="1.48438" r="0.5" fill="black"/>
+<circle cx="9.49609" cy="2.48438" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="6.99219" cy="14.5" r="0.5" fill="black"/>
+<circle cx="4.50391" cy="13.516" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="9.49609" cy="13.5" r="0.5" fill="black" fill-opacity="0.75"/>
+</svg>

assets/icons/arrow_left.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.125 6.99344L6.35938 3.63281M3.125 6.99344L6.35938 10.3672M3.125 6.99344H11" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/arrow_right.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.8906 7.00125L7.64062 3.64062M10.8906 7.00125L7.64062 10.375M10.8906 7.00125H3" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/channel_hash.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<line x1="10.2795" y1="2.63847" x2="7.74785" y2="11.0142" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="6.26624" y1="2.99597" x2="3.7346" y2="11.3717" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="3.15982" y1="5.3799" x2="11.9098" y2="5.3799" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="2.0983" y1="8.62407" x2="10.8483" y2="8.62407" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/check.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.98438 7.85115L6.13569 9.44983L9.98438 4.08141" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/check_circle.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5 8L6.5 9L9 5.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="7" cy="7" r="4.875" stroke="black" stroke-width="1.25"/>
+</svg>

assets/icons/chevron_down.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.63281 5.66406L6.99344 8.89844L10.3672 5.66406" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/chevron_left.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.35938 3.63281L5.125 6.99344L8.35938 10.3672" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/chevron_right.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.64062 3.64062L8.89062 7.00125L5.64062 10.375" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/chevron_up.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.63281 8.36719L6.99344 5.13281L10.3672 8.36719" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/conversations.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.46115 8.43419C7.30678 8.43419 8.92229 7.43411 8.92229 5.21171C8.92229 2.98933 7.30678 1.98926 5.46115 1.98926C3.61553 1.98926 2 2.98933 2 5.21171C2 6.028 2.21794 6.67935 2.58519 7.17685C2.7184 7.35732 2.69033 7.77795 2.58387 7.97539C2.32908 8.44793 2.81048 8.9657 3.33372 8.84571C3.72539 8.75597 4.13621 8.63447 4.49574 8.4715C4.62736 8.41181 4.7727 8.38777 4.91631 8.40402C5.09471 8.42416 5.27678 8.43419 5.46115 8.43419Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>

assets/icons/copilot.svg 🔗

@@ -0,0 +1,9 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5.64063 7.67017C5.97718 7.67017 6.25 7.94437 6.25 8.28263V9.60963C6.25 9.94786 5.97718 10.2221 5.64063 10.2221C5.30408 10.2221 5.03125 9.94786 5.03125 9.60963V8.28263C5.03125 7.94437 5.30408 7.67017 5.64063 7.67017Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.37537 7.67017C8.71192 7.67017 8.98474 7.94437 8.98474 8.28263V9.60963C8.98474 9.94786 8.71192 10.2221 8.37537 10.2221C8.03882 10.2221 7.76599 9.94786 7.76599 9.60963V8.28263C7.76599 7.94437 8.03882 7.67017 8.37537 7.67017Z" fill="black"/>
+<path d="M7 3.65625C7 5.84375 5.10754 6.3718 3.76562 6.3718C2.42371 6.3718 2.1405 5.3854 2.1405 4.16861C2.1405 2.95182 3.22834 1.96542 4.57025 1.96542C5.91216 1.96542 7 2.43946 7 3.65625Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25"/>
+<path d="M7 3.65625C7 5.84375 8.89246 6.3718 10.2344 6.3718C11.5763 6.3718 11.8595 5.3854 11.8595 4.16861C11.8595 2.95182 10.7717 1.96542 9.42975 1.96542C8.08784 1.96542 7 2.43946 7 3.65625Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25"/>
+<path d="M11.0156 6.01562C11.0156 6.01562 11.6735 6.43636 12 7.07348C12.3265 7.7106 12.3281 9.18621 12 9.7181C11.6719 10.25 11.2813 10.625 10.2931 11.16C9.30501 11.695 8 12.0156 8 12.0156H6C6 12.0156 4.70312 11.7344 3.70687 11.16C2.71061 10.5856 2.23437 10.2188 2 9.7181C1.76562 9.21746 1.6875 7.75 2 7.07348C2.31249 6.39695 3 6.01562 3 6.01562" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M10.4454 11.0264V6.41934L12.1671 6.99323V9.5598L10.4454 11.0264Z" fill="black" fill-opacity="0.75"/>
+<path d="M3.51556 11.0264V6.41934L1.79388 6.99323V9.5598L3.51556 11.0264Z" fill="black" fill-opacity="0.75"/>
+</svg>

assets/icons/copy.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2" y="5.64062" width="6.35938" height="6.35938" rx="0.5" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M8.01562 3.75H5.625V2.03125H11.9375V8.39062H10.2656V6C10.2656 4.75736 9.25827 3.75 8.01562 3.75Z" fill="black" fill-opacity="0.5"/>
+<path d="M5.625 3.125V2.5C5.625 2.22386 5.84886 2 6.125 2H11.5C11.7761 2 12 2.22386 12 2.5V7.875C12 8.15114 11.7761 8.375 11.5 8.375H10.8906" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/ellipsis.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="7" cy="7" r="1" fill="black"/>
+<circle cx="11" cy="7" r="1" fill="black"/>
+<circle cx="3" cy="7" r="1" fill="black"/>
+</svg>

assets/icons/error.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.86396 2C8.99657 2 9.12375 2.05268 9.21751 2.14645L11.8536 4.78249C11.9473 4.87625 12 5.00343 12 5.13604L12 8.86396C12 8.99657 11.9473 9.12375 11.8536 9.21751L9.21751 11.8536C9.12375 11.9473 8.99657 12 8.86396 12L5.13604 12C5.00343 12 4.87625 11.9473 4.78249 11.8536L2.14645 9.21751C2.05268 9.12375 2 8.99657 2 8.86396L2 5.13604C2 5.00343 2.05268 4.87625 2.14645 4.78249L4.78249 2.14645C4.87625 2.05268 5.00343 2 5.13604 2L8.86396 2Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M8.89063 5.10938L5.10937 8.89063M8.89063 8.89063L5.10937 5.10938" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/exit.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.3594 7.00127L9.86062 4.5025M12.3594 7.00127L9.86062 9.50002M12.3594 7.00127L5 7.00127" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 2H2.5C2.22386 2 2 2.22386 2 2.5V11.5C2 11.7761 2.22386 12 2.5 12H6" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/feedback.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 3.5C2 3.22386 2.22386 3 2.5 3H11.5C11.7761 3 12 3.22386 12 3.5V10.5C12 10.7761 11.7761 11 11.5 11H2.5C2.22386 11 2 10.7761 2 10.5V3.5Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M3 4L6.95312 7L11 4" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 9L5 8" stroke="black" stroke-opacity="0.5" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 9L9 8" stroke="black" stroke-opacity="0.5" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/filter.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.6749 2.40608C11.8058 2.24239 11.6893 1.99991 11.4796 1.99991H2.51996C2.31033 1.99991 2.19379 2.24239 2.32474 2.40608L5.14583 5.93246C5.34148 6.17701 5.44808 6.48087 5.44808 6.79412C5.44808 7.46881 5.44808 10.334 5.44808 11.5016C5.44808 11.7778 5.67194 11.9999 5.94808 11.9999H8.05153C8.32767 11.9999 8.55153 11.7778 8.55153 11.5016C8.55153 10.334 8.55153 7.46881 8.55153 6.79412C8.55153 6.48087 8.65815 6.17701 8.8538 5.93246L11.6749 2.40608Z" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/hash.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<line x1="10.2795" y1="2.63847" x2="7.74786" y2="11.0142" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="6.26625" y1="2.99597" x2="3.73461" y2="11.3717" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="3.15979" y1="5.3799" x2="11.9098" y2="5.3799" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="2.09833" y1="8.62407" x2="10.8483" y2="8.62407" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/html.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.15735 3.17108L5.84271 10.8289" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<path d="M4 5L2 7L4 9" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 9L12 7L10 5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/kebab.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="7" cy="7" r="1" fill="black"/>
+<circle cx="11" cy="7" r="1" fill="black"/>
+<circle cx="3" cy="7" r="1" fill="black"/>
+</svg>

assets/icons/lock.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="3" y="5" width="8" height="7" rx="0.5" stroke="black" stroke-width="1.25"/>
+<path d="M4 4C4 2.89543 4.89543 2 6 2H8C9.10457 2 10 2.89543 10 4V5H4V4Z" stroke="black" stroke-opacity="0.75" stroke-width="1.25"/>
+<circle cx="7" cy="8" r="1" fill="black"/>
+<path d="M7 8V9.375" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/magnifying_glass.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 12L9.41379 9.41379M2 6.31034C2 3.92981 3.92981 2 6.31034 2C8.6909 2 10.6207 3.92981 10.6207 6.31034C10.6207 8.6909 8.6909 10.6207 6.31034 10.6207C3.92981 10.6207 2 8.6909 2 6.31034Z" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/match_case.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.47087 3.20502H4.93146L7.12233 10.845H6.16733L5.60557 8.91252H2.78552L2.235 10.845H1.28L3.47087 3.20502ZM5.3921 8.06988L4.24611 4.02519H4.15622L3.01023 8.06988H5.3921Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.35784 3.05502H5.04449L7.32139 10.995H6.05473L5.49297 9.06253H2.89876L2.34823 10.995H1.08094L3.35784 3.05502ZM4.20117 4.41683L3.20863 7.91989H5.1937L4.20117 4.41683Z" fill="black"/>

assets/icons/match_word.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4.74677 9.48683L4.07035 6.03229L3.38589 9.48683H2.17618L1.00285 4.00778H2.27563L2.81571 7.41751L3.48443 4.01749H4.65869L5.31824 7.41173L5.8574 4.00778H7.13018L5.95684 9.48683H4.74677Z" fill="black"/>

assets/icons/maximize.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 8.5V12M2 12H5.5M2 12L6.01562 7.98437" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 5.5V2M12 2L8.5 2M12 2L8.01562 5.98437" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/microphone.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.5 8.5C10.5 8.5 9.375 10 7 10C4.625 10 3.5 8.5 3.5 8.5" stroke="black" stroke-width="1.25"/>
+<rect x="5" y="2" width="4" height="5.40625" rx="2" fill="black" fill-opacity="0.25" stroke="black" stroke-width="1.25"/>
+<path d="M7 10V12M7 12H9M7 12H5" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/minimize.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.01563 11.4844L6.01563 7.98438M6.01563 7.98438L2.51563 7.98437M6.01563 7.98438L2 12" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.01562 2.48438V5.98438M8.01562 5.98438H11.5156M8.01562 5.98438L12 2" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/plus.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 3V11M11 7H3" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/project.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.03125 2V2.03125M2.03125 8C2.03125 10 5 10 5 10M2.03125 8V2.03125M2.03125 8L2.03125 11M2.03125 2.03125C2.03125 4 5 4 5 4" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<rect x="7.375" y="2.375" width="4.25" height="3.25" rx="1.125" fill="black" fill-opacity="0.33" stroke="black" stroke-width="1.25"/>
+<rect x="7.375" y="8.375" width="4.25" height="3.25" rx="1.125" fill="black" fill-opacity="0.33" stroke="black" stroke-width="1.25"/>
+</svg>

assets/icons/replace.svg 🔗

@@ -0,0 +1,11 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 12C4.97279 12 3.22735 10.7936 2.4425 9.0595M7 2C9.11228 2 10.9186 3.30981 11.6512 5.16152" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="1.65625" cy="1.67188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="3.71094" cy="1.67188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="4.96094" cy="3.36719" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="3.71094" cy="4.79688" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="4.60156" cy="6.67188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="1.65625" cy="4.17188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="1.65625" cy="6.67188" r="0.625" fill="black" fill-opacity="0.75"/>

assets/icons/replace_all.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.10517 5.8012C4.07193 5.73172 4.00176 5.6875 3.92475 5.6875H3.44609C3.33564 5.6875 3.24609 5.77704 3.24609 5.8875V7.26172C3.24609 7.53786 3.02224 7.76172 2.74609 7.76172H2.64062C2.36448 7.76172 2.14062 7.53786 2.14062 7.26172V2.625C2.14062 2.34886 2.36448 2.125 2.64062 2.125H4.1875C5.41406 2.125 6.16406 2.80469 6.16406 3.92188C6.16406 4.57081 5.85885 5.12418 5.36073 5.40943C5.25888 5.46775 5.20921 5.59421 5.2617 5.69918L5.93117 7.03811C6.09739 7.37056 5.85564 7.76172 5.48395 7.76172H5.35806C5.16552 7.76172 4.99009 7.65117 4.907 7.47748L4.10517 5.8012ZM3.44609 3.03125C3.33564 3.03125 3.24609 3.12079 3.24609 3.23125V4.63594C3.24609 4.74639 3.33564 4.83594 3.44609 4.83594H4.03125C4.66016 4.83594 5.03516 4.49609 5.03516 3.92578C5.03516 3.36719 4.66797 3.03125 4.04297 3.03125H3.44609Z" fill="black" fill-opacity="0.75"/>

assets/icons/replace_next.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.96454 5.6762C3.93131 5.60672 3.86114 5.5625 3.78412 5.5625H3.30547C3.19501 5.5625 3.10547 5.65204 3.10547 5.7625V7.13672C3.10547 7.41286 2.88161 7.63672 2.60547 7.63672H2.5C2.22386 7.63672 2 7.41286 2 7.13672V2.5C2 2.22386 2.22386 2 2.5 2H4.04688C5.27344 2 6.02344 2.67969 6.02344 3.79688C6.02344 4.44581 5.71823 4.99918 5.2201 5.28443C5.11826 5.34275 5.06859 5.46921 5.12107 5.57418L5.79054 6.91311C5.95677 7.24556 5.71502 7.63672 5.34333 7.63672H5.21743C5.02489 7.63672 4.84946 7.52617 4.76638 7.35248L3.96454 5.6762ZM3.30547 2.90625C3.19501 2.90625 3.10547 2.99579 3.10547 3.10625V4.51094C3.10547 4.62139 3.19501 4.71094 3.30547 4.71094H3.89062C4.51953 4.71094 4.89453 4.37109 4.89453 3.80078C4.89453 3.24219 4.52734 2.90625 3.90234 2.90625H3.30547Z" fill="black" fill-opacity="0.75"/>
+<path d="M3.78412 5.6125C3.84188 5.6125 3.89451 5.64567 3.91944 5.69777L4.72127 7.37405C4.81266 7.56511 5.00564 7.68672 5.21743 7.68672H5.34333C5.75219 7.68672 6.01811 7.25645 5.83526 6.89075L5.1658 5.55182C5.12715 5.47453 5.16207 5.37528 5.24495 5.32782C5.76044 5.03262 6.07344 4.46155 6.07344 3.79688C6.07344 3.22658 5.88164 2.76303 5.52873 2.44248C5.17642 2.12247 4.6691 1.95 4.04688 1.95H2.5C2.19624 1.95 1.95 2.19624 1.95 2.5V7.13672C1.95 7.44048 2.19624 7.68672 2.5 7.68672H2.60547C2.90923 7.68672 3.15547 7.44048 3.15547 7.13672V5.7625C3.15547 5.67966 3.22263 5.6125 3.30547 5.6125H3.78412ZM3.15547 3.10625C3.15547 3.02341 3.22263 2.95625 3.30547 2.95625H3.90234C4.20626 2.95625 4.44101 3.03787 4.59926 3.18111C4.75686 3.32376 4.84453 3.5329 4.84453 3.80078C4.84453 4.07452 4.75491 4.28758 4.59484 4.43268C4.43413 4.57837 4.19643 4.66094 3.89062 4.66094H3.30547C3.22263 4.66094 3.15547 4.59378 3.15547 4.51094V3.10625Z" stroke="black" stroke-opacity="0.75" stroke-width="0.1"/>
+<path d="M7.5 5.88672C9.433 5.88672 11 7.45372 11 9.38672V12M11 12L13 10M11 12L9 10" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/screen.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2" y="2" width="10" height="7" rx="0.5" fill="black" fill-opacity="0.25" stroke="black" stroke-width="1.25"/>
+<path d="M7 9V12M7 12H9M7 12H5" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/split.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 2H10C11.1046 2 12 2.89543 12 4V10C12 11.1046 11.1046 12 10 12H7V2Z" fill="black" fill-opacity="0.25"/>
+<rect x="2" y="2" width="10" height="10" rx="0.5" stroke="black" stroke-width="1.25"/>
+<line x1="7" y1="2" x2="7" y2="12" stroke="black" stroke-width="1.25"/>
+</svg>

assets/icons/success.svg 🔗

@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2 2.5C2 2.22386 2.22386 2 2.5 2H11.5C11.7761 2 12 2.22386 12 2.5V11.5C12 11.7761 11.7761 12 11.5 12H2.5C2.22386 12 2 11.7761 2 11.5V2.5Z" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M4.60938 7.625L6.3125 8.89062L9.35938 4.64062" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/terminal.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.65625 2.5C1.65625 2.22386 1.88011 2 2.15625 2H11.8437C12.1199 2 12.3438 2.22386 12.3438 2.5V11.5C12.3438 11.7761 12.1199 12 11.8437 12H2.15625C1.88011 12 1.65625 11.7761 1.65625 11.5V2.5Z" stroke="black" stroke-width="1.25"/>
+<path d="M4.375 9L6.375 7L4.375 5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.625 9L9.90625 9" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/icons/warning.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.5 6.5L11.994 11.625C12.1556 11.9571 11.9137 12.3438 11.5444 12.3438H2.45563C2.08628 12.3438 1.84442 11.9571 2.00603 11.625L4.5 6.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 7L7 2" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="7" cy="9.24219" r="0.75" fill="black"/>
+</svg>

assets/icons/x.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.82843 4.17157L4.17157 9.82842M9.82843 9.82842L4.17157 4.17157" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>

assets/keymaps/default.json 🔗

@@ -13,6 +13,7 @@
       "cmd-up": "menu::SelectFirst",
       "cmd-down": "menu::SelectLast",
       "enter": "menu::Confirm",
+      "ctrl-enter": "menu::ShowContextMenu",
       "cmd-enter": "menu::SecondaryConfirm",
       "escape": "menu::Cancel",
       "ctrl-c": "menu::Cancel",
@@ -513,7 +514,8 @@
   {
     "bindings": {
       "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator",
-      "cmd-shift-c": "collab::ToggleContactsMenu",
+      // TODO: Move this to a dock open action
+      "cmd-shift-c": "collab_panel::ToggleFocus",
       "cmd-alt-i": "zed::DebugElements"
     }
   },
@@ -549,6 +551,25 @@
       "alt-shift-f": "project_panel::NewSearchInDirectory"
     }
   },
+  {
+    "context": "CollabPanel",
+    "bindings": {
+      "ctrl-backspace": "collab_panel::Remove",
+      "space": "menu::Confirm"
+    }
+  },
+  {
+    "context": "ChannelModal",
+    "bindings": {
+      "tab": "channel_modal::ToggleMode"
+    }
+  },
+  {
+    "context": "ChannelModal > Picker > Editor",
+    "bindings": {
+      "tab": "channel_modal::ToggleMode"
+    }
+  },
   {
     "context": "Terminal",
     "bindings": {

assets/settings/default.json 🔗

@@ -122,7 +122,17 @@
     // Amount of indentation for nested items.
     "indent_size": 20
   },
+  "collaboration_panel": {
+    // Whether to show the collaboration panel button in the status bar.
+    "button": true,
+    // Where to dock channels panel. Can be 'left' or 'right'.
+    "dock": "right",
+    // Default width of the channels panel.
+    "default_width": 240
+  },
   "assistant": {
+    // Whether to show the assistant panel button in the status bar.
+    "button": true,
     // Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
     "dock": "right",
     // Default width when the assistant is docked to the left or right.

crates/ai/src/assistant.rs 🔗

@@ -192,6 +192,7 @@ impl AssistantPanel {
                                 old_dock_position = new_dock_position;
                                 cx.emit(AssistantPanelEvent::DockPositionChanged);
                             }
+                            cx.notify();
                         })];
 
                     this
@@ -780,8 +781,10 @@ impl Panel for AssistantPanel {
         }
     }
 
-    fn icon_path(&self) -> &'static str {
-        "icons/robot_14.svg"
+    fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> {
+        settings::get::<AssistantSettings>(cx)
+            .button
+            .then(|| "icons/ai.svg")
     }
 
     fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {

crates/ai/src/assistant_settings.rs 🔗

@@ -13,6 +13,7 @@ pub enum AssistantDockPosition {
 
 #[derive(Deserialize, Debug)]
 pub struct AssistantSettings {
+    pub button: bool,
     pub dock: AssistantDockPosition,
     pub default_width: f32,
     pub default_height: f32,
@@ -20,6 +21,7 @@ pub struct AssistantSettings {
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
 pub struct AssistantSettingsContent {
+    pub button: Option<bool>,
     pub dock: Option<AssistantDockPosition>,
     pub default_width: Option<f32>,
     pub default_height: Option<f32>,

crates/audio/src/audio.rs 🔗

@@ -39,29 +39,43 @@ pub struct Audio {
 
 impl Audio {
     pub fn new() -> Self {
-        let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
-
         Self {
-            _output_stream,
-            output_handle,
+            _output_stream: None,
+            output_handle: None,
         }
     }
 
-    pub fn play_sound(sound: Sound, cx: &AppContext) {
+    fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> {
+        if self.output_handle.is_none() {
+            let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
+            self.output_handle = output_handle;
+            self._output_stream = _output_stream;
+        }
+
+        self.output_handle.as_ref()
+    }
+
+    pub fn play_sound(sound: Sound, cx: &mut AppContext) {
         if !cx.has_global::<Self>() {
             return;
         }
 
-        let this = cx.global::<Self>();
+        cx.update_global::<Self, _, _>(|this, cx| {
+            let output_handle = this.ensure_output_exists()?;
+            let source = SoundRegistry::global(cx).get(sound.file()).log_err()?;
+            output_handle.play_raw(source).log_err()?;
+            Some(())
+        });
+    }
 
-        let Some(output_handle) = this.output_handle.as_ref() else {
+    pub fn end_call(cx: &mut AppContext) {
+        if !cx.has_global::<Self>() {
             return;
-        };
-
-        let Some(source) = SoundRegistry::global(cx).get(sound.file()).log_err() else {
-        return;
-    };
+        }
 
-        output_handle.play_raw(source).log_err();
+        cx.update_global::<Self, _, _>(|this, _| {
+            this._output_stream.take();
+            this.output_handle.take();
+        });
     }
 }

crates/call/src/call.rs 🔗

@@ -5,8 +5,11 @@ pub mod room;
 use std::sync::Arc;
 
 use anyhow::{anyhow, Result};
+use audio::Audio;
 use call_settings::CallSettings;
-use client::{proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore};
+use client::{
+    proto, ChannelId, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore,
+};
 use collections::HashSet;
 use futures::{future::Shared, FutureExt};
 use postage::watch;
@@ -75,6 +78,10 @@ impl ActiveCall {
         }
     }
 
+    pub fn channel_id(&self, cx: &AppContext) -> Option<ChannelId> {
+        self.room()?.read(cx).channel_id()
+    }
+
     async fn handle_incoming_call(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::IncomingCall>,
@@ -274,9 +281,36 @@ impl ActiveCall {
         Ok(())
     }
 
+    pub fn join_channel(
+        &mut self,
+        channel_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if let Some(room) = self.room().cloned() {
+            if room.read(cx).channel_id() == Some(channel_id) {
+                return Task::ready(Ok(()));
+            } else {
+                room.update(cx, |room, cx| room.clear_state(cx));
+            }
+        }
+
+        let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx);
+
+        cx.spawn(|this, mut cx| async move {
+            let room = join.await?;
+            this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
+                .await?;
+            this.update(&mut cx, |this, cx| {
+                this.report_call_event("join channel", cx)
+            });
+            Ok(())
+        })
+    }
+
     pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
         cx.notify();
         self.report_call_event("hang up", cx);
+        Audio::end_call(cx);
         if let Some((room, _)) = self.room.take() {
             room.update(cx, |room, cx| room.leave(cx))
         } else {

crates/call/src/room.rs 🔗

@@ -49,6 +49,7 @@ pub enum Event {
 
 pub struct Room {
     id: u64,
+    channel_id: Option<u64>,
     live_kit: Option<LiveKitRoom>,
     status: RoomStatus,
     shared_projects: HashSet<WeakModelHandle<Project>>,
@@ -93,8 +94,25 @@ impl Entity for Room {
 }
 
 impl Room {
+    pub fn channel_id(&self) -> Option<u64> {
+        self.channel_id
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn is_connected(&self) -> bool {
+        if let Some(live_kit) = self.live_kit.as_ref() {
+            matches!(
+                *live_kit.room.status().borrow(),
+                live_kit_client::ConnectionState::Connected { .. }
+            )
+        } else {
+            false
+        }
+    }
+
     fn new(
         id: u64,
+        channel_id: Option<u64>,
         live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
@@ -185,6 +203,7 @@ impl Room {
 
         Self {
             id,
+            channel_id,
             live_kit: live_kit_room,
             status: RoomStatus::Online,
             shared_projects: Default::default(),
@@ -217,6 +236,7 @@ impl Room {
             let room = cx.add_model(|cx| {
                 Self::new(
                     room_proto.id,
+                    None,
                     response.live_kit_connection_info,
                     client,
                     user_store,
@@ -248,35 +268,64 @@ impl Room {
         })
     }
 
+    pub(crate) fn join_channel(
+        channel_id: u64,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut AppContext,
+    ) -> Task<Result<ModelHandle<Self>>> {
+        cx.spawn(|cx| async move {
+            Self::from_join_response(
+                client.request(proto::JoinChannel { channel_id }).await?,
+                client,
+                user_store,
+                cx,
+            )
+        })
+    }
+
     pub(crate) fn join(
         call: &IncomingCall,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
         cx: &mut AppContext,
     ) -> Task<Result<ModelHandle<Self>>> {
-        let room_id = call.room_id;
-        cx.spawn(|mut cx| async move {
-            let response = client.request(proto::JoinRoom { id: room_id }).await?;
-            let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
-            let room = cx.add_model(|cx| {
-                Self::new(
-                    room_id,
-                    response.live_kit_connection_info,
-                    client,
-                    user_store,
-                    cx,
-                )
-            });
-            room.update(&mut cx, |room, cx| {
-                room.leave_when_empty = true;
-                room.apply_room_update(room_proto, cx)?;
-                anyhow::Ok(())
-            })?;
-
-            Ok(room)
+        let id = call.room_id;
+        cx.spawn(|cx| async move {
+            Self::from_join_response(
+                client.request(proto::JoinRoom { id }).await?,
+                client,
+                user_store,
+                cx,
+            )
         })
     }
 
+    fn from_join_response(
+        response: proto::JoinRoomResponse,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        mut cx: AsyncAppContext,
+    ) -> Result<ModelHandle<Self>> {
+        let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+        let room = cx.add_model(|cx| {
+            Self::new(
+                room_proto.id,
+                response.channel_id,
+                response.live_kit_connection_info,
+                client,
+                user_store,
+                cx,
+            )
+        });
+        room.update(&mut cx, |room, cx| {
+            room.leave_when_empty = room.channel_id.is_none();
+            room.apply_room_update(room_proto, cx)?;
+            anyhow::Ok(())
+        })?;
+        Ok(room)
+    }
+
     fn should_leave(&self) -> bool {
         self.leave_when_empty
             && self.pending_room_update.is_none()
@@ -297,7 +346,18 @@ impl Room {
         }
 
         log::info!("leaving room");
+        Audio::play_sound(Sound::Leave, cx);
+
+        self.clear_state(cx);
+
+        let leave_room = self.client.request(proto::LeaveRoom {});
+        cx.background().spawn(async move {
+            leave_room.await?;
+            anyhow::Ok(())
+        })
+    }
 
+    pub(crate) fn clear_state(&mut self, cx: &mut AppContext) {
         for project in self.shared_projects.drain() {
             if let Some(project) = project.upgrade(cx) {
                 project.update(cx, |project, cx| {
@@ -314,8 +374,6 @@ impl Room {
             }
         }
 
-        Audio::play_sound(Sound::Leave, cx);
-
         self.status = RoomStatus::Offline;
         self.remote_participants.clear();
         self.pending_participants.clear();
@@ -324,12 +382,6 @@ impl Room {
         self.live_kit.take();
         self.pending_room_update.take();
         self.maintain_connection.take();
-
-        let leave_room = self.client.request(proto::LeaveRoom {});
-        cx.background().spawn(async move {
-            leave_room.await?;
-            anyhow::Ok(())
-        })
     }
 
     async fn maintain_connection(
@@ -1066,11 +1118,11 @@ impl Room {
         })
     }
 
-    pub fn is_muted(&self) -> bool {
+    pub fn is_muted(&self, cx: &AppContext) -> bool {
         self.live_kit
             .as_ref()
             .and_then(|live_kit| match &live_kit.microphone_track {
-                LocalTrack::None => Some(true),
+                LocalTrack::None => Some(settings::get::<CallSettings>(cx).mute_on_join),
                 LocalTrack::Pending { muted, .. } => Some(*muted),
                 LocalTrack::Published { muted, .. } => Some(*muted),
             })
@@ -1260,7 +1312,7 @@ impl Room {
     }
 
     pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) -> Result<Task<Result<()>>> {
-        let should_mute = !self.is_muted();
+        let should_mute = !self.is_muted(cx);
         if let Some(live_kit) = self.live_kit.as_mut() {
             if matches!(live_kit.microphone_track, LocalTrack::None) {
                 return Ok(self.share_microphone(cx));

crates/client/src/channel_store.rs 🔗

@@ -0,0 +1,548 @@
+use crate::Status;
+use crate::{Client, Subscription, User, UserStore};
+use anyhow::anyhow;
+use anyhow::Result;
+use collections::HashMap;
+use collections::HashSet;
+use futures::channel::mpsc;
+use futures::Future;
+use futures::StreamExt;
+use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
+use rpc::{proto, TypedEnvelope};
+use std::sync::Arc;
+use util::ResultExt;
+
+pub type ChannelId = u64;
+pub type UserId = u64;
+
+pub struct ChannelStore {
+    channels_by_id: HashMap<ChannelId, Arc<Channel>>,
+    channel_paths: Vec<Vec<ChannelId>>,
+    channel_invitations: Vec<Arc<Channel>>,
+    channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
+    channels_with_admin_privileges: HashSet<ChannelId>,
+    outgoing_invites: HashSet<(ChannelId, UserId)>,
+    update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
+    client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
+    _rpc_subscription: Subscription,
+    _watch_connection_status: Task<()>,
+    _update_channels: Task<()>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Channel {
+    pub id: ChannelId,
+    pub name: String,
+}
+
+pub struct ChannelMembership {
+    pub user: Arc<User>,
+    pub kind: proto::channel_member::Kind,
+    pub admin: bool,
+}
+
+pub enum ChannelEvent {
+    ChannelCreated(ChannelId),
+    ChannelRenamed(ChannelId),
+}
+
+impl Entity for ChannelStore {
+    type Event = ChannelEvent;
+}
+
+pub enum ChannelMemberStatus {
+    Invited,
+    Member,
+    NotMember,
+}
+
+impl ChannelStore {
+    pub fn new(
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let rpc_subscription =
+            client.add_message_handler(cx.handle(), Self::handle_update_channels);
+
+        let (update_channels_tx, mut update_channels_rx) = mpsc::unbounded();
+        let mut connection_status = client.status();
+        let watch_connection_status = cx.spawn_weak(|this, mut cx| async move {
+            while let Some(status) = connection_status.next().await {
+                if matches!(status, Status::ConnectionLost | Status::SignedOut) {
+                    if let Some(this) = this.upgrade(&cx) {
+                        this.update(&mut cx, |this, cx| {
+                            this.channels_by_id.clear();
+                            this.channel_invitations.clear();
+                            this.channel_participants.clear();
+                            this.channels_with_admin_privileges.clear();
+                            this.channel_paths.clear();
+                            this.outgoing_invites.clear();
+                            cx.notify();
+                        });
+                    } else {
+                        break;
+                    }
+                }
+            }
+        });
+        Self {
+            channels_by_id: HashMap::default(),
+            channel_invitations: Vec::default(),
+            channel_paths: Vec::default(),
+            channel_participants: Default::default(),
+            channels_with_admin_privileges: Default::default(),
+            outgoing_invites: Default::default(),
+            update_channels_tx,
+            client,
+            user_store,
+            _rpc_subscription: rpc_subscription,
+            _watch_connection_status: watch_connection_status,
+            _update_channels: cx.spawn_weak(|this, mut cx| async move {
+                while let Some(update_channels) = update_channels_rx.next().await {
+                    if let Some(this) = this.upgrade(&cx) {
+                        let update_task = this.update(&mut cx, |this, cx| {
+                            this.update_channels(update_channels, cx)
+                        });
+                        if let Some(update_task) = update_task {
+                            update_task.await.log_err();
+                        }
+                    }
+                }
+            }),
+        }
+    }
+
+    pub fn channel_count(&self) -> usize {
+        self.channel_paths.len()
+    }
+
+    pub fn channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
+        self.channel_paths.iter().map(move |path| {
+            let id = path.last().unwrap();
+            let channel = self.channel_for_id(*id).unwrap();
+            (path.len() - 1, channel)
+        })
+    }
+
+    pub fn channel_at_index(&self, ix: usize) -> Option<(usize, &Arc<Channel>)> {
+        let path = self.channel_paths.get(ix)?;
+        let id = path.last().unwrap();
+        let channel = self.channel_for_id(*id).unwrap();
+        Some((path.len() - 1, channel))
+    }
+
+    pub fn channel_invitations(&self) -> &[Arc<Channel>] {
+        &self.channel_invitations
+    }
+
+    pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc<Channel>> {
+        self.channels_by_id.get(&channel_id)
+    }
+
+    pub fn is_user_admin(&self, channel_id: ChannelId) -> bool {
+        self.channel_paths.iter().any(|path| {
+            if let Some(ix) = path.iter().position(|id| *id == channel_id) {
+                path[..=ix]
+                    .iter()
+                    .any(|id| self.channels_with_admin_privileges.contains(id))
+            } else {
+                false
+            }
+        })
+    }
+
+    pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc<User>] {
+        self.channel_participants
+            .get(&channel_id)
+            .map_or(&[], |v| v.as_slice())
+    }
+
+    pub fn create_channel(
+        &self,
+        name: &str,
+        parent_id: Option<ChannelId>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<ChannelId>> {
+        let client = self.client.clone();
+        let name = name.trim_start_matches("#").to_owned();
+        cx.spawn(|this, mut cx| async move {
+            let channel = client
+                .request(proto::CreateChannel { name, parent_id })
+                .await?
+                .channel
+                .ok_or_else(|| anyhow!("missing channel in response"))?;
+
+            let channel_id = channel.id;
+
+            this.update(&mut cx, |this, cx| {
+                let task = this.update_channels(
+                    proto::UpdateChannels {
+                        channels: vec![channel],
+                        ..Default::default()
+                    },
+                    cx,
+                );
+                assert!(task.is_none());
+
+                // This event is emitted because the collab panel wants to clear the pending edit state
+                // before this frame is rendered. But we can't guarantee that the collab panel's future
+                // will resolve before this flush_effects finishes. Synchronously emitting this event
+                // ensures that the collab panel will observe this creation before the frame completes
+                cx.emit(ChannelEvent::ChannelCreated(channel_id));
+            });
+
+            Ok(channel_id)
+        })
+    }
+
+    pub fn invite_member(
+        &mut self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        admin: bool,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if !self.outgoing_invites.insert((channel_id, user_id)) {
+            return Task::ready(Err(anyhow!("invite request already in progress")));
+        }
+
+        cx.notify();
+        let client = self.client.clone();
+        cx.spawn(|this, mut cx| async move {
+            let result = client
+                .request(proto::InviteChannelMember {
+                    channel_id,
+                    user_id,
+                    admin,
+                })
+                .await;
+
+            this.update(&mut cx, |this, cx| {
+                this.outgoing_invites.remove(&(channel_id, user_id));
+                cx.notify();
+            });
+
+            result?;
+
+            Ok(())
+        })
+    }
+
+    pub fn remove_member(
+        &mut self,
+        channel_id: ChannelId,
+        user_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if !self.outgoing_invites.insert((channel_id, user_id)) {
+            return Task::ready(Err(anyhow!("invite request already in progress")));
+        }
+
+        cx.notify();
+        let client = self.client.clone();
+        cx.spawn(|this, mut cx| async move {
+            let result = client
+                .request(proto::RemoveChannelMember {
+                    channel_id,
+                    user_id,
+                })
+                .await;
+
+            this.update(&mut cx, |this, cx| {
+                this.outgoing_invites.remove(&(channel_id, user_id));
+                cx.notify();
+            });
+            result?;
+            Ok(())
+        })
+    }
+
+    pub fn set_member_admin(
+        &mut self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        admin: bool,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if !self.outgoing_invites.insert((channel_id, user_id)) {
+            return Task::ready(Err(anyhow!("member request already in progress")));
+        }
+
+        cx.notify();
+        let client = self.client.clone();
+        cx.spawn(|this, mut cx| async move {
+            let result = client
+                .request(proto::SetChannelMemberAdmin {
+                    channel_id,
+                    user_id,
+                    admin,
+                })
+                .await;
+
+            this.update(&mut cx, |this, cx| {
+                this.outgoing_invites.remove(&(channel_id, user_id));
+                cx.notify();
+            });
+
+            result?;
+            Ok(())
+        })
+    }
+
+    pub fn rename(
+        &mut self,
+        channel_id: ChannelId,
+        new_name: &str,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        let name = new_name.to_string();
+        cx.spawn(|this, mut cx| async move {
+            let channel = client
+                .request(proto::RenameChannel { channel_id, name })
+                .await?
+                .channel
+                .ok_or_else(|| anyhow!("missing channel in response"))?;
+            this.update(&mut cx, |this, cx| {
+                let task = this.update_channels(
+                    proto::UpdateChannels {
+                        channels: vec![channel],
+                        ..Default::default()
+                    },
+                    cx,
+                );
+                assert!(task.is_none());
+
+                // This event is emitted because the collab panel wants to clear the pending edit state
+                // before this frame is rendered. But we can't guarantee that the collab panel's future
+                // will resolve before this flush_effects finishes. Synchronously emitting this event
+                // ensures that the collab panel will observe this creation before the frame complete
+                cx.emit(ChannelEvent::ChannelRenamed(channel_id))
+            });
+            Ok(())
+        })
+    }
+
+    pub fn respond_to_channel_invite(
+        &mut self,
+        channel_id: ChannelId,
+        accept: bool,
+    ) -> impl Future<Output = Result<()>> {
+        let client = self.client.clone();
+        async move {
+            client
+                .request(proto::RespondToChannelInvite { channel_id, accept })
+                .await?;
+            Ok(())
+        }
+    }
+
+    pub fn get_channel_member_details(
+        &self,
+        channel_id: ChannelId,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<ChannelMembership>>> {
+        let client = self.client.clone();
+        let user_store = self.user_store.downgrade();
+        cx.spawn(|_, mut cx| async move {
+            let response = client
+                .request(proto::GetChannelMembers { channel_id })
+                .await?;
+
+            let user_ids = response.members.iter().map(|m| m.user_id).collect();
+            let user_store = user_store
+                .upgrade(&cx)
+                .ok_or_else(|| anyhow!("user store dropped"))?;
+            let users = user_store
+                .update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))
+                .await?;
+
+            Ok(users
+                .into_iter()
+                .zip(response.members)
+                .filter_map(|(user, member)| {
+                    Some(ChannelMembership {
+                        user,
+                        admin: member.admin,
+                        kind: proto::channel_member::Kind::from_i32(member.kind)?,
+                    })
+                })
+                .collect())
+        })
+    }
+
+    pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future<Output = Result<()>> {
+        let client = self.client.clone();
+        async move {
+            client.request(proto::RemoveChannel { channel_id }).await?;
+            Ok(())
+        }
+    }
+
+    pub fn has_pending_channel_invite_response(&self, _: &Arc<Channel>) -> bool {
+        false
+    }
+
+    pub fn has_pending_channel_invite(&self, channel_id: ChannelId, user_id: UserId) -> bool {
+        self.outgoing_invites.contains(&(channel_id, user_id))
+    }
+
+    async fn handle_update_channels(
+        this: ModelHandle<Self>,
+        message: TypedEnvelope<proto::UpdateChannels>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, _| {
+            this.update_channels_tx
+                .unbounded_send(message.payload)
+                .unwrap();
+        });
+        Ok(())
+    }
+
+    pub(crate) fn update_channels(
+        &mut self,
+        payload: proto::UpdateChannels,
+        cx: &mut ModelContext<ChannelStore>,
+    ) -> Option<Task<Result<()>>> {
+        if !payload.remove_channel_invitations.is_empty() {
+            self.channel_invitations
+                .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id));
+        }
+        for channel in payload.channel_invitations {
+            match self
+                .channel_invitations
+                .binary_search_by_key(&channel.id, |c| c.id)
+            {
+                Ok(ix) => Arc::make_mut(&mut self.channel_invitations[ix]).name = channel.name,
+                Err(ix) => self.channel_invitations.insert(
+                    ix,
+                    Arc::new(Channel {
+                        id: channel.id,
+                        name: channel.name,
+                    }),
+                ),
+            }
+        }
+
+        let channels_changed = !payload.channels.is_empty() || !payload.remove_channels.is_empty();
+        if channels_changed {
+            if !payload.remove_channels.is_empty() {
+                self.channels_by_id
+                    .retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
+                self.channel_participants
+                    .retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
+                self.channels_with_admin_privileges
+                    .retain(|channel_id| !payload.remove_channels.contains(channel_id));
+            }
+
+            for channel in payload.channels {
+                if let Some(existing_channel) = self.channels_by_id.get_mut(&channel.id) {
+                    let existing_channel = Arc::make_mut(existing_channel);
+                    existing_channel.name = channel.name;
+                    continue;
+                }
+                self.channels_by_id.insert(
+                    channel.id,
+                    Arc::new(Channel {
+                        id: channel.id,
+                        name: channel.name,
+                    }),
+                );
+
+                if let Some(parent_id) = channel.parent_id {
+                    let mut ix = 0;
+                    while ix < self.channel_paths.len() {
+                        let path = &self.channel_paths[ix];
+                        if path.ends_with(&[parent_id]) {
+                            let mut new_path = path.clone();
+                            new_path.push(channel.id);
+                            self.channel_paths.insert(ix + 1, new_path);
+                            ix += 1;
+                        }
+                        ix += 1;
+                    }
+                } else {
+                    self.channel_paths.push(vec![channel.id]);
+                }
+            }
+
+            self.channel_paths.sort_by(|a, b| {
+                let a = Self::channel_path_sorting_key(a, &self.channels_by_id);
+                let b = Self::channel_path_sorting_key(b, &self.channels_by_id);
+                a.cmp(b)
+            });
+            self.channel_paths.dedup();
+            self.channel_paths.retain(|path| {
+                path.iter()
+                    .all(|channel_id| self.channels_by_id.contains_key(channel_id))
+            });
+        }
+
+        for permission in payload.channel_permissions {
+            if permission.is_admin {
+                self.channels_with_admin_privileges
+                    .insert(permission.channel_id);
+            } else {
+                self.channels_with_admin_privileges
+                    .remove(&permission.channel_id);
+            }
+        }
+
+        cx.notify();
+        if payload.channel_participants.is_empty() {
+            return None;
+        }
+
+        let mut all_user_ids = Vec::new();
+        let channel_participants = payload.channel_participants;
+        for entry in &channel_participants {
+            for user_id in entry.participant_user_ids.iter() {
+                if let Err(ix) = all_user_ids.binary_search(user_id) {
+                    all_user_ids.insert(ix, *user_id);
+                }
+            }
+        }
+
+        let users = self
+            .user_store
+            .update(cx, |user_store, cx| user_store.get_users(all_user_ids, cx));
+        Some(cx.spawn(|this, mut cx| async move {
+            let users = users.await?;
+
+            this.update(&mut cx, |this, cx| {
+                for entry in &channel_participants {
+                    let mut participants: Vec<_> = entry
+                        .participant_user_ids
+                        .iter()
+                        .filter_map(|user_id| {
+                            users
+                                .binary_search_by_key(&user_id, |user| &user.id)
+                                .ok()
+                                .map(|ix| users[ix].clone())
+                        })
+                        .collect();
+
+                    participants.sort_by_key(|u| u.id);
+
+                    this.channel_participants
+                        .insert(entry.channel_id, participants);
+                }
+
+                cx.notify();
+            });
+            anyhow::Ok(())
+        }))
+    }
+
+    fn channel_path_sorting_key<'a>(
+        path: &'a [ChannelId],
+        channels_by_id: &'a HashMap<ChannelId, Arc<Channel>>,
+    ) -> impl 'a + Iterator<Item = Option<&'a str>> {
+        path.iter()
+            .map(|id| Some(channels_by_id.get(id)?.name.as_str()))
+    }
+}

crates/client/src/channel_store_tests.rs 🔗

@@ -0,0 +1,165 @@
+use super::*;
+use util::http::FakeHttpClient;
+
+#[gpui::test]
+fn test_update_channels(cx: &mut AppContext) {
+    let http = FakeHttpClient::with_404_response();
+    let client = Client::new(http.clone(), cx);
+    let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+
+    let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
+
+    update_channels(
+        &channel_store,
+        proto::UpdateChannels {
+            channels: vec![
+                proto::Channel {
+                    id: 1,
+                    name: "b".to_string(),
+                    parent_id: None,
+                },
+                proto::Channel {
+                    id: 2,
+                    name: "a".to_string(),
+                    parent_id: None,
+                },
+            ],
+            channel_permissions: vec![proto::ChannelPermission {
+                channel_id: 1,
+                is_admin: true,
+            }],
+            ..Default::default()
+        },
+        cx,
+    );
+    assert_channels(
+        &channel_store,
+        &[
+            //
+            (0, "a".to_string(), false),
+            (0, "b".to_string(), true),
+        ],
+        cx,
+    );
+
+    update_channels(
+        &channel_store,
+        proto::UpdateChannels {
+            channels: vec![
+                proto::Channel {
+                    id: 3,
+                    name: "x".to_string(),
+                    parent_id: Some(1),
+                },
+                proto::Channel {
+                    id: 4,
+                    name: "y".to_string(),
+                    parent_id: Some(2),
+                },
+            ],
+            ..Default::default()
+        },
+        cx,
+    );
+    assert_channels(
+        &channel_store,
+        &[
+            (0, "a".to_string(), false),
+            (1, "y".to_string(), false),
+            (0, "b".to_string(), true),
+            (1, "x".to_string(), true),
+        ],
+        cx,
+    );
+}
+
+#[gpui::test]
+fn test_dangling_channel_paths(cx: &mut AppContext) {
+    let http = FakeHttpClient::with_404_response();
+    let client = Client::new(http.clone(), cx);
+    let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+
+    let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
+
+    update_channels(
+        &channel_store,
+        proto::UpdateChannels {
+            channels: vec![
+                proto::Channel {
+                    id: 0,
+                    name: "a".to_string(),
+                    parent_id: None,
+                },
+                proto::Channel {
+                    id: 1,
+                    name: "b".to_string(),
+                    parent_id: Some(0),
+                },
+                proto::Channel {
+                    id: 2,
+                    name: "c".to_string(),
+                    parent_id: Some(1),
+                },
+            ],
+            channel_permissions: vec![proto::ChannelPermission {
+                channel_id: 0,
+                is_admin: true,
+            }],
+            ..Default::default()
+        },
+        cx,
+    );
+    // Sanity check
+    assert_channels(
+        &channel_store,
+        &[
+            //
+            (0, "a".to_string(), true),
+            (1, "b".to_string(), true),
+            (2, "c".to_string(), true),
+        ],
+        cx,
+    );
+
+    update_channels(
+        &channel_store,
+        proto::UpdateChannels {
+            remove_channels: vec![1, 2],
+            ..Default::default()
+        },
+        cx,
+    );
+
+    // Make sure that the 1/2/3 path is gone
+    assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx);
+}
+
+fn update_channels(
+    channel_store: &ModelHandle<ChannelStore>,
+    message: proto::UpdateChannels,
+    cx: &mut AppContext,
+) {
+    let task = channel_store.update(cx, |store, cx| store.update_channels(message, cx));
+    assert!(task.is_none());
+}
+
+#[track_caller]
+fn assert_channels(
+    channel_store: &ModelHandle<ChannelStore>,
+    expected_channels: &[(usize, String, bool)],
+    cx: &AppContext,
+) {
+    let actual = channel_store.read_with(cx, |store, _| {
+        store
+            .channels()
+            .map(|(depth, channel)| {
+                (
+                    depth,
+                    channel.name.to_string(),
+                    store.is_user_admin(channel.id),
+                )
+            })
+            .collect::<Vec<_>>()
+    });
+    assert_eq!(actual, expected_channels);
+}

crates/client/src/client.rs 🔗

@@ -1,6 +1,10 @@
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
+#[cfg(test)]
+mod channel_store_tests;
+
+pub mod channel_store;
 pub mod telemetry;
 pub mod user;
 
@@ -44,6 +48,7 @@ use util::channel::ReleaseChannel;
 use util::http::HttpClient;
 use util::{ResultExt, TryFutureExt};
 
+pub use channel_store::*;
 pub use rpc::*;
 pub use telemetry::ClickhouseEvent;
 pub use user::*;
@@ -535,6 +540,7 @@ impl Client {
         }
     }
 
+    #[track_caller]
     pub fn add_message_handler<M, E, H, F>(
         self: &Arc<Self>,
         model: ModelHandle<E>,
@@ -570,7 +576,13 @@ impl Client {
             }),
         );
         if prev_handler.is_some() {
-            panic!("registered handler for the same message twice");
+            let location = std::panic::Location::caller();
+            panic!(
+                "{}:{} registered handler for the same message {} twice",
+                location.file(),
+                location.line(),
+                std::any::type_name::<M>()
+            );
         }
 
         Subscription::Message {

crates/client/src/user.rs 🔗

@@ -165,17 +165,29 @@ impl UserStore {
                                 });
 
                                 current_user_tx.send(user).await.ok();
+
+                                this.update(&mut cx, |_, cx| {
+                                    cx.notify();
+                                });
                             }
                         }
                         Status::SignedOut => {
                             current_user_tx.send(None).await.ok();
                             if let Some(this) = this.upgrade(&cx) {
-                                this.update(&mut cx, |this, _| this.clear_contacts()).await;
+                                this.update(&mut cx, |this, cx| {
+                                    cx.notify();
+                                    this.clear_contacts()
+                                })
+                                .await;
                             }
                         }
                         Status::ConnectionLost => {
                             if let Some(this) = this.upgrade(&cx) {
-                                this.update(&mut cx, |this, _| this.clear_contacts()).await;
+                                this.update(&mut cx, |this, cx| {
+                                    cx.notify();
+                                    this.clear_contacts()
+                                })
+                                .await;
                             }
                         }
                         _ => {}

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -36,7 +36,8 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
 
 CREATE TABLE "rooms" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
-    "live_kit_room" VARCHAR NOT NULL
+    "live_kit_room" VARCHAR NOT NULL,
+    "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
 );
 
 CREATE TABLE "projects" (
@@ -184,3 +185,26 @@ CREATE UNIQUE INDEX
     "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
 ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
 CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");
+
+CREATE TABLE "channels" (
+    "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+    "name" VARCHAR NOT NULL,
+    "created_at" TIMESTAMP NOT NULL DEFAULT now
+);
+
+CREATE TABLE "channel_paths" (
+    "id_path" TEXT NOT NULL PRIMARY KEY,
+    "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
+);
+CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
+
+CREATE TABLE "channel_members" (
+    "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+    "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
+    "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    "admin" BOOLEAN NOT NULL DEFAULT false,
+    "accepted" BOOLEAN NOT NULL DEFAULT false,
+    "updated_at" TIMESTAMP NOT NULL DEFAULT now
+);
+
+CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");

crates/collab/migrations/20230727150500_add_channels.sql 🔗

@@ -0,0 +1,30 @@
+DROP TABLE "channel_messages";
+DROP TABLE "channel_memberships";
+DROP TABLE "org_memberships";
+DROP TABLE "orgs";
+DROP TABLE "channels";
+
+CREATE TABLE "channels" (
+    "id" SERIAL PRIMARY KEY,
+    "name" VARCHAR NOT NULL,
+    "created_at" TIMESTAMP NOT NULL DEFAULT now()
+);
+
+CREATE TABLE "channel_paths" (
+    "id_path" VARCHAR NOT NULL PRIMARY KEY,
+    "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
+);
+CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
+
+CREATE TABLE "channel_members" (
+    "id" SERIAL PRIMARY KEY,
+    "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
+    "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+    "admin" BOOLEAN NOT NULL DEFAULT false,
+    "accepted" BOOLEAN NOT NULL DEFAULT false,
+    "updated_at" TIMESTAMP NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");
+
+ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE;

crates/collab/src/bin/seed.rs 🔗

@@ -64,9 +64,9 @@ async fn main() {
             .expect("failed to fetch user")
             .is_none()
         {
-            if let Some(email) = &github_user.email {
+            if admin {
                 db.create_user(
-                    email,
+                    &format!("{}@zed.dev", github_user.login),
                     admin,
                     db::NewUserParams {
                         github_login: github_user.login,
@@ -76,15 +76,11 @@ async fn main() {
                 )
                 .await
                 .expect("failed to insert user");
-            } else if admin {
-                db.create_user(
-                    &format!("{}@zed.dev", github_user.login),
-                    admin,
-                    db::NewUserParams {
-                        github_login: github_user.login,
-                        github_user_id: github_user.id,
-                        invite_count: 5,
-                    },
+            } else {
+                db.get_or_create_user_by_github_account(
+                    &github_user.login,
+                    Some(github_user.id),
+                    github_user.email.as_deref(),
                 )
                 .await
                 .expect("failed to insert user");

crates/collab/src/db.rs 🔗

@@ -1,4 +1,7 @@
 mod access_token;
+mod channel;
+mod channel_member;
+mod channel_path;
 mod contact;
 mod follower;
 mod language_server;
@@ -41,6 +44,7 @@ use serde::{Deserialize, Serialize};
 pub use signup::{Invite, NewSignup, WaitlistSummary};
 use sqlx::migrate::{Migrate, Migration, MigrationSource};
 use sqlx::Connection;
+use std::fmt::Write as _;
 use std::ops::{Deref, DerefMut};
 use std::path::Path;
 use std::time::Duration;
@@ -208,18 +212,27 @@ impl Database {
                     .map(|participant| participant.user_id),
             );
 
-            let room = self.get_room(room_id, &tx).await?;
-            // Delete the room if it becomes empty.
-            if room.participants.is_empty() {
-                project::Entity::delete_many()
-                    .filter(project::Column::RoomId.eq(room_id))
-                    .exec(&*tx)
-                    .await?;
-                room::Entity::delete_by_id(room_id).exec(&*tx).await?;
-            }
+            let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+            let channel_members;
+            if let Some(channel_id) = channel_id {
+                channel_members = self.get_channel_members_internal(channel_id, &tx).await?;
+            } else {
+                channel_members = Vec::new();
+
+                // Delete the room if it becomes empty.
+                if room.participants.is_empty() {
+                    project::Entity::delete_many()
+                        .filter(project::Column::RoomId.eq(room_id))
+                        .exec(&*tx)
+                        .await?;
+                    room::Entity::delete_by_id(room_id).exec(&*tx).await?;
+                }
+            };
 
             Ok(RefreshedRoom {
                 room,
+                channel_id,
+                channel_members,
                 stale_participant_user_ids,
                 canceled_calls_to_user_ids,
             })
@@ -1330,36 +1343,119 @@ impl Database {
         .await
     }
 
+    pub async fn is_current_room_different_channel(
+        &self,
+        user_id: UserId,
+        channel_id: ChannelId,
+    ) -> Result<bool> {
+        self.transaction(|tx| async move {
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryAs {
+                ChannelId,
+            }
+
+            let channel_id_model: Option<ChannelId> = room_participant::Entity::find()
+                .select_only()
+                .column_as(room::Column::ChannelId, QueryAs::ChannelId)
+                .inner_join(room::Entity)
+                .filter(room_participant::Column::UserId.eq(user_id))
+                .into_values::<_, QueryAs>()
+                .one(&*tx)
+                .await?;
+
+            let result = channel_id_model
+                .map(|channel_id_model| channel_id_model != channel_id)
+                .unwrap_or(false);
+
+            Ok(result)
+        })
+        .await
+    }
+
     pub async fn join_room(
         &self,
         room_id: RoomId,
         user_id: UserId,
         connection: ConnectionId,
-    ) -> Result<RoomGuard<proto::Room>> {
+    ) -> Result<RoomGuard<JoinRoom>> {
         self.room_transaction(room_id, |tx| async move {
-            let result = room_participant::Entity::update_many()
-                .filter(
-                    Condition::all()
-                        .add(room_participant::Column::RoomId.eq(room_id))
-                        .add(room_participant::Column::UserId.eq(user_id))
-                        .add(room_participant::Column::AnsweringConnectionId.is_null()),
-                )
-                .set(room_participant::ActiveModel {
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryChannelId {
+                ChannelId,
+            }
+            let channel_id: Option<ChannelId> = room::Entity::find()
+                .select_only()
+                .column(room::Column::ChannelId)
+                .filter(room::Column::Id.eq(room_id))
+                .into_values::<_, QueryChannelId>()
+                .one(&*tx)
+                .await?
+                .ok_or_else(|| anyhow!("no such room"))?;
+
+            if let Some(channel_id) = channel_id {
+                self.check_user_is_channel_member(channel_id, user_id, &*tx)
+                    .await?;
+
+                room_participant::Entity::insert_many([room_participant::ActiveModel {
+                    room_id: ActiveValue::set(room_id),
+                    user_id: ActiveValue::set(user_id),
                     answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
                     answering_connection_server_id: ActiveValue::set(Some(ServerId(
                         connection.owner_id as i32,
                     ))),
                     answering_connection_lost: ActiveValue::set(false),
+                    calling_user_id: ActiveValue::set(user_id),
+                    calling_connection_id: ActiveValue::set(connection.id as i32),
+                    calling_connection_server_id: ActiveValue::set(Some(ServerId(
+                        connection.owner_id as i32,
+                    ))),
                     ..Default::default()
-                })
+                }])
+                .on_conflict(
+                    OnConflict::columns([room_participant::Column::UserId])
+                        .update_columns([
+                            room_participant::Column::AnsweringConnectionId,
+                            room_participant::Column::AnsweringConnectionServerId,
+                            room_participant::Column::AnsweringConnectionLost,
+                        ])
+                        .to_owned(),
+                )
                 .exec(&*tx)
                 .await?;
-            if result.rows_affected == 0 {
-                Err(anyhow!("room does not exist or was already joined"))?
             } else {
-                let room = self.get_room(room_id, &tx).await?;
-                Ok(room)
+                let result = room_participant::Entity::update_many()
+                    .filter(
+                        Condition::all()
+                            .add(room_participant::Column::RoomId.eq(room_id))
+                            .add(room_participant::Column::UserId.eq(user_id))
+                            .add(room_participant::Column::AnsweringConnectionId.is_null()),
+                    )
+                    .set(room_participant::ActiveModel {
+                        answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+                        answering_connection_server_id: ActiveValue::set(Some(ServerId(
+                            connection.owner_id as i32,
+                        ))),
+                        answering_connection_lost: ActiveValue::set(false),
+                        ..Default::default()
+                    })
+                    .exec(&*tx)
+                    .await?;
+                if result.rows_affected == 0 {
+                    Err(anyhow!("room does not exist or was already joined"))?;
+                }
             }
+
+            let room = self.get_room(room_id, &tx).await?;
+            let channel_members = if let Some(channel_id) = channel_id {
+                self.get_channel_members_internal(channel_id, &tx).await?
+            } else {
+                Vec::new()
+            };
+            Ok(JoinRoom {
+                room,
+                channel_id,
+                channel_members,
+            })
         })
         .await
     }
@@ -1653,9 +1749,17 @@ impl Database {
                 });
             }
 
-            let room = self.get_room(room_id, &tx).await?;
+            let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+            let channel_members = if let Some(channel_id) = channel_id {
+                self.get_channel_members_internal(channel_id, &tx).await?
+            } else {
+                Vec::new()
+            };
+
             Ok(RejoinedRoom {
                 room,
+                channel_id,
+                channel_members,
                 rejoined_projects,
                 reshared_projects,
             })
@@ -1796,15 +1900,29 @@ impl Database {
                     .exec(&*tx)
                     .await?;
 
-                let room = self.get_room(room_id, &tx).await?;
-                if room.participants.is_empty() {
-                    room::Entity::delete_by_id(room_id).exec(&*tx).await?;
-                }
+                let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+                let deleted = if room.participants.is_empty() {
+                    let result = room::Entity::delete_by_id(room_id)
+                        .filter(room::Column::ChannelId.is_null())
+                        .exec(&*tx)
+                        .await?;
+                    result.rows_affected > 0
+                } else {
+                    false
+                };
 
+                let channel_members = if let Some(channel_id) = channel_id {
+                    self.get_channel_members_internal(channel_id, &tx).await?
+                } else {
+                    Vec::new()
+                };
                 let left_room = LeftRoom {
                     room,
+                    channel_id,
+                    channel_members,
                     left_projects,
                     canceled_calls_to_user_ids,
+                    deleted,
                 };
 
                 if left_room.room.participants.is_empty() {
@@ -1998,8 +2116,16 @@ impl Database {
             }),
         })
     }
-
     async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result<proto::Room> {
+        let (_, room) = self.get_channel_room(room_id, tx).await?;
+        Ok(room)
+    }
+
+    async fn get_channel_room(
+        &self,
+        room_id: RoomId,
+        tx: &DatabaseTransaction,
+    ) -> Result<(Option<ChannelId>, proto::Room)> {
         let db_room = room::Entity::find_by_id(room_id)
             .one(tx)
             .await?
@@ -2103,13 +2229,16 @@ impl Database {
             });
         }
 
-        Ok(proto::Room {
-            id: db_room.id.to_proto(),
-            live_kit_room: db_room.live_kit_room,
-            participants: participants.into_values().collect(),
-            pending_participants,
-            followers,
-        })
+        Ok((
+            db_room.channel_id,
+            proto::Room {
+                id: db_room.id.to_proto(),
+                live_kit_room: db_room.live_kit_room,
+                participants: participants.into_values().collect(),
+                pending_participants,
+                followers,
+            },
+        ))
     }
 
     // projects
@@ -3027,6 +3156,693 @@ impl Database {
         .await
     }
 
+    // channels
+
+    pub async fn create_root_channel(
+        &self,
+        name: &str,
+        live_kit_room: &str,
+        creator_id: UserId,
+    ) -> Result<ChannelId> {
+        self.create_channel(name, None, live_kit_room, creator_id)
+            .await
+    }
+
+    pub async fn create_channel(
+        &self,
+        name: &str,
+        parent: Option<ChannelId>,
+        live_kit_room: &str,
+        creator_id: UserId,
+    ) -> Result<ChannelId> {
+        let name = Self::sanitize_channel_name(name)?;
+        self.transaction(move |tx| async move {
+            if let Some(parent) = parent {
+                self.check_user_is_channel_admin(parent, creator_id, &*tx)
+                    .await?;
+            }
+
+            let channel = channel::ActiveModel {
+                name: ActiveValue::Set(name.to_string()),
+                ..Default::default()
+            }
+            .insert(&*tx)
+            .await?;
+
+            let channel_paths_stmt;
+            if let Some(parent) = parent {
+                let sql = r#"
+                    INSERT INTO channel_paths
+                    (id_path, channel_id)
+                    SELECT
+                        id_path || $1 || '/', $2
+                    FROM
+                        channel_paths
+                    WHERE
+                        channel_id = $3
+                "#;
+                channel_paths_stmt = Statement::from_sql_and_values(
+                    self.pool.get_database_backend(),
+                    sql,
+                    [
+                        channel.id.to_proto().into(),
+                        channel.id.to_proto().into(),
+                        parent.to_proto().into(),
+                    ],
+                );
+                tx.execute(channel_paths_stmt).await?;
+            } else {
+                channel_path::Entity::insert(channel_path::ActiveModel {
+                    channel_id: ActiveValue::Set(channel.id),
+                    id_path: ActiveValue::Set(format!("/{}/", channel.id)),
+                })
+                .exec(&*tx)
+                .await?;
+            }
+
+            channel_member::ActiveModel {
+                channel_id: ActiveValue::Set(channel.id),
+                user_id: ActiveValue::Set(creator_id),
+                accepted: ActiveValue::Set(true),
+                admin: ActiveValue::Set(true),
+                ..Default::default()
+            }
+            .insert(&*tx)
+            .await?;
+
+            room::ActiveModel {
+                channel_id: ActiveValue::Set(Some(channel.id)),
+                live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
+                ..Default::default()
+            }
+            .insert(&*tx)
+            .await?;
+
+            Ok(channel.id)
+        })
+        .await
+    }
+
+    pub async fn remove_channel(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+    ) -> Result<(Vec<ChannelId>, Vec<UserId>)> {
+        self.transaction(move |tx| async move {
+            self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+                .await?;
+
+            // Don't remove descendant channels that have additional parents.
+            let mut channels_to_remove = self.get_channel_descendants([channel_id], &*tx).await?;
+            {
+                let mut channels_to_keep = channel_path::Entity::find()
+                    .filter(
+                        channel_path::Column::ChannelId
+                            .is_in(
+                                channels_to_remove
+                                    .keys()
+                                    .copied()
+                                    .filter(|&id| id != channel_id),
+                            )
+                            .and(
+                                channel_path::Column::IdPath
+                                    .not_like(&format!("%/{}/%", channel_id)),
+                            ),
+                    )
+                    .stream(&*tx)
+                    .await?;
+                while let Some(row) = channels_to_keep.next().await {
+                    let row = row?;
+                    channels_to_remove.remove(&row.channel_id);
+                }
+            }
+
+            let channel_ancestors = self.get_channel_ancestors(channel_id, &*tx).await?;
+            let members_to_notify: Vec<UserId> = channel_member::Entity::find()
+                .filter(channel_member::Column::ChannelId.is_in(channel_ancestors))
+                .select_only()
+                .column(channel_member::Column::UserId)
+                .distinct()
+                .into_values::<_, QueryUserIds>()
+                .all(&*tx)
+                .await?;
+
+            channel::Entity::delete_many()
+                .filter(channel::Column::Id.is_in(channels_to_remove.keys().copied()))
+                .exec(&*tx)
+                .await?;
+
+            Ok((channels_to_remove.into_keys().collect(), members_to_notify))
+        })
+        .await
+    }
+
+    pub async fn invite_channel_member(
+        &self,
+        channel_id: ChannelId,
+        invitee_id: UserId,
+        inviter_id: UserId,
+        is_admin: bool,
+    ) -> Result<()> {
+        self.transaction(move |tx| async move {
+            self.check_user_is_channel_admin(channel_id, inviter_id, &*tx)
+                .await?;
+
+            channel_member::ActiveModel {
+                channel_id: ActiveValue::Set(channel_id),
+                user_id: ActiveValue::Set(invitee_id),
+                accepted: ActiveValue::Set(false),
+                admin: ActiveValue::Set(is_admin),
+                ..Default::default()
+            }
+            .insert(&*tx)
+            .await?;
+
+            Ok(())
+        })
+        .await
+    }
+
+    fn sanitize_channel_name(name: &str) -> Result<&str> {
+        let new_name = name.trim().trim_start_matches('#');
+        if new_name == "" {
+            Err(anyhow!("channel name can't be blank"))?;
+        }
+        Ok(new_name)
+    }
+
+    pub async fn rename_channel(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        new_name: &str,
+    ) -> Result<String> {
+        self.transaction(move |tx| async move {
+            let new_name = Self::sanitize_channel_name(new_name)?.to_string();
+
+            self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+                .await?;
+
+            channel::ActiveModel {
+                id: ActiveValue::Unchanged(channel_id),
+                name: ActiveValue::Set(new_name.clone()),
+                ..Default::default()
+            }
+            .update(&*tx)
+            .await?;
+
+            Ok(new_name)
+        })
+        .await
+    }
+
+    pub async fn respond_to_channel_invite(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        accept: bool,
+    ) -> Result<()> {
+        self.transaction(move |tx| async move {
+            let rows_affected = if accept {
+                channel_member::Entity::update_many()
+                    .set(channel_member::ActiveModel {
+                        accepted: ActiveValue::Set(accept),
+                        ..Default::default()
+                    })
+                    .filter(
+                        channel_member::Column::ChannelId
+                            .eq(channel_id)
+                            .and(channel_member::Column::UserId.eq(user_id))
+                            .and(channel_member::Column::Accepted.eq(false)),
+                    )
+                    .exec(&*tx)
+                    .await?
+                    .rows_affected
+            } else {
+                channel_member::ActiveModel {
+                    channel_id: ActiveValue::Unchanged(channel_id),
+                    user_id: ActiveValue::Unchanged(user_id),
+                    ..Default::default()
+                }
+                .delete(&*tx)
+                .await?
+                .rows_affected
+            };
+
+            if rows_affected == 0 {
+                Err(anyhow!("no such invitation"))?;
+            }
+
+            Ok(())
+        })
+        .await
+    }
+
+    pub async fn remove_channel_member(
+        &self,
+        channel_id: ChannelId,
+        member_id: UserId,
+        remover_id: UserId,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
+                .await?;
+
+            let result = channel_member::Entity::delete_many()
+                .filter(
+                    channel_member::Column::ChannelId
+                        .eq(channel_id)
+                        .and(channel_member::Column::UserId.eq(member_id)),
+                )
+                .exec(&*tx)
+                .await?;
+
+            if result.rows_affected == 0 {
+                Err(anyhow!("no such member"))?;
+            }
+
+            Ok(())
+        })
+        .await
+    }
+
+    pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result<Vec<Channel>> {
+        self.transaction(|tx| async move {
+            let channel_invites = channel_member::Entity::find()
+                .filter(
+                    channel_member::Column::UserId
+                        .eq(user_id)
+                        .and(channel_member::Column::Accepted.eq(false)),
+                )
+                .all(&*tx)
+                .await?;
+
+            let channels = channel::Entity::find()
+                .filter(
+                    channel::Column::Id.is_in(
+                        channel_invites
+                            .into_iter()
+                            .map(|channel_member| channel_member.channel_id),
+                    ),
+                )
+                .all(&*tx)
+                .await?;
+
+            let channels = channels
+                .into_iter()
+                .map(|channel| Channel {
+                    id: channel.id,
+                    name: channel.name,
+                    parent_id: None,
+                })
+                .collect();
+
+            Ok(channels)
+        })
+        .await
+    }
+
+    pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
+        self.transaction(|tx| async move {
+            let tx = tx;
+
+            let channel_memberships = channel_member::Entity::find()
+                .filter(
+                    channel_member::Column::UserId
+                        .eq(user_id)
+                        .and(channel_member::Column::Accepted.eq(true)),
+                )
+                .all(&*tx)
+                .await?;
+
+            let parents_by_child_id = self
+                .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
+                .await?;
+
+            let channels_with_admin_privileges = channel_memberships
+                .iter()
+                .filter_map(|membership| membership.admin.then_some(membership.channel_id))
+                .collect();
+
+            let mut channels = Vec::with_capacity(parents_by_child_id.len());
+            {
+                let mut rows = channel::Entity::find()
+                    .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied()))
+                    .stream(&*tx)
+                    .await?;
+                while let Some(row) = rows.next().await {
+                    let row = row?;
+                    channels.push(Channel {
+                        id: row.id,
+                        name: row.name,
+                        parent_id: parents_by_child_id.get(&row.id).copied().flatten(),
+                    });
+                }
+            }
+
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryUserIdsAndChannelIds {
+                ChannelId,
+                UserId,
+            }
+
+            let mut channel_participants: HashMap<ChannelId, Vec<UserId>> = HashMap::default();
+            {
+                let mut rows = room_participant::Entity::find()
+                    .inner_join(room::Entity)
+                    .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
+                    .select_only()
+                    .column(room::Column::ChannelId)
+                    .column(room_participant::Column::UserId)
+                    .into_values::<_, QueryUserIdsAndChannelIds>()
+                    .stream(&*tx)
+                    .await?;
+                while let Some(row) = rows.next().await {
+                    let row: (ChannelId, UserId) = row?;
+                    channel_participants.entry(row.0).or_default().push(row.1)
+                }
+            }
+
+            Ok(ChannelsForUser {
+                channels,
+                channel_participants,
+                channels_with_admin_privileges,
+            })
+        })
+        .await
+    }
+
+    pub async fn get_channel_members(&self, id: ChannelId) -> Result<Vec<UserId>> {
+        self.transaction(|tx| async move { self.get_channel_members_internal(id, &*tx).await })
+            .await
+    }
+
+    pub async fn set_channel_member_admin(
+        &self,
+        channel_id: ChannelId,
+        from: UserId,
+        for_user: UserId,
+        admin: bool,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            self.check_user_is_channel_admin(channel_id, from, &*tx)
+                .await?;
+
+            let result = channel_member::Entity::update_many()
+                .filter(
+                    channel_member::Column::ChannelId
+                        .eq(channel_id)
+                        .and(channel_member::Column::UserId.eq(for_user)),
+                )
+                .set(channel_member::ActiveModel {
+                    admin: ActiveValue::set(admin),
+                    ..Default::default()
+                })
+                .exec(&*tx)
+                .await?;
+
+            if result.rows_affected == 0 {
+                Err(anyhow!("no such member"))?;
+            }
+
+            Ok(())
+        })
+        .await
+    }
+
+    pub async fn get_channel_member_details(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+    ) -> Result<Vec<proto::ChannelMember>> {
+        self.transaction(|tx| async move {
+            self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+                .await?;
+
+            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+            enum QueryMemberDetails {
+                UserId,
+                Admin,
+                IsDirectMember,
+                Accepted,
+            }
+
+            let tx = tx;
+            let ancestor_ids = self.get_channel_ancestors(channel_id, &*tx).await?;
+            let mut stream = channel_member::Entity::find()
+                .distinct()
+                .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied()))
+                .select_only()
+                .column(channel_member::Column::UserId)
+                .column(channel_member::Column::Admin)
+                .column_as(
+                    channel_member::Column::ChannelId.eq(channel_id),
+                    QueryMemberDetails::IsDirectMember,
+                )
+                .column(channel_member::Column::Accepted)
+                .order_by_asc(channel_member::Column::UserId)
+                .into_values::<_, QueryMemberDetails>()
+                .stream(&*tx)
+                .await?;
+
+            let mut rows = Vec::<proto::ChannelMember>::new();
+            while let Some(row) = stream.next().await {
+                let (user_id, is_admin, is_direct_member, is_invite_accepted): (
+                    UserId,
+                    bool,
+                    bool,
+                    bool,
+                ) = row?;
+                let kind = match (is_direct_member, is_invite_accepted) {
+                    (true, true) => proto::channel_member::Kind::Member,
+                    (true, false) => proto::channel_member::Kind::Invitee,
+                    (false, true) => proto::channel_member::Kind::AncestorMember,
+                    (false, false) => continue,
+                };
+                let user_id = user_id.to_proto();
+                let kind = kind.into();
+                if let Some(last_row) = rows.last_mut() {
+                    if last_row.user_id == user_id {
+                        if is_direct_member {
+                            last_row.kind = kind;
+                            last_row.admin = is_admin;
+                        }
+                        continue;
+                    }
+                }
+                rows.push(proto::ChannelMember {
+                    user_id,
+                    kind,
+                    admin: is_admin,
+                });
+            }
+
+            Ok(rows)
+        })
+        .await
+    }
+
+    pub async fn get_channel_members_internal(
+        &self,
+        id: ChannelId,
+        tx: &DatabaseTransaction,
+    ) -> Result<Vec<UserId>> {
+        let ancestor_ids = self.get_channel_ancestors(id, tx).await?;
+        let user_ids = channel_member::Entity::find()
+            .distinct()
+            .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied()))
+            .select_only()
+            .column(channel_member::Column::UserId)
+            .into_values::<_, QueryUserIds>()
+            .all(&*tx)
+            .await?;
+        Ok(user_ids)
+    }
+
+    async fn check_user_is_channel_member(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        tx: &DatabaseTransaction,
+    ) -> Result<()> {
+        let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
+        channel_member::Entity::find()
+            .filter(
+                channel_member::Column::ChannelId
+                    .is_in(channel_ids)
+                    .and(channel_member::Column::UserId.eq(user_id)),
+            )
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?;
+        Ok(())
+    }
+
+    async fn check_user_is_channel_admin(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+        tx: &DatabaseTransaction,
+    ) -> Result<()> {
+        let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
+        channel_member::Entity::find()
+            .filter(
+                channel_member::Column::ChannelId
+                    .is_in(channel_ids)
+                    .and(channel_member::Column::UserId.eq(user_id))
+                    .and(channel_member::Column::Admin.eq(true)),
+            )
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!("user is not a channel admin or channel does not exist"))?;
+        Ok(())
+    }
+
+    async fn get_channel_ancestors(
+        &self,
+        channel_id: ChannelId,
+        tx: &DatabaseTransaction,
+    ) -> Result<Vec<ChannelId>> {
+        let paths = channel_path::Entity::find()
+            .filter(channel_path::Column::ChannelId.eq(channel_id))
+            .all(tx)
+            .await?;
+        let mut channel_ids = Vec::new();
+        for path in paths {
+            for id in path.id_path.trim_matches('/').split('/') {
+                if let Ok(id) = id.parse() {
+                    let id = ChannelId::from_proto(id);
+                    if let Err(ix) = channel_ids.binary_search(&id) {
+                        channel_ids.insert(ix, id);
+                    }
+                }
+            }
+        }
+        Ok(channel_ids)
+    }
+
+    async fn get_channel_descendants(
+        &self,
+        channel_ids: impl IntoIterator<Item = ChannelId>,
+        tx: &DatabaseTransaction,
+    ) -> Result<HashMap<ChannelId, Option<ChannelId>>> {
+        let mut values = String::new();
+        for id in channel_ids {
+            if !values.is_empty() {
+                values.push_str(", ");
+            }
+            write!(&mut values, "({})", id).unwrap();
+        }
+
+        if values.is_empty() {
+            return Ok(HashMap::default());
+        }
+
+        let sql = format!(
+            r#"
+            SELECT
+                descendant_paths.*
+            FROM
+                channel_paths parent_paths, channel_paths descendant_paths
+            WHERE
+                parent_paths.channel_id IN ({values}) AND
+                descendant_paths.id_path LIKE (parent_paths.id_path || '%')
+        "#
+        );
+
+        let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
+
+        let mut parents_by_child_id = HashMap::default();
+        let mut paths = channel_path::Entity::find()
+            .from_raw_sql(stmt)
+            .stream(tx)
+            .await?;
+
+        while let Some(path) = paths.next().await {
+            let path = path?;
+            let ids = path.id_path.trim_matches('/').split('/');
+            let mut parent_id = None;
+            for id in ids {
+                if let Ok(id) = id.parse() {
+                    let id = ChannelId::from_proto(id);
+                    if id == path.channel_id {
+                        break;
+                    }
+                    parent_id = Some(id);
+                }
+            }
+            parents_by_child_id.insert(path.channel_id, parent_id);
+        }
+
+        Ok(parents_by_child_id)
+    }
+
+    /// Returns the channel with the given ID and:
+    /// - true if the user is a member
+    /// - false if the user hasn't accepted the invitation yet
+    pub async fn get_channel(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+    ) -> Result<Option<(Channel, bool)>> {
+        self.transaction(|tx| async move {
+            let tx = tx;
+
+            let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
+
+            if let Some(channel) = channel {
+                if self
+                    .check_user_is_channel_member(channel_id, user_id, &*tx)
+                    .await
+                    .is_err()
+                {
+                    return Ok(None);
+                }
+
+                let channel_membership = channel_member::Entity::find()
+                    .filter(
+                        channel_member::Column::ChannelId
+                            .eq(channel_id)
+                            .and(channel_member::Column::UserId.eq(user_id)),
+                    )
+                    .one(&*tx)
+                    .await?;
+
+                let is_accepted = channel_membership
+                    .map(|membership| membership.accepted)
+                    .unwrap_or(false);
+
+                Ok(Some((
+                    Channel {
+                        id: channel.id,
+                        name: channel.name,
+                        parent_id: None,
+                    },
+                    is_accepted,
+                )))
+            } else {
+                Ok(None)
+            }
+        })
+        .await
+    }
+
+    pub async fn room_id_for_channel(&self, channel_id: ChannelId) -> Result<RoomId> {
+        self.transaction(|tx| async move {
+            let tx = tx;
+            let room = channel::Model {
+                id: channel_id,
+                ..Default::default()
+            }
+            .find_related(room::Entity)
+            .one(&*tx)
+            .await?
+            .ok_or_else(|| anyhow!("invalid channel"))?;
+            Ok(room.id)
+        })
+        .await
+    }
+
     async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
     where
         F: Send + Fn(TransactionHandle) -> Fut,
@@ -3257,6 +4073,12 @@ impl<T> DerefMut for RoomGuard<T> {
     }
 }
 
+impl<T> RoomGuard<T> {
+    pub fn into_inner(self) -> T {
+        self.data
+    }
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub struct NewUserParams {
     pub github_login: String,
@@ -3272,6 +4094,20 @@ pub struct NewUserResult {
     pub signup_device_id: Option<String>,
 }
 
+#[derive(FromQueryResult, Debug, PartialEq)]
+pub struct Channel {
+    pub id: ChannelId,
+    pub name: String,
+    pub parent_id: Option<ChannelId>,
+}
+
+#[derive(Debug, PartialEq)]
+pub struct ChannelsForUser {
+    pub channels: Vec<Channel>,
+    pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
+    pub channels_with_admin_privileges: HashSet<ChannelId>,
+}
+
 fn random_invite_code() -> String {
     nanoid::nanoid!(16)
 }

crates/collab/src/db/channel.rs 🔗

@@ -0,0 +1,38 @@
+use super::ChannelId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channels")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id: ChannelId,
+    pub name: String,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(has_one = "super::room::Entity")]
+    Room,
+    #[sea_orm(has_many = "super::channel_member::Entity")]
+    Member,
+}
+
+impl Related<super::channel_member::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Member.def()
+    }
+}
+
+impl Related<super::room::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Room.def()
+    }
+}
+
+// impl Related<super::follower::Entity> for Entity {
+//     fn to() -> RelationDef {
+//         Relation::Follower.def()
+//     }
+// }

crates/collab/src/db/channel_member.rs 🔗

@@ -0,0 +1,61 @@
+use crate::db::channel_member;
+
+use super::{ChannelId, ChannelMemberId, UserId};
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channel_members")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id: ChannelMemberId,
+    pub channel_id: ChannelId,
+    pub user_id: UserId,
+    pub accepted: bool,
+    pub admin: bool,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+    #[sea_orm(
+        belongs_to = "super::channel::Entity",
+        from = "Column::ChannelId",
+        to = "super::channel::Column::Id"
+    )]
+    Channel,
+    #[sea_orm(
+        belongs_to = "super::user::Entity",
+        from = "Column::UserId",
+        to = "super::user::Column::Id"
+    )]
+    User,
+}
+
+impl Related<super::channel::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Channel.def()
+    }
+}
+
+impl Related<super::user::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::User.def()
+    }
+}
+
+#[derive(Debug)]
+pub struct UserToChannel;
+
+impl Linked for UserToChannel {
+    type FromEntity = super::user::Entity;
+
+    type ToEntity = super::channel::Entity;
+
+    fn link(&self) -> Vec<RelationDef> {
+        vec![
+            channel_member::Relation::User.def().rev(),
+            channel_member::Relation::Channel.def(),
+        ]
+    }
+}

crates/collab/src/db/channel_path.rs 🔗

@@ -0,0 +1,15 @@
+use super::ChannelId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channel_paths")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id_path: String,
+    pub channel_id: ChannelId,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}

crates/collab/src/db/room.rs 🔗

@@ -1,12 +1,13 @@
-use super::RoomId;
+use super::{ChannelId, RoomId};
 use sea_orm::entity::prelude::*;
 
-#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]
 #[sea_orm(table_name = "rooms")]
 pub struct Model {
     #[sea_orm(primary_key)]
     pub id: RoomId,
     pub live_kit_room: String,
+    pub channel_id: Option<ChannelId>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -17,6 +18,12 @@ pub enum Relation {
     Project,
     #[sea_orm(has_many = "super::follower::Entity")]
     Follower,
+    #[sea_orm(
+        belongs_to = "super::channel::Entity",
+        from = "Column::ChannelId",
+        to = "super::channel::Column::Id"
+    )]
+    Channel,
 }
 
 impl Related<super::room_participant::Entity> for Entity {
@@ -37,4 +44,10 @@ impl Related<super::follower::Entity> for Entity {
     }
 }
 
+impl Related<super::channel::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::Channel.def()
+    }
+}
+
 impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/db/tests.rs 🔗

@@ -879,6 +879,453 @@ async fn test_invite_codes() {
     assert!(db.has_contact(user5, user1).await.unwrap());
 }
 
+test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
+    let a_id = db
+        .create_user(
+            "user1@example.com",
+            false,
+            NewUserParams {
+                github_login: "user1".into(),
+                github_user_id: 5,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    let b_id = db
+        .create_user(
+            "user2@example.com",
+            false,
+            NewUserParams {
+                github_login: "user2".into(),
+                github_user_id: 6,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap();
+
+    // Make sure that people cannot read channels they haven't been invited to
+    assert!(db.get_channel(zed_id, b_id).await.unwrap().is_none());
+
+    db.invite_channel_member(zed_id, b_id, a_id, false)
+        .await
+        .unwrap();
+
+    db.respond_to_channel_invite(zed_id, b_id, true)
+        .await
+        .unwrap();
+
+    let crdb_id = db
+        .create_channel("crdb", Some(zed_id), "2", a_id)
+        .await
+        .unwrap();
+    let livestreaming_id = db
+        .create_channel("livestreaming", Some(zed_id), "3", a_id)
+        .await
+        .unwrap();
+    let replace_id = db
+        .create_channel("replace", Some(zed_id), "4", a_id)
+        .await
+        .unwrap();
+
+    let mut members = db.get_channel_members(replace_id).await.unwrap();
+    members.sort();
+    assert_eq!(members, &[a_id, b_id]);
+
+    let rust_id = db.create_root_channel("rust", "5", a_id).await.unwrap();
+    let cargo_id = db
+        .create_channel("cargo", Some(rust_id), "6", a_id)
+        .await
+        .unwrap();
+
+    let cargo_ra_id = db
+        .create_channel("cargo-ra", Some(cargo_id), "7", a_id)
+        .await
+        .unwrap();
+
+    let result = db.get_channels_for_user(a_id).await.unwrap();
+    assert_eq!(
+        result.channels,
+        vec![
+            Channel {
+                id: zed_id,
+                name: "zed".to_string(),
+                parent_id: None,
+            },
+            Channel {
+                id: crdb_id,
+                name: "crdb".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: livestreaming_id,
+                name: "livestreaming".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: replace_id,
+                name: "replace".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: rust_id,
+                name: "rust".to_string(),
+                parent_id: None,
+            },
+            Channel {
+                id: cargo_id,
+                name: "cargo".to_string(),
+                parent_id: Some(rust_id),
+            },
+            Channel {
+                id: cargo_ra_id,
+                name: "cargo-ra".to_string(),
+                parent_id: Some(cargo_id),
+            }
+        ]
+    );
+
+    let result = db.get_channels_for_user(b_id).await.unwrap();
+    assert_eq!(
+        result.channels,
+        vec![
+            Channel {
+                id: zed_id,
+                name: "zed".to_string(),
+                parent_id: None,
+            },
+            Channel {
+                id: crdb_id,
+                name: "crdb".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: livestreaming_id,
+                name: "livestreaming".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: replace_id,
+                name: "replace".to_string(),
+                parent_id: Some(zed_id),
+            },
+        ]
+    );
+
+    // Update member permissions
+    let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await;
+    assert!(set_subchannel_admin.is_err());
+    let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await;
+    assert!(set_channel_admin.is_ok());
+
+    let result = db.get_channels_for_user(b_id).await.unwrap();
+    assert_eq!(
+        result.channels,
+        vec![
+            Channel {
+                id: zed_id,
+                name: "zed".to_string(),
+                parent_id: None,
+            },
+            Channel {
+                id: crdb_id,
+                name: "crdb".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: livestreaming_id,
+                name: "livestreaming".to_string(),
+                parent_id: Some(zed_id),
+            },
+            Channel {
+                id: replace_id,
+                name: "replace".to_string(),
+                parent_id: Some(zed_id),
+            },
+        ]
+    );
+
+    // Remove a single channel
+    db.remove_channel(crdb_id, a_id).await.unwrap();
+    assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none());
+
+    // Remove a channel tree
+    let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap();
+    channel_ids.sort();
+    assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
+    assert_eq!(user_ids, &[a_id]);
+
+    assert!(db.get_channel(rust_id, a_id).await.unwrap().is_none());
+    assert!(db.get_channel(cargo_id, a_id).await.unwrap().is_none());
+    assert!(db.get_channel(cargo_ra_id, a_id).await.unwrap().is_none());
+});
+
+test_both_dbs!(
+    test_joining_channels_postgres,
+    test_joining_channels_sqlite,
+    db,
+    {
+        let owner_id = db.create_server("test").await.unwrap().0 as u32;
+
+        let user_1 = db
+            .create_user(
+                "user1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user1".into(),
+                    github_user_id: 5,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+        let user_2 = db
+            .create_user(
+                "user2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user2".into(),
+                    github_user_id: 6,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let channel_1 = db
+            .create_root_channel("channel_1", "1", user_1)
+            .await
+            .unwrap();
+        let room_1 = db.room_id_for_channel(channel_1).await.unwrap();
+
+        // can join a room with membership to its channel
+        let joined_room = db
+            .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 })
+            .await
+            .unwrap();
+        assert_eq!(joined_room.room.participants.len(), 1);
+
+        drop(joined_room);
+        // cannot join a room without membership to its channel
+        assert!(db
+            .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 })
+            .await
+            .is_err());
+    }
+);
+
+test_both_dbs!(
+    test_channel_invites_postgres,
+    test_channel_invites_sqlite,
+    db,
+    {
+        db.create_server("test").await.unwrap();
+
+        let user_1 = db
+            .create_user(
+                "user1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user1".into(),
+                    github_user_id: 5,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+        let user_2 = db
+            .create_user(
+                "user2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user2".into(),
+                    github_user_id: 6,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let user_3 = db
+            .create_user(
+                "user3@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user3".into(),
+                    github_user_id: 7,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let channel_1_1 = db
+            .create_root_channel("channel_1", "1", user_1)
+            .await
+            .unwrap();
+
+        let channel_1_2 = db
+            .create_root_channel("channel_2", "2", user_1)
+            .await
+            .unwrap();
+
+        db.invite_channel_member(channel_1_1, user_2, user_1, false)
+            .await
+            .unwrap();
+        db.invite_channel_member(channel_1_2, user_2, user_1, false)
+            .await
+            .unwrap();
+        db.invite_channel_member(channel_1_1, user_3, user_1, true)
+            .await
+            .unwrap();
+
+        let user_2_invites = db
+            .get_channel_invites_for_user(user_2) // -> [channel_1_1, channel_1_2]
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|channel| channel.id)
+            .collect::<Vec<_>>();
+
+        assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]);
+
+        let user_3_invites = db
+            .get_channel_invites_for_user(user_3) // -> [channel_1_1]
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|channel| channel.id)
+            .collect::<Vec<_>>();
+
+        assert_eq!(user_3_invites, &[channel_1_1]);
+
+        let members = db
+            .get_channel_member_details(channel_1_1, user_1)
+            .await
+            .unwrap();
+        assert_eq!(
+            members,
+            &[
+                proto::ChannelMember {
+                    user_id: user_1.to_proto(),
+                    kind: proto::channel_member::Kind::Member.into(),
+                    admin: true,
+                },
+                proto::ChannelMember {
+                    user_id: user_2.to_proto(),
+                    kind: proto::channel_member::Kind::Invitee.into(),
+                    admin: false,
+                },
+                proto::ChannelMember {
+                    user_id: user_3.to_proto(),
+                    kind: proto::channel_member::Kind::Invitee.into(),
+                    admin: true,
+                },
+            ]
+        );
+
+        db.respond_to_channel_invite(channel_1_1, user_2, true)
+            .await
+            .unwrap();
+
+        let channel_1_3 = db
+            .create_channel("channel_3", Some(channel_1_1), "1", user_1)
+            .await
+            .unwrap();
+
+        let members = db
+            .get_channel_member_details(channel_1_3, user_1)
+            .await
+            .unwrap();
+        assert_eq!(
+            members,
+            &[
+                proto::ChannelMember {
+                    user_id: user_1.to_proto(),
+                    kind: proto::channel_member::Kind::Member.into(),
+                    admin: true,
+                },
+                proto::ChannelMember {
+                    user_id: user_2.to_proto(),
+                    kind: proto::channel_member::Kind::AncestorMember.into(),
+                    admin: false,
+                },
+            ]
+        );
+    }
+);
+
+test_both_dbs!(
+    test_channel_renames_postgres,
+    test_channel_renames_sqlite,
+    db,
+    {
+        db.create_server("test").await.unwrap();
+
+        let user_1 = db
+            .create_user(
+                "user1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user1".into(),
+                    github_user_id: 5,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let user_2 = db
+            .create_user(
+                "user2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user2".into(),
+                    github_user_id: 6,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let zed_id = db.create_root_channel("zed", "1", user_1).await.unwrap();
+
+        db.rename_channel(zed_id, user_1, "#zed-archive")
+            .await
+            .unwrap();
+
+        let zed_archive_id = zed_id;
+
+        let (channel, _) = db
+            .get_channel(zed_archive_id, user_1)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(channel.name, "zed-archive");
+
+        let non_permissioned_rename = db
+            .rename_channel(zed_archive_id, user_2, "hacked-lol")
+            .await;
+        assert!(non_permissioned_rename.is_err());
+
+        let bad_name_rename = db.rename_channel(zed_id, user_1, "#").await;
+        assert!(bad_name_rename.is_err())
+    }
+);
+
 #[gpui::test]
 async fn test_multiple_signup_overwrite() {
     let test_db = TestDb::postgres(build_background_executor());

crates/collab/src/db/user.rs 🔗

@@ -26,6 +26,8 @@ pub enum Relation {
     RoomParticipant,
     #[sea_orm(has_many = "super::project::Entity")]
     HostedProjects,
+    #[sea_orm(has_many = "super::channel_member::Entity")]
+    ChannelMemberships,
 }
 
 impl Related<super::access_token::Entity> for Entity {
@@ -46,4 +48,10 @@ impl Related<super::project::Entity> for Entity {
     }
 }
 
+impl Related<super::channel_member::Entity> for Entity {
+    fn to() -> RelationDef {
+        Relation::ChannelMemberships.def()
+    }
+}
+
 impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/rpc.rs 🔗

@@ -2,7 +2,7 @@ mod connection_pool;
 
 use crate::{
     auth,
-    db::{self, Database, ProjectId, RoomId, ServerId, User, UserId},
+    db::{self, ChannelId, ChannelsForUser, Database, ProjectId, RoomId, ServerId, User, UserId},
     executor::Executor,
     AppState, Result,
 };
@@ -34,7 +34,10 @@ use futures::{
 use lazy_static::lazy_static;
 use prometheus::{register_int_gauge, IntGauge};
 use rpc::{
-    proto::{self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage},
+    proto::{
+        self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo,
+        RequestMessage,
+    },
     Connection, ConnectionId, Peer, Receipt, TypedEnvelope,
 };
 use serde::{Serialize, Serializer};
@@ -239,6 +242,15 @@ impl Server {
             .add_request_handler(request_contact)
             .add_request_handler(remove_contact)
             .add_request_handler(respond_to_contact_request)
+            .add_request_handler(create_channel)
+            .add_request_handler(remove_channel)
+            .add_request_handler(invite_channel_member)
+            .add_request_handler(remove_channel_member)
+            .add_request_handler(set_channel_member_admin)
+            .add_request_handler(rename_channel)
+            .add_request_handler(get_channel_members)
+            .add_request_handler(respond_to_channel_invite)
+            .add_request_handler(join_channel)
             .add_request_handler(follow)
             .add_message_handler(unfollow)
             .add_message_handler(update_followers)
@@ -287,6 +299,15 @@ impl Server {
                                 "refreshed room"
                             );
                             room_updated(&refreshed_room.room, &peer);
+                            if let Some(channel_id) = refreshed_room.channel_id {
+                                channel_updated(
+                                    channel_id,
+                                    &refreshed_room.room,
+                                    &refreshed_room.channel_members,
+                                    &peer,
+                                    &*pool.lock(),
+                                );
+                            }
                             contacts_to_update
                                 .extend(refreshed_room.stale_participant_user_ids.iter().copied());
                             contacts_to_update
@@ -508,15 +529,21 @@ impl Server {
                 this.app_state.db.set_user_connected_once(user_id, true).await?;
             }
 
-            let (contacts, invite_code) = future::try_join(
+            let (contacts, invite_code, channels_for_user, channel_invites) = future::try_join4(
                 this.app_state.db.get_contacts(user_id),
-                this.app_state.db.get_invite_code_for_user(user_id)
+                this.app_state.db.get_invite_code_for_user(user_id),
+                this.app_state.db.get_channels_for_user(user_id),
+                this.app_state.db.get_channel_invites_for_user(user_id)
             ).await?;
 
             {
                 let mut pool = this.connection_pool.lock();
                 pool.add_connection(connection_id, user_id, user.admin);
                 this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
+                this.peer.send(connection_id, build_initial_channels_update(
+                    channels_for_user,
+                    channel_invites
+                ))?;
 
                 if let Some((code, count)) = invite_code {
                     this.peer.send(connection_id, proto::UpdateInviteInfo {
@@ -857,42 +884,41 @@ async fn create_room(
     session: Session,
 ) -> Result<()> {
     let live_kit_room = nanoid::nanoid!(30);
-    let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() {
-        if let Some(_) = live_kit
-            .create_room(live_kit_room.clone())
-            .await
-            .trace_err()
-        {
-            if let Some(token) = live_kit
-                .room_token(&live_kit_room, &session.user_id.to_string())
-                .trace_err()
-            {
-                Some(proto::LiveKitConnectionInfo {
-                    server_url: live_kit.url().into(),
-                    token,
-                })
-            } else {
-                None
-            }
-        } else {
-            None
-        }
-    } else {
-        None
-    };
 
-    {
-        let room = session
-            .db()
-            .await
-            .create_room(session.user_id, session.connection_id, &live_kit_room)
-            .await?;
+    let live_kit_connection_info = {
+        let live_kit_room = live_kit_room.clone();
+        let live_kit = session.live_kit_client.as_ref();
 
-        response.send(proto::CreateRoomResponse {
-            room: Some(room.clone()),
-            live_kit_connection_info,
-        })?;
+        util::async_iife!({
+            let live_kit = live_kit?;
+
+            live_kit
+                .create_room(live_kit_room.clone())
+                .await
+                .trace_err()?;
+
+            let token = live_kit
+                .room_token(&live_kit_room, &session.user_id.to_string())
+                .trace_err()?;
+
+            Some(proto::LiveKitConnectionInfo {
+                server_url: live_kit.url().into(),
+                token,
+            })
+        })
     }
+    .await;
+
+    let room = session
+        .db()
+        .await
+        .create_room(session.user_id, session.connection_id, &live_kit_room)
+        .await?;
+
+    response.send(proto::CreateRoomResponse {
+        room: Some(room.clone()),
+        live_kit_connection_info,
+    })?;
 
     update_user_contacts(session.user_id, &session).await?;
     Ok(())
@@ -904,16 +930,26 @@ async fn join_room(
     session: Session,
 ) -> Result<()> {
     let room_id = RoomId::from_proto(request.id);
-    let room = {
+    let joined_room = {
         let room = session
             .db()
             .await
             .join_room(room_id, session.user_id, session.connection_id)
             .await?;
-        room_updated(&room, &session.peer);
-        room.clone()
+        room_updated(&room.room, &session.peer);
+        room.into_inner()
     };
 
+    if let Some(channel_id) = joined_room.channel_id {
+        channel_updated(
+            channel_id,
+            &joined_room.room,
+            &joined_room.channel_members,
+            &session.peer,
+            &*session.connection_pool().await,
+        )
+    }
+
     for connection_id in session
         .connection_pool()
         .await
@@ -932,7 +968,10 @@ async fn join_room(
 
     let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() {
         if let Some(token) = live_kit
-            .room_token(&room.live_kit_room, &session.user_id.to_string())
+            .room_token(
+                &joined_room.room.live_kit_room,
+                &session.user_id.to_string(),
+            )
             .trace_err()
         {
             Some(proto::LiveKitConnectionInfo {
@@ -947,7 +986,8 @@ async fn join_room(
     };
 
     response.send(proto::JoinRoomResponse {
-        room: Some(room),
+        room: Some(joined_room.room),
+        channel_id: joined_room.channel_id.map(|id| id.to_proto()),
         live_kit_connection_info,
     })?;
 
@@ -960,6 +1000,9 @@ async fn rejoin_room(
     response: Response<proto::RejoinRoom>,
     session: Session,
 ) -> Result<()> {
+    let room;
+    let channel_id;
+    let channel_members;
     {
         let mut rejoined_room = session
             .db()
@@ -1121,6 +1164,22 @@ async fn rejoin_room(
                 )?;
             }
         }
+
+        let rejoined_room = rejoined_room.into_inner();
+
+        room = rejoined_room.room;
+        channel_id = rejoined_room.channel_id;
+        channel_members = rejoined_room.channel_members;
+    }
+
+    if let Some(channel_id) = channel_id {
+        channel_updated(
+            channel_id,
+            &room,
+            &channel_members,
+            &session.peer,
+            &*session.connection_pool().await,
+        );
     }
 
     update_user_contacts(session.user_id, &session).await?;
@@ -1282,11 +1341,12 @@ async fn update_participant_location(
     let location = request
         .location
         .ok_or_else(|| anyhow!("invalid location"))?;
-    let room = session
-        .db()
-        .await
+
+    let db = session.db().await;
+    let room = db
         .update_room_participant_location(room_id, session.connection_id, location)
         .await?;
+
     room_updated(&room, &session.peer);
     response.send(proto::Ack {})?;
     Ok(())
@@ -2084,6 +2144,340 @@ async fn remove_contact(
     Ok(())
 }
 
+async fn create_channel(
+    request: proto::CreateChannel,
+    response: Response<proto::CreateChannel>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
+
+    if let Some(live_kit) = session.live_kit_client.as_ref() {
+        live_kit.create_room(live_kit_room.clone()).await?;
+    }
+
+    let parent_id = request.parent_id.map(|id| ChannelId::from_proto(id));
+    let id = db
+        .create_channel(&request.name, parent_id, &live_kit_room, session.user_id)
+        .await?;
+
+    let channel = proto::Channel {
+        id: id.to_proto(),
+        name: request.name,
+        parent_id: request.parent_id,
+    };
+
+    response.send(proto::ChannelResponse {
+        channel: Some(channel.clone()),
+    })?;
+
+    let mut update = proto::UpdateChannels::default();
+    update.channels.push(channel);
+
+    let user_ids_to_notify = if let Some(parent_id) = parent_id {
+        db.get_channel_members(parent_id).await?
+    } else {
+        vec![session.user_id]
+    };
+
+    let connection_pool = session.connection_pool().await;
+    for user_id in user_ids_to_notify {
+        for connection_id in connection_pool.user_connection_ids(user_id) {
+            let mut update = update.clone();
+            if user_id == session.user_id {
+                update.channel_permissions.push(proto::ChannelPermission {
+                    channel_id: id.to_proto(),
+                    is_admin: true,
+                });
+            }
+            session.peer.send(connection_id, update)?;
+        }
+    }
+
+    Ok(())
+}
+
+async fn remove_channel(
+    request: proto::RemoveChannel,
+    response: Response<proto::RemoveChannel>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+
+    let channel_id = request.channel_id;
+    let (removed_channels, member_ids) = db
+        .remove_channel(ChannelId::from_proto(channel_id), session.user_id)
+        .await?;
+    response.send(proto::Ack {})?;
+
+    // Notify members of removed channels
+    let mut update = proto::UpdateChannels::default();
+    update
+        .remove_channels
+        .extend(removed_channels.into_iter().map(|id| id.to_proto()));
+
+    let connection_pool = session.connection_pool().await;
+    for member_id in member_ids {
+        for connection_id in connection_pool.user_connection_ids(member_id) {
+            session.peer.send(connection_id, update.clone())?;
+        }
+    }
+
+    Ok(())
+}
+
+async fn invite_channel_member(
+    request: proto::InviteChannelMember,
+    response: Response<proto::InviteChannelMember>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let invitee_id = UserId::from_proto(request.user_id);
+    db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin)
+        .await?;
+
+    let (channel, _) = db
+        .get_channel(channel_id, session.user_id)
+        .await?
+        .ok_or_else(|| anyhow!("channel not found"))?;
+
+    let mut update = proto::UpdateChannels::default();
+    update.channel_invitations.push(proto::Channel {
+        id: channel.id.to_proto(),
+        name: channel.name,
+        parent_id: None,
+    });
+    for connection_id in session
+        .connection_pool()
+        .await
+        .user_connection_ids(invitee_id)
+    {
+        session.peer.send(connection_id, update.clone())?;
+    }
+
+    response.send(proto::Ack {})?;
+    Ok(())
+}
+
+async fn remove_channel_member(
+    request: proto::RemoveChannelMember,
+    response: Response<proto::RemoveChannelMember>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let member_id = UserId::from_proto(request.user_id);
+
+    db.remove_channel_member(channel_id, member_id, session.user_id)
+        .await?;
+
+    let mut update = proto::UpdateChannels::default();
+    update.remove_channels.push(channel_id.to_proto());
+
+    for connection_id in session
+        .connection_pool()
+        .await
+        .user_connection_ids(member_id)
+    {
+        session.peer.send(connection_id, update.clone())?;
+    }
+
+    response.send(proto::Ack {})?;
+    Ok(())
+}
+
+async fn set_channel_member_admin(
+    request: proto::SetChannelMemberAdmin,
+    response: Response<proto::SetChannelMemberAdmin>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let member_id = UserId::from_proto(request.user_id);
+    db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin)
+        .await?;
+
+    let (channel, has_accepted) = db
+        .get_channel(channel_id, member_id)
+        .await?
+        .ok_or_else(|| anyhow!("channel not found"))?;
+
+    let mut update = proto::UpdateChannels::default();
+    if has_accepted {
+        update.channel_permissions.push(proto::ChannelPermission {
+            channel_id: channel.id.to_proto(),
+            is_admin: request.admin,
+        });
+    }
+
+    for connection_id in session
+        .connection_pool()
+        .await
+        .user_connection_ids(member_id)
+    {
+        session.peer.send(connection_id, update.clone())?;
+    }
+
+    response.send(proto::Ack {})?;
+    Ok(())
+}
+
+async fn rename_channel(
+    request: proto::RenameChannel,
+    response: Response<proto::RenameChannel>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let new_name = db
+        .rename_channel(channel_id, session.user_id, &request.name)
+        .await?;
+
+    let channel = proto::Channel {
+        id: request.channel_id,
+        name: new_name,
+        parent_id: None,
+    };
+    response.send(proto::ChannelResponse {
+        channel: Some(channel.clone()),
+    })?;
+    let mut update = proto::UpdateChannels::default();
+    update.channels.push(channel);
+
+    let member_ids = db.get_channel_members(channel_id).await?;
+
+    let connection_pool = session.connection_pool().await;
+    for member_id in member_ids {
+        for connection_id in connection_pool.user_connection_ids(member_id) {
+            session.peer.send(connection_id, update.clone())?;
+        }
+    }
+
+    Ok(())
+}
+
+async fn get_channel_members(
+    request: proto::GetChannelMembers,
+    response: Response<proto::GetChannelMembers>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    let members = db
+        .get_channel_member_details(channel_id, session.user_id)
+        .await?;
+    response.send(proto::GetChannelMembersResponse { members })?;
+    Ok(())
+}
+
+async fn respond_to_channel_invite(
+    request: proto::RespondToChannelInvite,
+    response: Response<proto::RespondToChannelInvite>,
+    session: Session,
+) -> Result<()> {
+    let db = session.db().await;
+    let channel_id = ChannelId::from_proto(request.channel_id);
+    db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
+        .await?;
+
+    let mut update = proto::UpdateChannels::default();
+    update
+        .remove_channel_invitations
+        .push(channel_id.to_proto());
+    if request.accept {
+        let result = db.get_channels_for_user(session.user_id).await?;
+        update
+            .channels
+            .extend(result.channels.into_iter().map(|channel| proto::Channel {
+                id: channel.id.to_proto(),
+                name: channel.name,
+                parent_id: channel.parent_id.map(ChannelId::to_proto),
+            }));
+        update
+            .channel_participants
+            .extend(
+                result
+                    .channel_participants
+                    .into_iter()
+                    .map(|(channel_id, user_ids)| proto::ChannelParticipants {
+                        channel_id: channel_id.to_proto(),
+                        participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(),
+                    }),
+            );
+        update
+            .channel_permissions
+            .extend(
+                result
+                    .channels_with_admin_privileges
+                    .into_iter()
+                    .map(|channel_id| proto::ChannelPermission {
+                        channel_id: channel_id.to_proto(),
+                        is_admin: true,
+                    }),
+            );
+    }
+    session.peer.send(session.connection_id, update)?;
+    response.send(proto::Ack {})?;
+
+    Ok(())
+}
+
+async fn join_channel(
+    request: proto::JoinChannel,
+    response: Response<proto::JoinChannel>,
+    session: Session,
+) -> Result<()> {
+    let channel_id = ChannelId::from_proto(request.channel_id);
+
+    let joined_room = {
+        leave_room_for_session(&session).await?;
+        let db = session.db().await;
+
+        let room_id = db.room_id_for_channel(channel_id).await?;
+
+        let joined_room = db
+            .join_room(room_id, session.user_id, session.connection_id)
+            .await?;
+
+        let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
+            let token = live_kit
+                .room_token(
+                    &joined_room.room.live_kit_room,
+                    &session.user_id.to_string(),
+                )
+                .trace_err()?;
+
+            Some(LiveKitConnectionInfo {
+                server_url: live_kit.url().into(),
+                token,
+            })
+        });
+
+        response.send(proto::JoinRoomResponse {
+            room: Some(joined_room.room.clone()),
+            channel_id: joined_room.channel_id.map(|id| id.to_proto()),
+            live_kit_connection_info,
+        })?;
+
+        room_updated(&joined_room.room, &session.peer);
+
+        joined_room.into_inner()
+    };
+
+    channel_updated(
+        channel_id,
+        &joined_room.room,
+        &joined_room.channel_members,
+        &session.peer,
+        &*session.connection_pool().await,
+    );
+
+    update_user_contacts(session.user_id, &session).await?;
+
+    Ok(())
+}
+
 async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> {
     let project_id = ProjectId::from_proto(request.project_id);
     let project_connection_ids = session
@@ -2154,6 +2548,52 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage {
     }
 }
 
+fn build_initial_channels_update(
+    channels: ChannelsForUser,
+    channel_invites: Vec<db::Channel>,
+) -> proto::UpdateChannels {
+    let mut update = proto::UpdateChannels::default();
+
+    for channel in channels.channels {
+        update.channels.push(proto::Channel {
+            id: channel.id.to_proto(),
+            name: channel.name,
+            parent_id: channel.parent_id.map(|id| id.to_proto()),
+        });
+    }
+
+    for (channel_id, participants) in channels.channel_participants {
+        update
+            .channel_participants
+            .push(proto::ChannelParticipants {
+                channel_id: channel_id.to_proto(),
+                participant_user_ids: participants.into_iter().map(|id| id.to_proto()).collect(),
+            });
+    }
+
+    update
+        .channel_permissions
+        .extend(
+            channels
+                .channels_with_admin_privileges
+                .into_iter()
+                .map(|id| proto::ChannelPermission {
+                    channel_id: id.to_proto(),
+                    is_admin: true,
+                }),
+        );
+
+    for channel in channel_invites {
+        update.channel_invitations.push(proto::Channel {
+            id: channel.id.to_proto(),
+            name: channel.name,
+            parent_id: None,
+        });
+    }
+
+    update
+}
+
 fn build_initial_contacts_update(
     contacts: Vec<db::Contact>,
     pool: &ConnectionPool,
@@ -2218,8 +2658,42 @@ fn room_updated(room: &proto::Room, peer: &Peer) {
     );
 }
 
+fn channel_updated(
+    channel_id: ChannelId,
+    room: &proto::Room,
+    channel_members: &[UserId],
+    peer: &Peer,
+    pool: &ConnectionPool,
+) {
+    let participants = room
+        .participants
+        .iter()
+        .map(|p| p.user_id)
+        .collect::<Vec<_>>();
+
+    broadcast(
+        None,
+        channel_members
+            .iter()
+            .flat_map(|user_id| pool.user_connection_ids(*user_id)),
+        |peer_id| {
+            peer.send(
+                peer_id.into(),
+                proto::UpdateChannels {
+                    channel_participants: vec![proto::ChannelParticipants {
+                        channel_id: channel_id.to_proto(),
+                        participant_user_ids: participants.clone(),
+                    }],
+                    ..Default::default()
+                },
+            )
+        },
+    );
+}
+
 async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> {
     let db = session.db().await;
+
     let contacts = db.get_contacts(user_id).await?;
     let busy = db.is_user_busy(user_id).await?;
 
@@ -2259,6 +2733,10 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
     let canceled_calls_to_user_ids;
     let live_kit_room;
     let delete_live_kit_room;
+    let room;
+    let channel_members;
+    let channel_id;
+
     if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? {
         contacts_to_update.insert(session.user_id);
 
@@ -2266,15 +2744,29 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
             project_left(project, session);
         }
 
-        room_updated(&left_room.room, &session.peer);
         room_id = RoomId::from_proto(left_room.room.id);
         canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids);
         live_kit_room = mem::take(&mut left_room.room.live_kit_room);
-        delete_live_kit_room = left_room.room.participants.is_empty();
+        delete_live_kit_room = left_room.deleted;
+        room = mem::take(&mut left_room.room);
+        channel_members = mem::take(&mut left_room.channel_members);
+        channel_id = left_room.channel_id;
+
+        room_updated(&room, &session.peer);
     } else {
         return Ok(());
     }
 
+    if let Some(channel_id) = channel_id {
+        channel_updated(
+            channel_id,
+            &room,
+            &channel_members,
+            &session.peer,
+            &*session.connection_pool().await,
+        );
+    }
+
     {
         let pool = session.connection_pool().await;
         for canceled_user_id in canceled_calls_to_user_ids {

crates/collab/src/tests.rs 🔗

@@ -5,14 +5,15 @@ use crate::{
     AppState,
 };
 use anyhow::anyhow;
-use call::ActiveCall;
+use call::{ActiveCall, Room};
 use client::{
-    self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
+    self, proto::PeerId, ChannelStore, Client, Connection, Credentials, EstablishConnectionError,
+    UserStore,
 };
 use collections::{HashMap, HashSet};
 use fs::FakeFs;
 use futures::{channel::oneshot, StreamExt as _};
-use gpui::{executor::Deterministic, ModelHandle, TestAppContext, WindowHandle};
+use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle};
 use language::LanguageRegistry;
 use parking_lot::Mutex;
 use project::{Project, WorktreeId};
@@ -30,6 +31,7 @@ use std::{
 use util::http::FakeHttpClient;
 use workspace::Workspace;
 
+mod channel_tests;
 mod integration_tests;
 mod randomized_integration_tests;
 
@@ -98,6 +100,9 @@ impl TestServer {
 
     async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
         cx.update(|cx| {
+            if cx.has_global::<SettingsStore>() {
+                panic!("Same cx used to create two test clients")
+            }
             cx.set_global(SettingsStore::test(cx));
         });
 
@@ -183,13 +188,16 @@ impl TestServer {
 
         let fs = FakeFs::new(cx.background());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+        let channel_store =
+            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let app_state = Arc::new(workspace::AppState {
             client: client.clone(),
             user_store: user_store.clone(),
+            channel_store: channel_store.clone(),
             languages: Arc::new(LanguageRegistry::test()),
             fs: fs.clone(),
             build_window_options: |_, _, _| Default::default(),
-            initialize_workspace: |_, _, _, _| unimplemented!(),
+            initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             background_actions: || &[],
         });
 
@@ -210,12 +218,9 @@ impl TestServer {
             .unwrap();
 
         let client = TestClient {
-            client,
+            app_state,
             username: name.to_string(),
             state: Default::default(),
-            user_store,
-            fs,
-            language_registry: Arc::new(LanguageRegistry::test()),
         };
         client.wait_for_current_user(cx).await;
         client
@@ -243,6 +248,7 @@ impl TestServer {
             let (client_a, cx_a) = left.last_mut().unwrap();
             for (client_b, cx_b) in right {
                 client_a
+                    .app_state
                     .user_store
                     .update(*cx_a, |store, cx| {
                         store.request_contact(client_b.user_id().unwrap(), cx)
@@ -251,6 +257,7 @@ impl TestServer {
                     .unwrap();
                 cx_a.foreground().run_until_parked();
                 client_b
+                    .app_state
                     .user_store
                     .update(*cx_b, |store, cx| {
                         store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
@@ -261,6 +268,52 @@ impl TestServer {
         }
     }
 
+    async fn make_channel(
+        &self,
+        channel: &str,
+        admin: (&TestClient, &mut TestAppContext),
+        members: &mut [(&TestClient, &mut TestAppContext)],
+    ) -> u64 {
+        let (admin_client, admin_cx) = admin;
+        let channel_id = admin_client
+            .app_state
+            .channel_store
+            .update(admin_cx, |channel_store, cx| {
+                channel_store.create_channel(channel, None, cx)
+            })
+            .await
+            .unwrap();
+
+        for (member_client, member_cx) in members {
+            admin_client
+                .app_state
+                .channel_store
+                .update(admin_cx, |channel_store, cx| {
+                    channel_store.invite_member(
+                        channel_id,
+                        member_client.user_id().unwrap(),
+                        false,
+                        cx,
+                    )
+                })
+                .await
+                .unwrap();
+
+            admin_cx.foreground().run_until_parked();
+
+            member_client
+                .app_state
+                .channel_store
+                .update(*member_cx, |channels, _| {
+                    channels.respond_to_channel_invite(channel_id, true)
+                })
+                .await
+                .unwrap();
+        }
+
+        channel_id
+    }
+
     async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
         self.make_contacts(clients).await;
 
@@ -312,12 +365,9 @@ impl Drop for TestServer {
 }
 
 struct TestClient {
-    client: Arc<Client>,
     username: String,
     state: RefCell<TestClientState>,
-    pub user_store: ModelHandle<UserStore>,
-    language_registry: Arc<LanguageRegistry>,
-    fs: Arc<FakeFs>,
+    app_state: Arc<workspace::AppState>,
 }
 
 #[derive(Default)]
@@ -331,7 +381,7 @@ impl Deref for TestClient {
     type Target = Arc<Client>;
 
     fn deref(&self) -> &Self::Target {
-        &self.client
+        &self.app_state.client
     }
 }
 
@@ -342,22 +392,45 @@ struct ContactsSummary {
 }
 
 impl TestClient {
+    pub fn fs(&self) -> &FakeFs {
+        self.app_state.fs.as_fake()
+    }
+
+    pub fn channel_store(&self) -> &ModelHandle<ChannelStore> {
+        &self.app_state.channel_store
+    }
+
+    pub fn user_store(&self) -> &ModelHandle<UserStore> {
+        &self.app_state.user_store
+    }
+
+    pub fn language_registry(&self) -> &Arc<LanguageRegistry> {
+        &self.app_state.languages
+    }
+
+    pub fn client(&self) -> &Arc<Client> {
+        &self.app_state.client
+    }
+
     pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
         UserId::from_proto(
-            self.user_store
+            self.app_state
+                .user_store
                 .read_with(cx, |user_store, _| user_store.current_user().unwrap().id),
         )
     }
 
     async fn wait_for_current_user(&self, cx: &TestAppContext) {
         let mut authed_user = self
+            .app_state
             .user_store
             .read_with(cx, |user_store, _| user_store.watch_current_user());
         while authed_user.next().await.unwrap().is_none() {}
     }
 
     async fn clear_contacts(&self, cx: &mut TestAppContext) {
-        self.user_store
+        self.app_state
+            .user_store
             .update(cx, |store, _| store.clear_contacts())
             .await;
     }
@@ -395,23 +468,25 @@ impl TestClient {
     }
 
     fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary {
-        self.user_store.read_with(cx, |store, _| ContactsSummary {
-            current: store
-                .contacts()
-                .iter()
-                .map(|contact| contact.user.github_login.clone())
-                .collect(),
-            outgoing_requests: store
-                .outgoing_contact_requests()
-                .iter()
-                .map(|user| user.github_login.clone())
-                .collect(),
-            incoming_requests: store
-                .incoming_contact_requests()
-                .iter()
-                .map(|user| user.github_login.clone())
-                .collect(),
-        })
+        self.app_state
+            .user_store
+            .read_with(cx, |store, _| ContactsSummary {
+                current: store
+                    .contacts()
+                    .iter()
+                    .map(|contact| contact.user.github_login.clone())
+                    .collect(),
+                outgoing_requests: store
+                    .outgoing_contact_requests()
+                    .iter()
+                    .map(|user| user.github_login.clone())
+                    .collect(),
+                incoming_requests: store
+                    .incoming_contact_requests()
+                    .iter()
+                    .map(|user| user.github_login.clone())
+                    .collect(),
+            })
     }
 
     async fn build_local_project(
@@ -421,10 +496,10 @@ impl TestClient {
     ) -> (ModelHandle<Project>, WorktreeId) {
         let project = cx.update(|cx| {
             Project::local(
-                self.client.clone(),
-                self.user_store.clone(),
-                self.language_registry.clone(),
-                self.fs.clone(),
+                self.client().clone(),
+                self.app_state.user_store.clone(),
+                self.app_state.languages.clone(),
+                self.app_state.fs.clone(),
                 cx,
             )
         });
@@ -450,8 +525,8 @@ impl TestClient {
         room.update(guest_cx, |room, cx| {
             room.join_project(
                 host_project_id,
-                self.language_registry.clone(),
-                self.fs.clone(),
+                self.app_state.languages.clone(),
+                self.app_state.fs.clone(),
                 cx,
             )
         })
@@ -464,12 +539,36 @@ impl TestClient {
         project: &ModelHandle<Project>,
         cx: &mut TestAppContext,
     ) -> WindowHandle<Workspace> {
-        cx.add_window(|cx| Workspace::test_new(project.clone(), cx))
+        cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx))
     }
 }
 
 impl Drop for TestClient {
     fn drop(&mut self) {
-        self.client.teardown();
+        self.app_state.client.teardown();
     }
 }
+
+#[derive(Debug, Eq, PartialEq)]
+struct RoomParticipants {
+    remote: Vec<String>,
+    pending: Vec<String>,
+}
+
+fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomParticipants {
+    room.read_with(cx, |room, _| {
+        let mut remote = room
+            .remote_participants()
+            .iter()
+            .map(|(_, participant)| participant.user.github_login.clone())
+            .collect::<Vec<_>>();
+        let mut pending = room
+            .pending_participants()
+            .iter()
+            .map(|user| user.github_login.clone())
+            .collect::<Vec<_>>();
+        remote.sort();
+        pending.sort();
+        RoomParticipants { remote, pending }
+    })
+}

crates/collab/src/tests/channel_tests.rs 🔗

@@ -0,0 +1,820 @@
+use crate::{
+    rpc::RECONNECT_TIMEOUT,
+    tests::{room_participants, RoomParticipants, TestServer},
+};
+use call::ActiveCall;
+use client::{ChannelId, ChannelMembership, ChannelStore, User};
+use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
+use rpc::{proto, RECEIVE_TIMEOUT};
+use std::sync::Arc;
+
+#[gpui::test]
+async fn test_core_channels(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let channel_a_id = client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.create_channel("channel-a", None, cx)
+        })
+        .await
+        .unwrap();
+    let channel_b_id = client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.create_channel("channel-b", Some(channel_a_id), cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_channels(
+        client_a.channel_store(),
+        cx_a,
+        &[
+            ExpectedChannel {
+                id: channel_a_id,
+                name: "channel-a".to_string(),
+                depth: 0,
+                user_is_admin: true,
+            },
+            ExpectedChannel {
+                id: channel_b_id,
+                name: "channel-b".to_string(),
+                depth: 1,
+                user_is_admin: true,
+            },
+        ],
+    );
+
+    client_b.channel_store().read_with(cx_b, |channels, _| {
+        assert!(channels.channels().collect::<Vec<_>>().is_empty())
+    });
+
+    // Invite client B to channel A as client A.
+    client_a
+        .channel_store()
+        .update(cx_a, |store, cx| {
+            assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
+
+            let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx);
+
+            // Make sure we're synchronously storing the pending invite
+            assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
+            invite
+        })
+        .await
+        .unwrap();
+
+    // Client A sees that B has been invited.
+    deterministic.run_until_parked();
+    assert_channel_invitations(
+        client_b.channel_store(),
+        cx_b,
+        &[ExpectedChannel {
+            id: channel_a_id,
+            name: "channel-a".to_string(),
+            depth: 0,
+            user_is_admin: false,
+        }],
+    );
+
+    let members = client_a
+        .channel_store()
+        .update(cx_a, |store, cx| {
+            assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
+            store.get_channel_member_details(channel_a_id, cx)
+        })
+        .await
+        .unwrap();
+    assert_members_eq(
+        &members,
+        &[
+            (
+                client_a.user_id().unwrap(),
+                true,
+                proto::channel_member::Kind::Member,
+            ),
+            (
+                client_b.user_id().unwrap(),
+                false,
+                proto::channel_member::Kind::Invitee,
+            ),
+        ],
+    );
+
+    // Client B accepts the invitation.
+    client_b
+        .channel_store()
+        .update(cx_b, |channels, _| {
+            channels.respond_to_channel_invite(channel_a_id, true)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+
+    // Client B now sees that they are a member of channel A and its existing subchannels.
+    assert_channel_invitations(client_b.channel_store(), cx_b, &[]);
+    assert_channels(
+        client_b.channel_store(),
+        cx_b,
+        &[
+            ExpectedChannel {
+                id: channel_a_id,
+                name: "channel-a".to_string(),
+                user_is_admin: false,
+                depth: 0,
+            },
+            ExpectedChannel {
+                id: channel_b_id,
+                name: "channel-b".to_string(),
+                user_is_admin: false,
+                depth: 1,
+            },
+        ],
+    );
+
+    let channel_c_id = client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.create_channel("channel-c", Some(channel_b_id), cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_channels(
+        client_b.channel_store(),
+        cx_b,
+        &[
+            ExpectedChannel {
+                id: channel_a_id,
+                name: "channel-a".to_string(),
+                user_is_admin: false,
+                depth: 0,
+            },
+            ExpectedChannel {
+                id: channel_b_id,
+                name: "channel-b".to_string(),
+                user_is_admin: false,
+                depth: 1,
+            },
+            ExpectedChannel {
+                id: channel_c_id,
+                name: "channel-c".to_string(),
+                user_is_admin: false,
+                depth: 2,
+            },
+        ],
+    );
+
+    // Update client B's membership to channel A to be an admin.
+    client_a
+        .channel_store()
+        .update(cx_a, |store, cx| {
+            store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+
+    // Observe that client B is now an admin of channel A, and that
+    // their admin priveleges extend to subchannels of channel A.
+    assert_channel_invitations(client_b.channel_store(), cx_b, &[]);
+    assert_channels(
+        client_b.channel_store(),
+        cx_b,
+        &[
+            ExpectedChannel {
+                id: channel_a_id,
+                name: "channel-a".to_string(),
+                depth: 0,
+                user_is_admin: true,
+            },
+            ExpectedChannel {
+                id: channel_b_id,
+                name: "channel-b".to_string(),
+                depth: 1,
+                user_is_admin: true,
+            },
+            ExpectedChannel {
+                id: channel_c_id,
+                name: "channel-c".to_string(),
+                depth: 2,
+                user_is_admin: true,
+            },
+        ],
+    );
+
+    // Client A deletes the channel, deletion also deletes subchannels.
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, _| {
+            channel_store.remove_channel(channel_b_id)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_channels(
+        client_a.channel_store(),
+        cx_a,
+        &[ExpectedChannel {
+            id: channel_a_id,
+            name: "channel-a".to_string(),
+            depth: 0,
+            user_is_admin: true,
+        }],
+    );
+    assert_channels(
+        client_b.channel_store(),
+        cx_b,
+        &[ExpectedChannel {
+            id: channel_a_id,
+            name: "channel-a".to_string(),
+            depth: 0,
+            user_is_admin: true,
+        }],
+    );
+
+    // Remove client B
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.remove_member(channel_a_id, client_b.user_id().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    // Client A still has their channel
+    assert_channels(
+        client_a.channel_store(),
+        cx_a,
+        &[ExpectedChannel {
+            id: channel_a_id,
+            name: "channel-a".to_string(),
+            depth: 0,
+            user_is_admin: true,
+        }],
+    );
+
+    // Client B no longer has access to the channel
+    assert_channels(client_b.channel_store(), cx_b, &[]);
+
+    // When disconnected, client A sees no channels.
+    server.forbid_connections();
+    server.disconnect_client(client_a.peer_id().unwrap());
+    deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+    assert_channels(client_a.channel_store(), cx_a, &[]);
+
+    server.allow_connections();
+    deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+    assert_channels(
+        client_a.channel_store(),
+        cx_a,
+        &[ExpectedChannel {
+            id: channel_a_id,
+            name: "channel-a".to_string(),
+            depth: 0,
+            user_is_admin: true,
+        }],
+    );
+}
+
+#[track_caller]
+fn assert_participants_eq(participants: &[Arc<User>], expected_partitipants: &[u64]) {
+    assert_eq!(
+        participants.iter().map(|p| p.id).collect::<Vec<_>>(),
+        expected_partitipants
+    );
+}
+
+#[track_caller]
+fn assert_members_eq(
+    members: &[ChannelMembership],
+    expected_members: &[(u64, bool, proto::channel_member::Kind)],
+) {
+    assert_eq!(
+        members
+            .iter()
+            .map(|member| (member.user.id, member.admin, member.kind))
+            .collect::<Vec<_>>(),
+        expected_members
+    );
+}
+
+#[gpui::test]
+async fn test_joining_channel_ancestor_member(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let parent_id = server
+        .make_channel("parent", (&client_a, cx_a), &mut [(&client_b, cx_b)])
+        .await;
+
+    let sub_id = client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.create_channel("sub_channel", Some(parent_id), cx)
+        })
+        .await
+        .unwrap();
+
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    assert!(active_call_b
+        .update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx))
+        .await
+        .is_ok());
+}
+
+#[gpui::test]
+async fn test_channel_room(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+
+    let zed_id = server
+        .make_channel(
+            "zed",
+            (&client_a, cx_a),
+            &mut [(&client_b, cx_b), (&client_c, cx_c)],
+        )
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    active_call_a
+        .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .await
+        .unwrap();
+
+    // Give everyone a chance to observe user A joining
+    deterministic.run_until_parked();
+
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap()],
+        );
+    });
+
+    assert_channels(
+        client_b.channel_store(),
+        cx_b,
+        &[ExpectedChannel {
+            id: zed_id,
+            name: "zed".to_string(),
+            depth: 0,
+            user_is_admin: false,
+        }],
+    );
+    client_b.channel_store().read_with(cx_b, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap()],
+        );
+    });
+
+    client_c.channel_store().read_with(cx_c, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap()],
+        );
+    });
+
+    active_call_b
+        .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+        );
+    });
+
+    client_b.channel_store().read_with(cx_b, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+        );
+    });
+
+    client_c.channel_store().read_with(cx_c, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+        );
+    });
+
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+    room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: vec![]
+        }
+    );
+
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    room_b.read_with(cx_b, |room, _| assert!(room.is_connected()));
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: vec![]
+        }
+    );
+
+    // Make sure that leaving and rejoining works
+
+    active_call_a
+        .update(cx_a, |active_call, cx| active_call.hang_up(cx))
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_b.user_id().unwrap()],
+        );
+    });
+
+    client_b.channel_store().read_with(cx_b, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_b.user_id().unwrap()],
+        );
+    });
+
+    client_c.channel_store().read_with(cx_c, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_b.user_id().unwrap()],
+        );
+    });
+
+    active_call_b
+        .update(cx_b, |active_call, cx| active_call.hang_up(cx))
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(channels.channel_participants(zed_id), &[]);
+    });
+
+    client_b.channel_store().read_with(cx_b, |channels, _| {
+        assert_participants_eq(channels.channel_participants(zed_id), &[]);
+    });
+
+    client_c.channel_store().read_with(cx_c, |channels, _| {
+        assert_participants_eq(channels.channel_participants(zed_id), &[]);
+    });
+
+    active_call_a
+        .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .await
+        .unwrap();
+
+    active_call_b
+        .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+    room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: vec![]
+        }
+    );
+
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    room_b.read_with(cx_b, |room, _| assert!(room.is_connected()));
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: vec![]
+        }
+    );
+}
+
+#[gpui::test]
+async fn test_channel_jumping(deterministic: Arc<Deterministic>, cx_a: &mut TestAppContext) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+
+    let zed_id = server.make_channel("zed", (&client_a, cx_a), &mut []).await;
+    let rust_id = server
+        .make_channel("rust", (&client_a, cx_a), &mut [])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    active_call_a
+        .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+        .await
+        .unwrap();
+
+    // Give everything a chance to observe user A joining
+    deterministic.run_until_parked();
+
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(zed_id),
+            &[client_a.user_id().unwrap()],
+        );
+        assert_participants_eq(channels.channel_participants(rust_id), &[]);
+    });
+
+    active_call_a
+        .update(cx_a, |active_call, cx| {
+            active_call.join_channel(rust_id, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(channels.channel_participants(zed_id), &[]);
+        assert_participants_eq(
+            channels.channel_participants(rust_id),
+            &[client_a.user_id().unwrap()],
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_permissions_update_while_invited(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let rust_id = server
+        .make_channel("rust", (&client_a, cx_a), &mut [])
+        .await;
+
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.invite_member(rust_id, client_b.user_id().unwrap(), false, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    assert_channel_invitations(
+        client_b.channel_store(),
+        cx_b,
+        &[ExpectedChannel {
+            depth: 0,
+            id: rust_id,
+            name: "rust".to_string(),
+            user_is_admin: false,
+        }],
+    );
+    assert_channels(client_b.channel_store(), cx_b, &[]);
+
+    // Update B's invite before they've accepted it
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.set_member_admin(rust_id, client_b.user_id().unwrap(), true, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    assert_channel_invitations(
+        client_b.channel_store(),
+        cx_b,
+        &[ExpectedChannel {
+            depth: 0,
+            id: rust_id,
+            name: "rust".to_string(),
+            user_is_admin: false,
+        }],
+    );
+    assert_channels(client_b.channel_store(), cx_b, &[]);
+}
+
+#[gpui::test]
+async fn test_channel_rename(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+
+    let rust_id = server
+        .make_channel("rust", (&client_a, cx_a), &mut [(&client_b, cx_b)])
+        .await;
+
+    // Rename the channel
+    client_a
+        .channel_store()
+        .update(cx_a, |channel_store, cx| {
+            channel_store.rename(rust_id, "#rust-archive", cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+
+    // Client A sees the channel with its new name.
+    assert_channels(
+        client_a.channel_store(),
+        cx_a,
+        &[ExpectedChannel {
+            depth: 0,
+            id: rust_id,
+            name: "rust-archive".to_string(),
+            user_is_admin: true,
+        }],
+    );
+
+    // Client B sees the channel with its new name.
+    assert_channels(
+        client_b.channel_store(),
+        cx_b,
+        &[ExpectedChannel {
+            depth: 0,
+            id: rust_id,
+            name: "rust-archive".to_string(),
+            user_is_admin: false,
+        }],
+    );
+}
+
+#[gpui::test]
+async fn test_call_from_channel(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+
+    let channel_id = server
+        .make_channel(
+            "x",
+            (&client_a, cx_a),
+            &mut [(&client_b, cx_b), (&client_c, cx_c)],
+        )
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
+    active_call_a
+        .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
+        .await
+        .unwrap();
+
+    // Client A calls client B while in the channel.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+
+    // Client B accepts the call.
+    deterministic.run_until_parked();
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+
+    // Client B sees that they are now in the channel
+    deterministic.run_until_parked();
+    active_call_b.read_with(cx_b, |call, cx| {
+        assert_eq!(call.channel_id(cx), Some(channel_id));
+    });
+    client_b.channel_store().read_with(cx_b, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(channel_id),
+            &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+        );
+    });
+
+    // Clients A and C also see that client B is in the channel.
+    client_a.channel_store().read_with(cx_a, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(channel_id),
+            &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+        );
+    });
+    client_c.channel_store().read_with(cx_c, |channels, _| {
+        assert_participants_eq(
+            channels.channel_participants(channel_id),
+            &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+        );
+    });
+}
+
+#[derive(Debug, PartialEq)]
+struct ExpectedChannel {
+    depth: usize,
+    id: ChannelId,
+    name: String,
+    user_is_admin: bool,
+}
+
+#[track_caller]
+fn assert_channel_invitations(
+    channel_store: &ModelHandle<ChannelStore>,
+    cx: &TestAppContext,
+    expected_channels: &[ExpectedChannel],
+) {
+    let actual = channel_store.read_with(cx, |store, _| {
+        store
+            .channel_invitations()
+            .iter()
+            .map(|channel| ExpectedChannel {
+                depth: 0,
+                name: channel.name.clone(),
+                id: channel.id,
+                user_is_admin: store.is_user_admin(channel.id),
+            })
+            .collect::<Vec<_>>()
+    });
+    assert_eq!(actual, expected_channels);
+}
+
+#[track_caller]
+fn assert_channels(
+    channel_store: &ModelHandle<ChannelStore>,
+    cx: &TestAppContext,
+    expected_channels: &[ExpectedChannel],
+) {
+    let actual = channel_store.read_with(cx, |store, _| {
+        store
+            .channels()
+            .map(|(depth, channel)| ExpectedChannel {
+                depth,
+                name: channel.name.clone(),
+                id: channel.id,
+                user_is_admin: store.is_user_admin(channel.id),
+            })
+            .collect::<Vec<_>>()
+    });
+    assert_eq!(actual, expected_channels);
+}

crates/collab/src/tests/integration_tests.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
-    tests::{TestClient, TestServer},
+    tests::{room_participants, RoomParticipants, TestClient, TestServer},
 };
 use call::{room, ActiveCall, ParticipantLocation, Room};
 use client::{User, RECEIVE_TIMEOUT};
@@ -748,7 +748,7 @@ async fn test_server_restarts(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     client_a
-        .fs
+        .fs()
         .insert_tree("/a", json!({ "a.txt": "a-contents" }))
         .await;
 
@@ -1220,7 +1220,7 @@ async fn test_share_project(
     let active_call_c = cx_c.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -1387,7 +1387,7 @@ async fn test_unshare_project(
     let active_call_b = cx_b.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -1476,7 +1476,7 @@ async fn test_host_disconnect(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -1498,7 +1498,8 @@ async fn test_host_disconnect(
     deterministic.run_until_parked();
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
-    let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+    let window_b =
+        cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
     let workspace_b = window_b.root(cx_b);
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
@@ -1581,7 +1582,7 @@ async fn test_project_reconnect(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1",
             json!({
@@ -1609,7 +1610,7 @@ async fn test_project_reconnect(
         )
         .await;
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-2",
             json!({
@@ -1618,7 +1619,7 @@ async fn test_project_reconnect(
         )
         .await;
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-3",
             json!({
@@ -1698,7 +1699,7 @@ async fn test_project_reconnect(
 
     // While client A is disconnected, add and remove files from client A's project.
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1/dir1/subdir2",
             json!({
@@ -1710,7 +1711,7 @@ async fn test_project_reconnect(
         )
         .await;
     client_a
-        .fs
+        .fs()
         .remove_dir(
             "/root-1/dir1/subdir1".as_ref(),
             RemoveOptions {
@@ -1832,11 +1833,11 @@ async fn test_project_reconnect(
 
     // While client B is disconnected, add and remove files from client A's project
     client_a
-        .fs
+        .fs()
         .insert_file("/root-1/dir1/subdir2/j.txt", "j-contents".into())
         .await;
     client_a
-        .fs
+        .fs()
         .remove_file("/root-1/dir1/subdir2/i.txt".as_ref(), Default::default())
         .await
         .unwrap();
@@ -1922,8 +1923,8 @@ async fn test_active_call_events(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    client_a.fs.insert_tree("/a", json!({})).await;
-    client_b.fs.insert_tree("/b", json!({})).await;
+    client_a.fs().insert_tree("/a", json!({})).await;
+    client_b.fs().insert_tree("/b", json!({})).await;
 
     let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
@@ -2011,8 +2012,8 @@ async fn test_room_location(
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
-    client_a.fs.insert_tree("/a", json!({})).await;
-    client_b.fs.insert_tree("/b", json!({})).await;
+    client_a.fs().insert_tree("/a", json!({})).await;
+    client_b.fs().insert_tree("/b", json!({})).await;
 
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
@@ -2201,12 +2202,12 @@ async fn test_propagate_saves_and_fs_changes(
         Some(tree_sitter_rust::language()),
     ));
     for client in [&client_a, &client_b, &client_c] {
-        client.language_registry.add(rust.clone());
-        client.language_registry.add(javascript.clone());
+        client.language_registry().add(rust.clone());
+        client.language_registry().add(javascript.clone());
     }
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -2276,7 +2277,7 @@ async fn test_propagate_saves_and_fs_changes(
     buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx));
     save_b.await.unwrap();
     assert_eq!(
-        client_a.fs.load("/a/file1.rs".as_ref()).await.unwrap(),
+        client_a.fs().load("/a/file1.rs".as_ref()).await.unwrap(),
         "hi-a, i-am-c, i-am-b, i-am-a"
     );
 
@@ -2287,7 +2288,7 @@ async fn test_propagate_saves_and_fs_changes(
 
     // Make changes on host's file system, see those changes on guest worktrees.
     client_a
-        .fs
+        .fs()
         .rename(
             "/a/file1.rs".as_ref(),
             "/a/file1.js".as_ref(),
@@ -2296,11 +2297,11 @@ async fn test_propagate_saves_and_fs_changes(
         .await
         .unwrap();
     client_a
-        .fs
+        .fs()
         .rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
         .await
         .unwrap();
-    client_a.fs.insert_file("/a/file4", "4".into()).await;
+    client_a.fs().insert_file("/a/file4", "4".into()).await;
     deterministic.run_until_parked();
 
     worktree_a.read_with(cx_a, |tree, _| {
@@ -2394,7 +2395,7 @@ async fn test_git_diff_base_change(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -2438,7 +2439,7 @@ async fn test_git_diff_base_change(
     "
     .unindent();
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/.git"),
         &[(Path::new("a.txt"), diff_base.clone())],
     );
@@ -2483,7 +2484,7 @@ async fn test_git_diff_base_change(
         );
     });
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/.git"),
         &[(Path::new("a.txt"), new_diff_base.clone())],
     );
@@ -2528,7 +2529,7 @@ async fn test_git_diff_base_change(
     "
     .unindent();
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/sub/.git"),
         &[(Path::new("b.txt"), diff_base.clone())],
     );
@@ -2573,7 +2574,7 @@ async fn test_git_diff_base_change(
         );
     });
 
-    client_a.fs.as_fake().set_index_for_repo(
+    client_a.fs().set_index_for_repo(
         Path::new("/dir/sub/.git"),
         &[(Path::new("b.txt"), new_diff_base.clone())],
     );
@@ -2632,7 +2633,7 @@ async fn test_git_branch_name(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -2651,8 +2652,7 @@ async fn test_git_branch_name(
 
     let project_remote = client_b.build_remote_project(project_id, cx_b).await;
     client_a
-        .fs
-        .as_fake()
+        .fs()
         .set_branch_name(Path::new("/dir/.git"), Some("branch-1"));
 
     // Wait for it to catch up to the new branch
@@ -2677,8 +2677,7 @@ async fn test_git_branch_name(
     });
 
     client_a
-        .fs
-        .as_fake()
+        .fs()
         .set_branch_name(Path::new("/dir/.git"), Some("branch-2"));
 
     // Wait for buffer_local_a to receive it
@@ -2717,7 +2716,7 @@ async fn test_git_status_sync(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -2731,7 +2730,7 @@ async fn test_git_status_sync(
     const A_TXT: &'static str = "a.txt";
     const B_TXT: &'static str = "b.txt";
 
-    client_a.fs.as_fake().set_status_for_repo_via_git_operation(
+    client_a.fs().set_status_for_repo_via_git_operation(
         Path::new("/dir/.git"),
         &[
             (&Path::new(A_TXT), GitFileStatus::Added),
@@ -2777,16 +2776,13 @@ async fn test_git_status_sync(
         assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
     });
 
-    client_a
-        .fs
-        .as_fake()
-        .set_status_for_repo_via_working_copy_change(
-            Path::new("/dir/.git"),
-            &[
-                (&Path::new(A_TXT), GitFileStatus::Modified),
-                (&Path::new(B_TXT), GitFileStatus::Modified),
-            ],
-        );
+    client_a.fs().set_status_for_repo_via_working_copy_change(
+        Path::new("/dir/.git"),
+        &[
+            (&Path::new(A_TXT), GitFileStatus::Modified),
+            (&Path::new(B_TXT), GitFileStatus::Modified),
+        ],
+    );
 
     // Wait for buffer_local_a to receive it
     deterministic.run_until_parked();
@@ -2857,7 +2853,7 @@ async fn test_fs_operations(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3130,7 +3126,7 @@ async fn test_local_settings(
 
     // As client A, open a project that contains some local settings files
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3172,7 +3168,7 @@ async fn test_local_settings(
 
     // As client A, update a settings file. As Client B, see the changed settings.
     client_a
-        .fs
+        .fs()
         .insert_file("/dir/.zed/settings.json", r#"{}"#.into())
         .await;
     deterministic.run_until_parked();
@@ -3189,17 +3185,17 @@ async fn test_local_settings(
 
     // As client A, create and remove some settings files. As client B, see the changed settings.
     client_a
-        .fs
+        .fs()
         .remove_file("/dir/.zed/settings.json".as_ref(), Default::default())
         .await
         .unwrap();
     client_a
-        .fs
+        .fs()
         .create_dir("/dir/b/.zed".as_ref())
         .await
         .unwrap();
     client_a
-        .fs
+        .fs()
         .insert_file("/dir/b/.zed/settings.json", r#"{"tab_size": 4}"#.into())
         .await;
     deterministic.run_until_parked();
@@ -3220,11 +3216,11 @@ async fn test_local_settings(
 
     // As client A, change and remove settings files while client B is disconnected.
     client_a
-        .fs
+        .fs()
         .insert_file("/dir/a/.zed/settings.json", r#"{"hard_tabs":true}"#.into())
         .await;
     client_a
-        .fs
+        .fs()
         .remove_file("/dir/b/.zed/settings.json".as_ref(), Default::default())
         .await
         .unwrap();
@@ -3258,7 +3254,7 @@ async fn test_buffer_conflict_after_save(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3320,7 +3316,7 @@ async fn test_buffer_reloading(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3348,7 +3344,7 @@ async fn test_buffer_reloading(
 
     let new_contents = Rope::from("d\ne\nf");
     client_a
-        .fs
+        .fs()
         .save("/dir/a.txt".as_ref(), &new_contents, LineEnding::Windows)
         .await
         .unwrap();
@@ -3377,7 +3373,7 @@ async fn test_editing_while_guest_opens_buffer(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/dir", json!({ "a.txt": "a-contents" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3426,7 +3422,7 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/dir", json!({ "a.txt": "Some text\n" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3520,7 +3516,7 @@ async fn test_leaving_worktree_while_opening_buffer(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/dir", json!({ "a.txt": "a-contents" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3563,7 +3559,7 @@ async fn test_canceling_buffer_opening(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -3619,7 +3615,7 @@ async fn test_leaving_project(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -3707,9 +3703,9 @@ async fn test_leaving_project(
     cx_b.spawn(|cx| {
         Project::remote(
             project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.language_registry.clone(),
+            client_b.app_state.client.clone(),
+            client_b.user_store().clone(),
+            client_b.language_registry().clone(),
             FakeFs::new(cx.background()),
             cx,
         )
@@ -3761,11 +3757,11 @@ async fn test_collaborating_with_diagnostics(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     // Share a project as client A
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -4033,11 +4029,11 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     let file_names = &["one.rs", "two.rs", "three.rs", "four.rs", "five.rs"];
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/test",
             json!({
@@ -4174,10 +4170,10 @@ async fn test_collaborating_with_completion(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -4335,7 +4331,7 @@ async fn test_reloading_buffer_manually(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree("/a", json!({ "a.rs": "let one = 1;" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
@@ -4366,7 +4362,7 @@ async fn test_reloading_buffer_manually(
     buffer_a.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "let six = 6;"));
 
     client_a
-        .fs
+        .fs()
         .save(
             "/a/a.rs".as_ref(),
             &Rope::from("let seven = 7;"),
@@ -4437,14 +4433,14 @@ async fn test_formatting_buffer(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     // Here we insert a fake tree with a directory that exists on disk. This is needed
     // because later we'll invoke a command, which requires passing a working directory
     // that points to a valid location on disk.
     let directory = env::current_dir().unwrap();
     client_a
-        .fs
+        .fs()
         .insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" }))
         .await;
     let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
@@ -4546,10 +4542,10 @@ async fn test_definition(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -4694,10 +4690,10 @@ async fn test_references(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -4790,7 +4786,7 @@ async fn test_project_search(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -4876,7 +4872,7 @@ async fn test_document_highlights(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1",
             json!({
@@ -4895,7 +4891,7 @@ async fn test_document_highlights(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
     let project_id = active_call_a
@@ -4982,7 +4978,7 @@ async fn test_lsp_hover(
     let active_call_a = cx_a.read(ActiveCall::global);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root-1",
             json!({
@@ -5001,7 +4997,7 @@ async fn test_lsp_hover(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
     let project_id = active_call_a
@@ -5100,10 +5096,10 @@ async fn test_project_symbols(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/code",
             json!({
@@ -5211,10 +5207,10 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/root",
             json!({
@@ -5271,6 +5267,7 @@ async fn test_collaborating_with_code_actions(
     deterministic.forbid_parking();
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
+    //
     let client_b = server.create_client(cx_b, "user_b").await;
     server
         .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
@@ -5289,10 +5286,10 @@ async fn test_collaborating_with_code_actions(
         Some(tree_sitter_rust::language()),
     );
     let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -5309,7 +5306,8 @@ async fn test_collaborating_with_code_actions(
 
     // Join the project as client B.
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
-    let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+    let window_b =
+        cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
     let workspace_b = window_b.root(cx_b);
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
@@ -5515,10 +5513,10 @@ async fn test_collaborating_with_renames(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -5534,7 +5532,8 @@ async fn test_collaborating_with_renames(
         .unwrap();
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
 
-    let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+    let window_b =
+        cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
     let workspace_b = window_b.root(cx_b);
     let editor_b = workspace_b
         .update(cx_b, |workspace, cx| {
@@ -5702,10 +5701,10 @@ async fn test_language_server_statuses(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/dir",
             json!({
@@ -6162,7 +6161,7 @@ async fn test_contacts(
 
     // Test removing a contact
     client_b
-        .user_store
+        .user_store()
         .update(cx_b, |store, cx| {
             store.remove_contact(client_c.user_id().unwrap(), cx)
         })
@@ -6185,7 +6184,7 @@ async fn test_contacts(
         client: &TestClient,
         cx: &TestAppContext,
     ) -> Vec<(String, &'static str, &'static str)> {
-        client.user_store.read_with(cx, |store, _| {
+        client.user_store().read_with(cx, |store, _| {
             store
                 .contacts()
                 .iter()
@@ -6228,14 +6227,14 @@ async fn test_contact_requests(
 
     // User A and User C request that user B become their contact.
     client_a
-        .user_store
+        .user_store()
         .update(cx_a, |store, cx| {
             store.request_contact(client_b.user_id().unwrap(), cx)
         })
         .await
         .unwrap();
     client_c
-        .user_store
+        .user_store()
         .update(cx_c, |store, cx| {
             store.request_contact(client_b.user_id().unwrap(), cx)
         })
@@ -6289,7 +6288,7 @@ async fn test_contact_requests(
 
     // User B accepts the request from user A.
     client_b
-        .user_store
+        .user_store()
         .update(cx_b, |store, cx| {
             store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
         })
@@ -6333,7 +6332,7 @@ async fn test_contact_requests(
 
     // User B rejects the request from user C.
     client_b
-        .user_store
+        .user_store()
         .update(cx_b, |store, cx| {
             store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx)
         })
@@ -6415,7 +6414,7 @@ async fn test_basic_following(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -6978,7 +6977,7 @@ async fn test_join_call_after_screen_was_shared(
         .await
         .unwrap();
 
-    client_b.user_store.update(cx_b, |user_store, _| {
+    client_b.user_store().update(cx_b, |user_store, _| {
         user_store.clear_cache();
     });
 
@@ -7038,7 +7037,7 @@ async fn test_following_tab_order(
     cx_b.update(editor::init);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7161,7 +7160,7 @@ async fn test_peers_following_each_other(
 
     // Client A shares a project.
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7334,7 +7333,7 @@ async fn test_auto_unfollowing(
 
     // Client A shares a project.
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7498,7 +7497,7 @@ async fn test_peers_simultaneously_following_each_other(
     cx_a.update(editor::init);
     cx_b.update(editor::init);
 
-    client_a.fs.insert_tree("/a", json!({})).await;
+    client_a.fs().insert_tree("/a", json!({})).await;
     let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
     let project_id = active_call_a
@@ -7575,10 +7574,10 @@ async fn test_on_input_format_from_host_to_guest(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7704,10 +7703,10 @@ async fn test_on_input_format_from_guest_to_host(
             ..Default::default()
         }))
         .await;
-    client_a.language_registry.add(Arc::new(language));
+    client_a.language_registry().add(Arc::new(language));
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -7860,11 +7859,11 @@ async fn test_mutual_editor_inlay_hint_cache_update(
         }))
         .await;
     let language = Arc::new(language);
-    client_a.language_registry.add(Arc::clone(&language));
-    client_b.language_registry.add(language);
+    client_a.language_registry().add(Arc::clone(&language));
+    client_b.language_registry().add(language);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -8170,11 +8169,11 @@ async fn test_inlay_hint_refresh_is_forwarded(
         }))
         .await;
     let language = Arc::new(language);
-    client_a.language_registry.add(Arc::clone(&language));
-    client_b.language_registry.add(language);
+    client_a.language_registry().add(Arc::clone(&language));
+    client_b.language_registry().add(language);
 
     client_a
-        .fs
+        .fs()
         .insert_tree(
             "/a",
             json!({
@@ -8324,30 +8323,6 @@ async fn test_inlay_hint_refresh_is_forwarded(
     });
 }
 
-#[derive(Debug, Eq, PartialEq)]
-struct RoomParticipants {
-    remote: Vec<String>,
-    pending: Vec<String>,
-}
-
-fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomParticipants {
-    room.read_with(cx, |room, _| {
-        let mut remote = room
-            .remote_participants()
-            .iter()
-            .map(|(_, participant)| participant.user.github_login.clone())
-            .collect::<Vec<_>>();
-        let mut pending = room
-            .pending_participants()
-            .iter()
-            .map(|user| user.github_login.clone())
-            .collect::<Vec<_>>();
-        remote.sort();
-        pending.sort();
-        RoomParticipants { remote, pending }
-    })
-}
-
 fn extract_hint_labels(editor: &Editor) -> Vec<String> {
     let mut labels = Vec::new();
     for hint in editor.inlay_hint_cache().hints() {

crates/collab/src/tests/randomized_integration_tests.rs 🔗

@@ -396,9 +396,9 @@ async fn apply_client_operation(
             );
 
             let root_path = Path::new("/").join(&first_root_name);
-            client.fs.create_dir(&root_path).await.unwrap();
+            client.fs().create_dir(&root_path).await.unwrap();
             client
-                .fs
+                .fs()
                 .create_file(&root_path.join("main.rs"), Default::default())
                 .await
                 .unwrap();
@@ -422,8 +422,8 @@ async fn apply_client_operation(
             );
 
             ensure_project_shared(&project, client, cx).await;
-            if !client.fs.paths(false).contains(&new_root_path) {
-                client.fs.create_dir(&new_root_path).await.unwrap();
+            if !client.fs().paths(false).contains(&new_root_path) {
+                client.fs().create_dir(&new_root_path).await.unwrap();
             }
             project
                 .update(cx, |project, cx| {
@@ -475,7 +475,7 @@ async fn apply_client_operation(
                     Some(room.update(cx, |room, cx| {
                         room.join_project(
                             project_id,
-                            client.language_registry.clone(),
+                            client.language_registry().clone(),
                             FakeFs::new(cx.background().clone()),
                             cx,
                         )
@@ -743,7 +743,7 @@ async fn apply_client_operation(
             content,
         } => {
             if !client
-                .fs
+                .fs()
                 .directories(false)
                 .contains(&path.parent().unwrap().to_owned())
             {
@@ -752,14 +752,14 @@ async fn apply_client_operation(
 
             if is_dir {
                 log::info!("{}: creating dir at {:?}", client.username, path);
-                client.fs.create_dir(&path).await.unwrap();
+                client.fs().create_dir(&path).await.unwrap();
             } else {
-                let exists = client.fs.metadata(&path).await?.is_some();
+                let exists = client.fs().metadata(&path).await?.is_some();
                 let verb = if exists { "updating" } else { "creating" };
                 log::info!("{}: {} file at {:?}", verb, client.username, path);
 
                 client
-                    .fs
+                    .fs()
                     .save(&path, &content.as_str().into(), fs::LineEnding::Unix)
                     .await
                     .unwrap();
@@ -771,12 +771,12 @@ async fn apply_client_operation(
                 repo_path,
                 contents,
             } => {
-                if !client.fs.directories(false).contains(&repo_path) {
+                if !client.fs().directories(false).contains(&repo_path) {
                     return Err(TestError::Inapplicable);
                 }
 
                 for (path, _) in contents.iter() {
-                    if !client.fs.files().contains(&repo_path.join(path)) {
+                    if !client.fs().files().contains(&repo_path.join(path)) {
                         return Err(TestError::Inapplicable);
                     }
                 }
@@ -793,16 +793,16 @@ async fn apply_client_operation(
                     .iter()
                     .map(|(path, contents)| (path.as_path(), contents.clone()))
                     .collect::<Vec<_>>();
-                if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                    client.fs.create_dir(&dot_git_dir).await?;
+                if client.fs().metadata(&dot_git_dir).await?.is_none() {
+                    client.fs().create_dir(&dot_git_dir).await?;
                 }
-                client.fs.set_index_for_repo(&dot_git_dir, &contents);
+                client.fs().set_index_for_repo(&dot_git_dir, &contents);
             }
             GitOperation::WriteGitBranch {
                 repo_path,
                 new_branch,
             } => {
-                if !client.fs.directories(false).contains(&repo_path) {
+                if !client.fs().directories(false).contains(&repo_path) {
                     return Err(TestError::Inapplicable);
                 }
 
@@ -814,21 +814,21 @@ async fn apply_client_operation(
                 );
 
                 let dot_git_dir = repo_path.join(".git");
-                if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                    client.fs.create_dir(&dot_git_dir).await?;
+                if client.fs().metadata(&dot_git_dir).await?.is_none() {
+                    client.fs().create_dir(&dot_git_dir).await?;
                 }
-                client.fs.set_branch_name(&dot_git_dir, new_branch);
+                client.fs().set_branch_name(&dot_git_dir, new_branch);
             }
             GitOperation::WriteGitStatuses {
                 repo_path,
                 statuses,
                 git_operation,
             } => {
-                if !client.fs.directories(false).contains(&repo_path) {
+                if !client.fs().directories(false).contains(&repo_path) {
                     return Err(TestError::Inapplicable);
                 }
                 for (path, _) in statuses.iter() {
-                    if !client.fs.files().contains(&repo_path.join(path)) {
+                    if !client.fs().files().contains(&repo_path.join(path)) {
                         return Err(TestError::Inapplicable);
                     }
                 }
@@ -847,16 +847,16 @@ async fn apply_client_operation(
                     .map(|(path, val)| (path.as_path(), val.clone()))
                     .collect::<Vec<_>>();
 
-                if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                    client.fs.create_dir(&dot_git_dir).await?;
+                if client.fs().metadata(&dot_git_dir).await?.is_none() {
+                    client.fs().create_dir(&dot_git_dir).await?;
                 }
 
                 if git_operation {
                     client
-                        .fs
+                        .fs()
                         .set_status_for_repo_via_git_operation(&dot_git_dir, statuses.as_slice());
                 } else {
-                    client.fs.set_status_for_repo_via_working_copy_change(
+                    client.fs().set_status_for_repo_via_working_copy_change(
                         &dot_git_dir,
                         statuses.as_slice(),
                     );
@@ -1499,7 +1499,7 @@ impl TestPlan {
                         // Invite a contact to the current call
                         0..=70 => {
                             let available_contacts =
-                                client.user_store.read_with(cx, |user_store, _| {
+                                client.user_store().read_with(cx, |user_store, _| {
                                     user_store
                                         .contacts()
                                         .iter()
@@ -1596,7 +1596,7 @@ impl TestPlan {
                                 .choose(&mut self.rng)
                                 .cloned() else { continue };
                             let project_root_name = root_name_for_project(&project, cx);
-                            let mut paths = client.fs.paths(false);
+                            let mut paths = client.fs().paths(false);
                             paths.remove(0);
                             let new_root_path = if paths.is_empty() || self.rng.gen() {
                                 Path::new("/").join(&self.next_root_dir_name(user_id))
@@ -1776,7 +1776,7 @@ impl TestPlan {
                     let is_dir = self.rng.gen::<bool>();
                     let content;
                     let mut path;
-                    let dir_paths = client.fs.directories(false);
+                    let dir_paths = client.fs().directories(false);
 
                     if is_dir {
                         content = String::new();
@@ -1786,7 +1786,7 @@ impl TestPlan {
                         content = Alphanumeric.sample_string(&mut self.rng, 16);
 
                         // Create a new file or overwrite an existing file
-                        let file_paths = client.fs.files();
+                        let file_paths = client.fs().files();
                         if file_paths.is_empty() || self.rng.gen_bool(0.5) {
                             path = dir_paths.choose(&mut self.rng).unwrap().clone();
                             path.push(gen_file_name(&mut self.rng));
@@ -1812,7 +1812,7 @@ impl TestPlan {
             client: &TestClient,
         ) -> Vec<PathBuf> {
             let mut paths = client
-                .fs
+                .fs()
                 .files()
                 .into_iter()
                 .filter(|path| path.starts_with(repo_path))
@@ -1829,7 +1829,7 @@ impl TestPlan {
         }
 
         let repo_path = client
-            .fs
+            .fs()
             .directories(false)
             .choose(&mut self.rng)
             .unwrap()
@@ -1928,7 +1928,7 @@ async fn simulate_client(
             name: "the-fake-language-server",
             capabilities: lsp::LanguageServer::full_capabilities(),
             initializer: Some(Box::new({
-                let fs = client.fs.clone();
+                let fs = client.app_state.fs.clone();
                 move |fake_server: &mut FakeLanguageServer| {
                     fake_server.handle_request::<lsp::request::Completion, _, _>(
                         |_, _| async move {
@@ -1973,7 +1973,7 @@ async fn simulate_client(
                             let background = cx.background();
                             let mut rng = background.rng();
                             let count = rng.gen_range::<usize, _>(1..3);
-                            let files = fs.files();
+                            let files = fs.as_fake().files();
                             let files = (0..count)
                                 .map(|_| files.choose(&mut *rng).unwrap().clone())
                                 .collect::<Vec<_>>();
@@ -2023,7 +2023,7 @@ async fn simulate_client(
             ..Default::default()
         }))
         .await;
-    client.language_registry.add(Arc::new(language));
+    client.app_state.languages.add(Arc::new(language));
 
     while let Some(batch_id) = operation_rx.next().await {
         let Some((operation, applied)) = plan.lock().next_client_operation(&client, batch_id, &cx) else { break };

crates/collab_ui/Cargo.toml 🔗

@@ -23,6 +23,7 @@ test-support = [
 
 [dependencies]
 auto_update = { path = "../auto_update" }
+db = { path = "../db" }
 call = { path = "../call" }
 client = { path = "../client" }
 clock = { path = "../clock" }
@@ -37,6 +38,7 @@ picker = { path = "../picker" }
 project = { path = "../project" }
 recent_projects = {path = "../recent_projects"}
 settings = { path = "../settings" }
+staff_mode = {path = "../staff_mode"}
 theme = { path = "../theme" }
 theme_selector = { path = "../theme_selector" }
 vcs_menu = { path = "../vcs_menu" }
@@ -44,10 +46,10 @@ util = { path = "../util" }
 workspace = { path = "../workspace" }
 zed-actions = {path = "../zed-actions"}
 
-
 anyhow.workspace = true
 futures.workspace = true
 log.workspace = true
+schemars.workspace = true
 postage.workspace = true
 serde.workspace = true
 serde_derive.workspace = true

crates/collab_ui/src/collab_panel.rs 🔗

@@ -0,0 +1,2521 @@
+mod channel_modal;
+mod contact_finder;
+mod panel_settings;
+
+use anyhow::Result;
+use call::ActiveCall;
+use client::{
+    proto::PeerId, Channel, ChannelEvent, ChannelId, ChannelStore, Client, Contact, User, UserStore,
+};
+
+use context_menu::{ContextMenu, ContextMenuItem};
+use db::kvp::KEY_VALUE_STORE;
+use editor::{Cancel, Editor};
+use futures::StreamExt;
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::{
+        Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
+        MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, Stack, Svg,
+    },
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
+    impl_actions,
+    platform::{CursorStyle, MouseButton, PromptLevel},
+    serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
+    Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings};
+use project::{Fs, Project};
+use serde_derive::{Deserialize, Serialize};
+use settings::SettingsStore;
+use staff_mode::StaffMode;
+use std::{borrow::Cow, mem, sync::Arc};
+use theme::IconButton;
+use util::{iife, ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    item::ItemHandle,
+    Workspace,
+};
+
+use crate::face_pile::FacePile;
+use channel_modal::ChannelModal;
+
+use self::contact_finder::ContactFinder;
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RemoveChannel {
+    channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct NewChannel {
+    channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct InviteMembers {
+    channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct ManageMembers {
+    channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RenameChannel {
+    channel_id: u64,
+}
+
+actions!(collab_panel, [ToggleFocus, Remove, Secondary]);
+
+impl_actions!(
+    collab_panel,
+    [
+        RemoveChannel,
+        NewChannel,
+        InviteMembers,
+        ManageMembers,
+        RenameChannel
+    ]
+);
+
+const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel";
+
+pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
+    settings::register::<panel_settings::CollaborationPanelSettings>(cx);
+    contact_finder::init(cx);
+    channel_modal::init(cx);
+
+    cx.add_action(CollabPanel::cancel);
+    cx.add_action(CollabPanel::select_next);
+    cx.add_action(CollabPanel::select_prev);
+    cx.add_action(CollabPanel::confirm);
+    cx.add_action(CollabPanel::remove);
+    cx.add_action(CollabPanel::remove_selected_channel);
+    cx.add_action(CollabPanel::show_inline_context_menu);
+    cx.add_action(CollabPanel::new_subchannel);
+    cx.add_action(CollabPanel::invite_members);
+    cx.add_action(CollabPanel::manage_members);
+    cx.add_action(CollabPanel::rename_selected_channel);
+    cx.add_action(CollabPanel::rename_channel);
+}
+
+#[derive(Debug)]
+pub enum ChannelEditingState {
+    Create {
+        parent_id: Option<u64>,
+        pending_name: Option<String>,
+    },
+    Rename {
+        channel_id: u64,
+        pending_name: Option<String>,
+    },
+}
+
+impl ChannelEditingState {
+    fn pending_name(&self) -> Option<&str> {
+        match self {
+            ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
+            ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
+        }
+    }
+}
+
+pub struct CollabPanel {
+    width: Option<f32>,
+    fs: Arc<dyn Fs>,
+    has_focus: bool,
+    pending_serialization: Task<Option<()>>,
+    context_menu: ViewHandle<ContextMenu>,
+    filter_editor: ViewHandle<Editor>,
+    channel_name_editor: ViewHandle<Editor>,
+    channel_editing_state: Option<ChannelEditingState>,
+    entries: Vec<ListEntry>,
+    selection: Option<usize>,
+    user_store: ModelHandle<UserStore>,
+    client: Arc<Client>,
+    channel_store: ModelHandle<ChannelStore>,
+    project: ModelHandle<Project>,
+    match_candidates: Vec<StringMatchCandidate>,
+    list_state: ListState<Self>,
+    subscriptions: Vec<Subscription>,
+    collapsed_sections: Vec<Section>,
+    workspace: WeakViewHandle<Workspace>,
+    context_menu_on_selected: bool,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedChannelsPanel {
+    width: Option<f32>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+    DockPositionChanged,
+    Focus,
+    Dismissed,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+    ActiveCall,
+    Channels,
+    ChannelInvites,
+    ContactRequests,
+    Contacts,
+    Online,
+    Offline,
+}
+
+#[derive(Clone, Debug)]
+enum ListEntry {
+    Header(Section, usize),
+    CallParticipant {
+        user: Arc<User>,
+        is_pending: bool,
+    },
+    ParticipantProject {
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+        host_user_id: u64,
+        is_last: bool,
+    },
+    ParticipantScreen {
+        peer_id: PeerId,
+        is_last: bool,
+    },
+    IncomingRequest(Arc<User>),
+    OutgoingRequest(Arc<User>),
+    ChannelInvite(Arc<Channel>),
+    Channel {
+        channel: Arc<Channel>,
+        depth: usize,
+    },
+    ChannelEditor {
+        depth: usize,
+    },
+    Contact {
+        contact: Arc<Contact>,
+        calling: bool,
+    },
+    ContactPlaceholder,
+}
+
+impl Entity for CollabPanel {
+    type Event = Event;
+}
+
+impl CollabPanel {
+    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+        cx.add_view::<Self, _>(|cx| {
+            let view_id = cx.view_id();
+
+            let filter_editor = cx.add_view(|cx| {
+                let mut editor = Editor::single_line(
+                    Some(Arc::new(|theme| {
+                        theme.collab_panel.user_query_editor.clone()
+                    })),
+                    cx,
+                );
+                editor.set_placeholder_text("Filter channels, contacts", cx);
+                editor
+            });
+
+            cx.subscribe(&filter_editor, |this, _, event, cx| {
+                if let editor::Event::BufferEdited = event {
+                    let query = this.filter_editor.read(cx).text(cx);
+                    if !query.is_empty() {
+                        this.selection.take();
+                    }
+                    this.update_entries(true, cx);
+                    if !query.is_empty() {
+                        this.selection = this
+                            .entries
+                            .iter()
+                            .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
+                    }
+                }
+            })
+            .detach();
+
+            let channel_name_editor = cx.add_view(|cx| {
+                Editor::single_line(
+                    Some(Arc::new(|theme| {
+                        theme.collab_panel.user_query_editor.clone()
+                    })),
+                    cx,
+                )
+            });
+
+            cx.subscribe(&channel_name_editor, |this, _, event, cx| {
+                if let editor::Event::Blurred = event {
+                    if let Some(state) = &this.channel_editing_state {
+                        if state.pending_name().is_some() {
+                            return;
+                        }
+                    }
+                    this.take_editing_state(cx);
+                    this.update_entries(false, cx);
+                    cx.notify();
+                }
+            })
+            .detach();
+
+            let list_state =
+                ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+                    let theme = theme::current(cx).clone();
+                    let is_selected = this.selection == Some(ix);
+                    let current_project_id = this.project.read(cx).remote_id();
+
+                    match &this.entries[ix] {
+                        ListEntry::Header(section, depth) => {
+                            let is_collapsed = this.collapsed_sections.contains(section);
+                            this.render_header(
+                                *section,
+                                &theme,
+                                *depth,
+                                is_selected,
+                                is_collapsed,
+                                cx,
+                            )
+                        }
+                        ListEntry::CallParticipant { user, is_pending } => {
+                            Self::render_call_participant(
+                                user,
+                                *is_pending,
+                                is_selected,
+                                &theme.collab_panel,
+                            )
+                        }
+                        ListEntry::ParticipantProject {
+                            project_id,
+                            worktree_root_names,
+                            host_user_id,
+                            is_last,
+                        } => Self::render_participant_project(
+                            *project_id,
+                            worktree_root_names,
+                            *host_user_id,
+                            Some(*project_id) == current_project_id,
+                            *is_last,
+                            is_selected,
+                            &theme.collab_panel,
+                            cx,
+                        ),
+                        ListEntry::ParticipantScreen { peer_id, is_last } => {
+                            Self::render_participant_screen(
+                                *peer_id,
+                                *is_last,
+                                is_selected,
+                                &theme.collab_panel,
+                                cx,
+                            )
+                        }
+                        ListEntry::Channel { channel, depth } => {
+                            let channel_row = this.render_channel(
+                                &*channel,
+                                *depth,
+                                &theme.collab_panel,
+                                is_selected,
+                                cx,
+                            );
+
+                            if is_selected && this.context_menu_on_selected {
+                                Stack::new()
+                                    .with_child(channel_row)
+                                    .with_child(
+                                        ChildView::new(&this.context_menu, cx)
+                                            .aligned()
+                                            .bottom()
+                                            .right(),
+                                    )
+                                    .into_any()
+                            } else {
+                                return channel_row;
+                            }
+                        }
+                        ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
+                            channel.clone(),
+                            this.channel_store.clone(),
+                            &theme.collab_panel,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::IncomingRequest(user) => Self::render_contact_request(
+                            user.clone(),
+                            this.user_store.clone(),
+                            &theme.collab_panel,
+                            true,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::OutgoingRequest(user) => Self::render_contact_request(
+                            user.clone(),
+                            this.user_store.clone(),
+                            &theme.collab_panel,
+                            false,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::Contact { contact, calling } => Self::render_contact(
+                            contact,
+                            *calling,
+                            &this.project,
+                            &theme.collab_panel,
+                            is_selected,
+                            cx,
+                        ),
+                        ListEntry::ChannelEditor { depth } => {
+                            this.render_channel_editor(&theme, *depth, cx)
+                        }
+                        ListEntry::ContactPlaceholder => {
+                            this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
+                        }
+                    }
+                });
+
+            let mut this = Self {
+                width: None,
+                has_focus: false,
+                fs: workspace.app_state().fs.clone(),
+                pending_serialization: Task::ready(None),
+                context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
+                channel_name_editor,
+                filter_editor,
+                entries: Vec::default(),
+                channel_editing_state: None,
+                selection: None,
+                user_store: workspace.user_store().clone(),
+                channel_store: workspace.app_state().channel_store.clone(),
+                project: workspace.project().clone(),
+                subscriptions: Vec::default(),
+                match_candidates: Vec::default(),
+                collapsed_sections: Vec::default(),
+                workspace: workspace.weak_handle(),
+                client: workspace.app_state().client.clone(),
+                context_menu_on_selected: true,
+                list_state,
+            };
+
+            this.update_entries(false, cx);
+
+            // Update the dock position when the setting changes.
+            let mut old_dock_position = this.position(cx);
+            this.subscriptions
+                .push(
+                    cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
+                        let new_dock_position = this.position(cx);
+                        if new_dock_position != old_dock_position {
+                            old_dock_position = new_dock_position;
+                            cx.emit(Event::DockPositionChanged);
+                        }
+                        cx.notify();
+                    }),
+                );
+
+            let active_call = ActiveCall::global(cx);
+            this.subscriptions
+                .push(cx.observe(&this.user_store, |this, _, cx| {
+                    this.update_entries(true, cx)
+                }));
+            this.subscriptions
+                .push(cx.observe(&this.channel_store, |this, _, cx| {
+                    this.update_entries(true, cx)
+                }));
+            this.subscriptions
+                .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
+            this.subscriptions.push(
+                cx.observe_global::<StaffMode, _>(move |this, cx| this.update_entries(true, cx)),
+            );
+            this.subscriptions.push(cx.subscribe(
+                &this.channel_store,
+                |this, _channel_store, e, cx| match e {
+                    ChannelEvent::ChannelCreated(channel_id)
+                    | ChannelEvent::ChannelRenamed(channel_id) => {
+                        if this.take_editing_state(cx) {
+                            this.update_entries(false, cx);
+                            this.selection = this.entries.iter().position(|entry| {
+                                if let ListEntry::Channel { channel, .. } = entry {
+                                    channel.id == *channel_id
+                                } else {
+                                    false
+                                }
+                            });
+                        }
+                    }
+                },
+            ));
+
+            this
+        })
+    }
+
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let serialized_panel = if let Some(panel) = cx
+                .background()
+                .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) })
+                .await
+                .log_err()
+                .flatten()
+            {
+                Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
+            } else {
+                None
+            };
+
+            workspace.update(&mut cx, |workspace, cx| {
+                let panel = CollabPanel::new(workspace, cx);
+                if let Some(serialized_panel) = serialized_panel {
+                    panel.update(cx, |panel, cx| {
+                        panel.width = serialized_panel.width;
+                        cx.notify();
+                    });
+                }
+                panel
+            })
+        })
+    }
+
+    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+        let width = self.width;
+        self.pending_serialization = cx.background().spawn(
+            async move {
+                KEY_VALUE_STORE
+                    .write_kvp(
+                        CHANNELS_PANEL_KEY.into(),
+                        serde_json::to_string(&SerializedChannelsPanel { width })?,
+                    )
+                    .await?;
+                anyhow::Ok(())
+            }
+            .log_err(),
+        );
+    }
+
+    fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
+        let user_store = self.user_store.read(cx);
+        let query = self.filter_editor.read(cx).text(cx);
+        let executor = cx.background().clone();
+
+        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
+        let old_entries = mem::take(&mut self.entries);
+
+        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+            self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
+
+            if !self.collapsed_sections.contains(&Section::ActiveCall) {
+                let room = room.read(cx);
+
+                // Populate the active user.
+                if let Some(user) = user_store.current_user() {
+                    self.match_candidates.clear();
+                    self.match_candidates.push(StringMatchCandidate {
+                        id: 0,
+                        string: user.github_login.clone(),
+                        char_bag: user.github_login.chars().collect(),
+                    });
+                    let matches = executor.block(match_strings(
+                        &self.match_candidates,
+                        &query,
+                        true,
+                        usize::MAX,
+                        &Default::default(),
+                        executor.clone(),
+                    ));
+                    if !matches.is_empty() {
+                        let user_id = user.id;
+                        self.entries.push(ListEntry::CallParticipant {
+                            user,
+                            is_pending: false,
+                        });
+                        let mut projects = room.local_participant().projects.iter().peekable();
+                        while let Some(project) = projects.next() {
+                            self.entries.push(ListEntry::ParticipantProject {
+                                project_id: project.id,
+                                worktree_root_names: project.worktree_root_names.clone(),
+                                host_user_id: user_id,
+                                is_last: projects.peek().is_none(),
+                            });
+                        }
+                    }
+                }
+
+                // Populate remote participants.
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(room.remote_participants().iter().map(|(_, participant)| {
+                        StringMatchCandidate {
+                            id: participant.user.id as usize,
+                            string: participant.user.github_login.clone(),
+                            char_bag: participant.user.github_login.chars().collect(),
+                        }
+                    }));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                for mat in matches {
+                    let user_id = mat.candidate_id as u64;
+                    let participant = &room.remote_participants()[&user_id];
+                    self.entries.push(ListEntry::CallParticipant {
+                        user: participant.user.clone(),
+                        is_pending: false,
+                    });
+                    let mut projects = participant.projects.iter().peekable();
+                    while let Some(project) = projects.next() {
+                        self.entries.push(ListEntry::ParticipantProject {
+                            project_id: project.id,
+                            worktree_root_names: project.worktree_root_names.clone(),
+                            host_user_id: participant.user.id,
+                            is_last: projects.peek().is_none()
+                                && participant.video_tracks.is_empty(),
+                        });
+                    }
+                    if !participant.video_tracks.is_empty() {
+                        self.entries.push(ListEntry::ParticipantScreen {
+                            peer_id: participant.peer_id,
+                            is_last: true,
+                        });
+                    }
+                }
+
+                // Populate pending participants.
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(room.pending_participants().iter().enumerate().map(
+                        |(id, participant)| StringMatchCandidate {
+                            id,
+                            string: participant.github_login.clone(),
+                            char_bag: participant.github_login.chars().collect(),
+                        },
+                    ));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                self.entries
+                    .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
+                        user: room.pending_participants()[mat.candidate_id].clone(),
+                        is_pending: true,
+                    }));
+            }
+        }
+
+        let mut request_entries = Vec::new();
+        if self.include_channels_section(cx) {
+            self.entries.push(ListEntry::Header(Section::Channels, 0));
+
+            if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(
+                        channel_store
+                            .channels()
+                            .enumerate()
+                            .map(|(ix, (_, channel))| StringMatchCandidate {
+                                id: ix,
+                                string: channel.name.clone(),
+                                char_bag: channel.name.chars().collect(),
+                            }),
+                    );
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                if let Some(state) = &self.channel_editing_state {
+                    if matches!(
+                        state,
+                        ChannelEditingState::Create {
+                            parent_id: None,
+                            ..
+                        }
+                    ) {
+                        self.entries.push(ListEntry::ChannelEditor { depth: 0 });
+                    }
+                }
+                for mat in matches {
+                    let (depth, channel) =
+                        channel_store.channel_at_index(mat.candidate_id).unwrap();
+
+                    match &self.channel_editing_state {
+                        Some(ChannelEditingState::Create { parent_id, .. })
+                            if *parent_id == Some(channel.id) =>
+                        {
+                            self.entries.push(ListEntry::Channel {
+                                channel: channel.clone(),
+                                depth,
+                            });
+                            self.entries
+                                .push(ListEntry::ChannelEditor { depth: depth + 1 });
+                        }
+                        Some(ChannelEditingState::Rename { channel_id, .. })
+                            if *channel_id == channel.id =>
+                        {
+                            self.entries.push(ListEntry::ChannelEditor { depth });
+                        }
+                        _ => {
+                            self.entries.push(ListEntry::Channel {
+                                channel: channel.clone(),
+                                depth,
+                            });
+                        }
+                    }
+                }
+            }
+
+            let channel_invites = channel_store.channel_invitations();
+            if !channel_invites.is_empty() {
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
+                        StringMatchCandidate {
+                            id: ix,
+                            string: channel.name.clone(),
+                            char_bag: channel.name.chars().collect(),
+                        }
+                    }));
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                request_entries.extend(matches.iter().map(|mat| {
+                    ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
+                }));
+
+                if !request_entries.is_empty() {
+                    self.entries
+                        .push(ListEntry::Header(Section::ChannelInvites, 1));
+                    if !self.collapsed_sections.contains(&Section::ChannelInvites) {
+                        self.entries.append(&mut request_entries);
+                    }
+                }
+            }
+        }
+
+        self.entries.push(ListEntry::Header(Section::Contacts, 0));
+
+        request_entries.clear();
+        let incoming = user_store.incoming_contact_requests();
+        if !incoming.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    incoming
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
+            );
+        }
+
+        let outgoing = user_store.outgoing_contact_requests();
+        if !outgoing.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    outgoing
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+            );
+        }
+
+        if !request_entries.is_empty() {
+            self.entries
+                .push(ListEntry::Header(Section::ContactRequests, 1));
+            if !self.collapsed_sections.contains(&Section::ContactRequests) {
+                self.entries.append(&mut request_entries);
+            }
+        }
+
+        let contacts = user_store.contacts();
+        if !contacts.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    contacts
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, contact)| StringMatchCandidate {
+                            id: ix,
+                            string: contact.user.github_login.clone(),
+                            char_bag: contact.user.github_login.chars().collect(),
+                        }),
+                );
+
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+
+            let (online_contacts, offline_contacts) = matches
+                .iter()
+                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
+
+            for (matches, section) in [
+                (online_contacts, Section::Online),
+                (offline_contacts, Section::Offline),
+            ] {
+                if !matches.is_empty() {
+                    self.entries.push(ListEntry::Header(section, 1));
+                    if !self.collapsed_sections.contains(&section) {
+                        let active_call = &ActiveCall::global(cx).read(cx);
+                        for mat in matches {
+                            let contact = &contacts[mat.candidate_id];
+                            self.entries.push(ListEntry::Contact {
+                                contact: contact.clone(),
+                                calling: active_call.pending_invites().contains(&contact.user.id),
+                            });
+                        }
+                    }
+                }
+            }
+        }
+
+        if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
+            self.entries.push(ListEntry::ContactPlaceholder);
+        }
+
+        if select_same_item {
+            if let Some(prev_selected_entry) = prev_selected_entry {
+                self.selection.take();
+                for (ix, entry) in self.entries.iter().enumerate() {
+                    if *entry == prev_selected_entry {
+                        self.selection = Some(ix);
+                        break;
+                    }
+                }
+            }
+        } else {
+            self.selection = self.selection.and_then(|prev_selection| {
+                if self.entries.is_empty() {
+                    None
+                } else {
+                    Some(prev_selection.min(self.entries.len() - 1))
+                }
+            });
+        }
+
+        let old_scroll_top = self.list_state.logical_scroll_top();
+        self.list_state.reset(self.entries.len());
+
+        // Attempt to maintain the same scroll position.
+        if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
+            let new_scroll_top = self
+                .entries
+                .iter()
+                .position(|entry| entry == old_top_entry)
+                .map(|item_ix| ListOffset {
+                    item_ix,
+                    offset_in_item: old_scroll_top.offset_in_item,
+                })
+                .or_else(|| {
+                    let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
+                    let item_ix = self
+                        .entries
+                        .iter()
+                        .position(|entry| entry == entry_after_old_top)?;
+                    Some(ListOffset {
+                        item_ix,
+                        offset_in_item: 0.,
+                    })
+                })
+                .or_else(|| {
+                    let entry_before_old_top =
+                        old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
+                    let item_ix = self
+                        .entries
+                        .iter()
+                        .position(|entry| entry == entry_before_old_top)?;
+                    Some(ListOffset {
+                        item_ix,
+                        offset_in_item: 0.,
+                    })
+                });
+
+            self.list_state
+                .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
+        }
+
+        cx.notify();
+    }
+
+    fn render_call_participant(
+        user: &User,
+        is_pending: bool,
+        is_selected: bool,
+        theme: &theme::CollabPanel,
+    ) -> AnyElement<Self> {
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            }))
+            .with_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .flex(1., true),
+            )
+            .with_children(if is_pending {
+                Some(
+                    Label::new("Calling", theme.calling_indicator.text.clone())
+                        .contained()
+                        .with_style(theme.calling_indicator.container)
+                        .aligned(),
+                )
+            } else {
+                None
+            })
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
+            )
+            .into_any()
+    }
+
+    fn render_participant_project(
+        project_id: u64,
+        worktree_root_names: &[String],
+        host_user_id: u64,
+        is_current: bool,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::CollabPanel,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum JoinProject {}
+
+        let font_cache = cx.font_cache();
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let row = &theme.project_row.inactive_state().default;
+        let tree_branch = theme.tree_branch;
+        let line_height = row.name.text.line_height(font_cache);
+        let cap_height = row.name.text.cap_height(font_cache);
+        let baseline_offset =
+            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+        let project_name = if worktree_root_names.is_empty() {
+            "untitled".to_string()
+        } else {
+            worktree_root_names.join(", ")
+        };
+
+        MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, _| {
+            let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+            let row = theme
+                .project_row
+                .in_state(is_selected)
+                .style_for(mouse_state);
+
+            Flex::row()
+                .with_child(
+                    Stack::new()
+                        .with_child(Canvas::new(move |scene, bounds, _, _, _| {
+                            let start_x =
+                                bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
+                            let end_x = bounds.max_x();
+                            let start_y = bounds.min_y();
+                            let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                            scene.push_quad(gpui::Quad {
+                                bounds: RectF::from_points(
+                                    vec2f(start_x, start_y),
+                                    vec2f(
+                                        start_x + tree_branch.width,
+                                        if is_last { end_y } else { bounds.max_y() },
+                                    ),
+                                ),
+                                background: Some(tree_branch.color),
+                                border: gpui::Border::default(),
+                                corner_radii: (0.).into(),
+                            });
+                            scene.push_quad(gpui::Quad {
+                                bounds: RectF::from_points(
+                                    vec2f(start_x, end_y),
+                                    vec2f(end_x, end_y + tree_branch.width),
+                                ),
+                                background: Some(tree_branch.color),
+                                border: gpui::Border::default(),
+                                corner_radii: (0.).into(),
+                            });
+                        }))
+                        .constrained()
+                        .with_width(host_avatar_height),
+                )
+                .with_child(
+                    Label::new(project_name, row.name.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(row.name.container)
+                        .flex(1., false),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+        })
+        .with_cursor_style(if !is_current {
+            CursorStyle::PointingHand
+        } else {
+            CursorStyle::Arrow
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            if !is_current {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    let app_state = workspace.read(cx).app_state().clone();
+                    workspace::join_remote_project(project_id, host_user_id, app_state, cx)
+                        .detach_and_log_err(cx);
+                }
+            }
+        })
+        .into_any()
+    }
+
+    fn render_participant_screen(
+        peer_id: PeerId,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::CollabPanel,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum OpenSharedScreen {}
+
+        let font_cache = cx.font_cache();
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let row = &theme.project_row.inactive_state().default;
+        let tree_branch = theme.tree_branch;
+        let line_height = row.name.text.line_height(font_cache);
+        let cap_height = row.name.text.cap_height(font_cache);
+        let baseline_offset =
+            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+
+        MouseEventHandler::new::<OpenSharedScreen, _>(
+            peer_id.as_u64() as usize,
+            cx,
+            |mouse_state, _| {
+                let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+                let row = theme
+                    .project_row
+                    .in_state(is_selected)
+                    .style_for(mouse_state);
+
+                Flex::row()
+                    .with_child(
+                        Stack::new()
+                            .with_child(Canvas::new(move |scene, bounds, _, _, _| {
+                                let start_x = bounds.min_x() + (bounds.width() / 2.)
+                                    - (tree_branch.width / 2.);
+                                let end_x = bounds.max_x();
+                                let start_y = bounds.min_y();
+                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                                scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, start_y),
+                                        vec2f(
+                                            start_x + tree_branch.width,
+                                            if is_last { end_y } else { bounds.max_y() },
+                                        ),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radii: (0.).into(),
+                                });
+                                scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, end_y),
+                                        vec2f(end_x, end_y + tree_branch.width),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radii: (0.).into(),
+                                });
+                            }))
+                            .constrained()
+                            .with_width(host_avatar_height),
+                    )
+                    .with_child(
+                        Svg::new("icons/disable_screen_sharing_12.svg")
+                            .with_color(row.icon.color)
+                            .constrained()
+                            .with_width(row.icon.width)
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.icon.container),
+                    )
+                    .with_child(
+                        Label::new("Screen", row.name.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.name.container)
+                            .flex(1., false),
+                    )
+                    .constrained()
+                    .with_height(theme.row_height)
+                    .contained()
+                    .with_style(row.container)
+            },
+        )
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            if let Some(workspace) = this.workspace.upgrade(cx) {
+                workspace.update(cx, |workspace, cx| {
+                    workspace.open_shared_screen(peer_id, cx)
+                });
+            }
+        })
+        .into_any()
+    }
+
+    fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
+        if let Some(_) = self.channel_editing_state.take() {
+            self.channel_name_editor.update(cx, |editor, cx| {
+                editor.set_text("", cx);
+            });
+            true
+        } else {
+            false
+        }
+    }
+
+    fn render_header(
+        &self,
+        section: Section,
+        theme: &theme::Theme,
+        depth: usize,
+        is_selected: bool,
+        is_collapsed: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Header {}
+        enum LeaveCallContactList {}
+        enum AddChannel {}
+
+        let tooltip_style = &theme.tooltip;
+        let text = match section {
+            Section::ActiveCall => {
+                let channel_name = iife!({
+                    let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
+
+                    let name = self
+                        .channel_store
+                        .read(cx)
+                        .channel_for_id(channel_id)?
+                        .name
+                        .as_str();
+
+                    Some(name)
+                });
+
+                if let Some(name) = channel_name {
+                    Cow::Owned(format!("Current Call - #{}", name))
+                } else {
+                    Cow::Borrowed("Current Call")
+                }
+            }
+            Section::ContactRequests => Cow::Borrowed("Requests"),
+            Section::Contacts => Cow::Borrowed("Contacts"),
+            Section::Channels => Cow::Borrowed("Channels"),
+            Section::ChannelInvites => Cow::Borrowed("Invites"),
+            Section::Online => Cow::Borrowed("Online"),
+            Section::Offline => Cow::Borrowed("Offline"),
+        };
+
+        enum AddContact {}
+        let button = match section {
+            Section::ActiveCall => Some(
+                MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
+                    render_icon_button(
+                        theme
+                            .collab_panel
+                            .leave_call_button
+                            .style_for(is_selected, state),
+                        "icons/exit.svg",
+                    )
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, _, cx| {
+                    Self::leave_call(cx);
+                })
+                .with_tooltip::<AddContact>(
+                    0,
+                    "Leave call",
+                    None,
+                    tooltip_style.clone(),
+                    cx,
+                ),
+            ),
+            Section::Contacts => Some(
+                MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
+                    render_icon_button(
+                        theme
+                            .collab_panel
+                            .add_contact_button
+                            .style_for(is_selected, state),
+                        "icons/plus_16.svg",
+                    )
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, this, cx| {
+                    this.toggle_contact_finder(cx);
+                })
+                .with_tooltip::<LeaveCallContactList>(
+                    0,
+                    "Search for new contact",
+                    None,
+                    tooltip_style.clone(),
+                    cx,
+                ),
+            ),
+            Section::Channels => Some(
+                MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
+                    render_icon_button(
+                        theme
+                            .collab_panel
+                            .add_contact_button
+                            .style_for(is_selected, state),
+                        "icons/plus.svg",
+                    )
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
+                .with_tooltip::<AddChannel>(
+                    0,
+                    "Create a channel",
+                    None,
+                    tooltip_style.clone(),
+                    cx,
+                ),
+            ),
+            _ => None,
+        };
+
+        let can_collapse = depth > 0;
+        let icon_size = (&theme.collab_panel).section_icon_size;
+        let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
+            let header_style = if can_collapse {
+                theme
+                    .collab_panel
+                    .subheader_row
+                    .in_state(is_selected)
+                    .style_for(state)
+            } else {
+                &theme.collab_panel.header_row
+            };
+
+            Flex::row()
+                .with_children(if can_collapse {
+                    Some(
+                        Svg::new(if is_collapsed {
+                            "icons/chevron_right.svg"
+                        } else {
+                            "icons/chevron_down.svg"
+                        })
+                        .with_color(header_style.text.color)
+                        .constrained()
+                        .with_max_width(icon_size)
+                        .with_max_height(icon_size)
+                        .aligned()
+                        .constrained()
+                        .with_width(icon_size)
+                        .contained()
+                        .with_margin_right(
+                            theme.collab_panel.contact_username.container.margin.left,
+                        ),
+                    )
+                } else {
+                    None
+                })
+                .with_child(
+                    Label::new(text, header_style.text.clone())
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                )
+                .with_children(button.map(|button| button.aligned().right()))
+                .constrained()
+                .with_height(theme.collab_panel.row_height)
+                .contained()
+                .with_style(header_style.container)
+        });
+
+        if can_collapse {
+            result = result
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    if can_collapse {
+                        this.toggle_expanded(section, cx);
+                    }
+                })
+        }
+
+        result.into_any()
+    }
+
+    fn render_contact(
+        contact: &Contact,
+        calling: bool,
+        project: &ModelHandle<Project>,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let online = contact.online;
+        let busy = contact.busy || calling;
+        let user_id = contact.user.id;
+        let github_login = contact.user.github_login.clone();
+        let initial_project = project.clone();
+        let mut event_handler =
+            MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
+                Flex::row()
+                    .with_children(contact.user.avatar.clone().map(|avatar| {
+                        let status_badge = if contact.online {
+                            Some(
+                                Empty::new()
+                                    .collapsed()
+                                    .contained()
+                                    .with_style(if busy {
+                                        theme.contact_status_busy
+                                    } else {
+                                        theme.contact_status_free
+                                    })
+                                    .aligned(),
+                            )
+                        } else {
+                            None
+                        };
+                        Stack::new()
+                            .with_child(
+                                Image::from_data(avatar)
+                                    .with_style(theme.contact_avatar)
+                                    .aligned()
+                                    .left(),
+                            )
+                            .with_children(status_badge)
+                    }))
+                    .with_child(
+                        Label::new(
+                            contact.user.github_login.clone(),
+                            theme.contact_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                    )
+                    .with_child(
+                        MouseEventHandler::new::<Cancel, _>(
+                            contact.user.id as usize,
+                            cx,
+                            |mouse_state, _| {
+                                let button_style = theme.contact_button.style_for(mouse_state);
+                                render_icon_button(button_style, "icons/x.svg")
+                                    .aligned()
+                                    .flex_float()
+                            },
+                        )
+                        .with_padding(Padding::uniform(2.))
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(MouseButton::Left, move |_, this, cx| {
+                            this.remove_contact(user_id, &github_login, cx);
+                        })
+                        .flex_float(),
+                    )
+                    .with_children(if calling {
+                        Some(
+                            Label::new("Calling", theme.calling_indicator.text.clone())
+                                .contained()
+                                .with_style(theme.calling_indicator.container)
+                                .aligned(),
+                        )
+                    } else {
+                        None
+                    })
+                    .constrained()
+                    .with_height(theme.row_height)
+                    .contained()
+                    .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
+            })
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if online && !busy {
+                    this.call(user_id, Some(initial_project.clone()), cx);
+                }
+            });
+
+        if online {
+            event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
+        }
+
+        event_handler.into_any()
+    }
+
+    fn render_contact_placeholder(
+        &self,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum AddContacts {}
+        MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
+            let style = theme.list_empty_state.style_for(is_selected, state);
+            Flex::row()
+                .with_child(
+                    Svg::new("icons/plus.svg")
+                        .with_color(theme.list_empty_icon.color)
+                        .constrained()
+                        .with_width(theme.list_empty_icon.width)
+                        .aligned()
+                        .left(),
+                )
+                .with_child(
+                    Label::new("Add a contact", style.text.clone())
+                        .contained()
+                        .with_style(theme.list_empty_label_container),
+                )
+                .align_children_center()
+                .contained()
+                .with_style(style.container)
+                .into_any()
+        })
+        .on_click(MouseButton::Left, |_, this, cx| {
+            this.toggle_contact_finder(cx);
+        })
+        .into_any()
+    }
+
+    fn render_channel_editor(
+        &self,
+        theme: &theme::Theme,
+        depth: usize,
+        cx: &AppContext,
+    ) -> AnyElement<Self> {
+        Flex::row()
+            .with_child(
+                Svg::new("icons/hash.svg")
+                    .with_color(theme.collab_panel.channel_hash.color)
+                    .constrained()
+                    .with_width(theme.collab_panel.channel_hash.width)
+                    .aligned()
+                    .left(),
+            )
+            .with_child(
+                if let Some(pending_name) = self
+                    .channel_editing_state
+                    .as_ref()
+                    .and_then(|state| state.pending_name())
+                {
+                    Label::new(
+                        pending_name.to_string(),
+                        theme.collab_panel.contact_username.text.clone(),
+                    )
+                    .contained()
+                    .with_style(theme.collab_panel.contact_username.container)
+                    .aligned()
+                    .left()
+                    .flex(1., true)
+                    .into_any()
+                } else {
+                    ChildView::new(&self.channel_name_editor, cx)
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(theme.collab_panel.channel_editor)
+                        .flex(1.0, true)
+                        .into_any()
+                },
+            )
+            .align_children_center()
+            .constrained()
+            .with_height(theme.collab_panel.row_height)
+            .contained()
+            .with_style(gpui::elements::ContainerStyle {
+                background_color: Some(theme.editor.background),
+                ..*theme.collab_panel.contact_row.default_style()
+            })
+            .with_padding_left(
+                theme.collab_panel.contact_row.default_style().padding.left
+                    + theme.collab_panel.channel_indent * depth as f32,
+            )
+            .into_any()
+    }
+
+    fn render_channel(
+        &self,
+        channel: &Channel,
+        depth: usize,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        let channel_id = channel.id;
+        let is_active = iife!({
+            let call_channel = ActiveCall::global(cx)
+                .read(cx)
+                .room()?
+                .read(cx)
+                .channel_id()?;
+            Some(call_channel == channel_id)
+        })
+        .unwrap_or(false);
+
+        const FACEPILE_LIMIT: usize = 3;
+
+        MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
+            Flex::row()
+                .with_child(
+                    Svg::new("icons/hash.svg")
+                        .with_color(theme.channel_hash.color)
+                        .constrained()
+                        .with_width(theme.channel_hash.width)
+                        .aligned()
+                        .left(),
+                )
+                .with_child(
+                    Label::new(channel.name.clone(), theme.channel_name.text.clone())
+                        .contained()
+                        .with_style(theme.channel_name.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true),
+                )
+                .with_children({
+                    let participants = self.channel_store.read(cx).channel_participants(channel_id);
+                    if !participants.is_empty() {
+                        let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
+
+                        Some(
+                            FacePile::new(theme.face_overlap)
+                                .with_children(
+                                    participants
+                                        .iter()
+                                        .filter_map(|user| {
+                                            Some(
+                                                Image::from_data(user.avatar.clone()?)
+                                                    .with_style(theme.channel_avatar),
+                                            )
+                                        })
+                                        .take(FACEPILE_LIMIT),
+                                )
+                                .with_children((extra_count > 0).then(|| {
+                                    Label::new(
+                                        format!("+{}", extra_count),
+                                        theme.extra_participant_label.text.clone(),
+                                    )
+                                    .contained()
+                                    .with_style(theme.extra_participant_label.container)
+                                })),
+                        )
+                    } else {
+                        None
+                    }
+                })
+                .align_children_center()
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(*theme.channel_row.style_for(is_selected || is_active, state))
+                .with_padding_left(
+                    theme.channel_row.default_style().padding.left
+                        + theme.channel_indent * depth as f32,
+                )
+        })
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.join_channel(channel_id, cx);
+        })
+        .on_click(MouseButton::Right, move |e, this, cx| {
+            this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .into_any()
+    }
+
+    fn render_channel_invite(
+        channel: Arc<Channel>,
+        channel_store: ModelHandle<ChannelStore>,
+        theme: &theme::CollabPanel,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Decline {}
+        enum Accept {}
+
+        let channel_id = channel.id;
+        let is_invite_pending = channel_store
+            .read(cx)
+            .has_pending_channel_invite_response(&channel);
+        let button_spacing = theme.contact_button_spacing;
+
+        Flex::row()
+            .with_child(
+                Svg::new("icons/hash.svg")
+                    .with_color(theme.channel_hash.color)
+                    .constrained()
+                    .with_width(theme.channel_hash.width)
+                    .aligned()
+                    .left(),
+            )
+            .with_child(
+                Label::new(channel.name.clone(), theme.contact_username.text.clone())
+                    .contained()
+                    .with_style(theme.contact_username.container)
+                    .aligned()
+                    .left()
+                    .flex(1., true),
+            )
+            .with_child(
+                MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_invite_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/x.svg").aligned()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_channel_invite(channel_id, false, cx);
+                })
+                .contained()
+                .with_margin_right(button_spacing),
+            )
+            .with_child(
+                MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_invite_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/check.svg")
+                        .aligned()
+                        .flex_float()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_channel_invite(channel_id, true, cx);
+                }),
+            )
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
+            )
+            .with_padding_left(
+                theme.contact_row.default_style().padding.left + theme.channel_indent,
+            )
+            .into_any()
+    }
+
+    fn render_contact_request(
+        user: Arc<User>,
+        user_store: ModelHandle<UserStore>,
+        theme: &theme::CollabPanel,
+        is_incoming: bool,
+        is_selected: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum Decline {}
+        enum Accept {}
+        enum Cancel {}
+
+        let mut row = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            }))
+            .with_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .flex(1., true),
+            );
+
+        let user_id = user.id;
+        let github_login = user.github_login.clone();
+        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+        let button_spacing = theme.contact_button_spacing;
+
+        if is_incoming {
+            row.add_child(
+                MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/x.svg").aligned()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_contact_request(user_id, false, cx);
+                })
+                .contained()
+                .with_margin_right(button_spacing),
+            );
+
+            row.add_child(
+                MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/check.svg")
+                        .aligned()
+                        .flex_float()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.respond_to_contact_request(user_id, true, cx);
+                }),
+            );
+        } else {
+            row.add_child(
+                MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state)
+                    };
+                    render_icon_button(button_style, "icons/x.svg")
+                        .aligned()
+                        .flex_float()
+                })
+                .with_padding(Padding::uniform(2.))
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, this, cx| {
+                    this.remove_contact(user_id, &github_login, cx);
+                })
+                .flex_float(),
+            );
+        }
+
+        row.constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
+            )
+            .into_any()
+    }
+
+    fn include_channels_section(&self, cx: &AppContext) -> bool {
+        if cx.has_global::<StaffMode>() {
+            cx.global::<StaffMode>().0
+        } else {
+            false
+        }
+    }
+
+    fn deploy_channel_context_menu(
+        &mut self,
+        position: Option<Vector2F>,
+        channel_id: u64,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if self.channel_store.read(cx).is_user_admin(channel_id) {
+            self.context_menu_on_selected = position.is_none();
+
+            self.context_menu.update(cx, |context_menu, cx| {
+                context_menu.set_position_mode(if self.context_menu_on_selected {
+                    OverlayPositionMode::Local
+                } else {
+                    OverlayPositionMode::Window
+                });
+
+                context_menu.show(
+                    position.unwrap_or_default(),
+                    if self.context_menu_on_selected {
+                        gpui::elements::AnchorCorner::TopRight
+                    } else {
+                        gpui::elements::AnchorCorner::BottomLeft
+                    },
+                    vec![
+                        ContextMenuItem::action("New Subchannel", NewChannel { channel_id }),
+                        ContextMenuItem::Separator,
+                        ContextMenuItem::action("Invite to Channel", InviteMembers { channel_id }),
+                        ContextMenuItem::Separator,
+                        ContextMenuItem::action("Rename", RenameChannel { channel_id }),
+                        ContextMenuItem::action("Manage", ManageMembers { channel_id }),
+                        ContextMenuItem::Separator,
+                        ContextMenuItem::action("Delete", RemoveChannel { channel_id }),
+                    ],
+                    cx,
+                );
+            });
+
+            cx.notify();
+        }
+    }
+
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        if self.take_editing_state(cx) {
+            cx.focus(&self.filter_editor);
+        } else {
+            self.filter_editor.update(cx, |editor, cx| {
+                if editor.buffer().read(cx).len(cx) > 0 {
+                    editor.set_text("", cx);
+                }
+            });
+        }
+
+        self.update_entries(false, cx);
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        let ix = self.selection.map_or(0, |ix| ix + 1);
+        if ix < self.entries.len() {
+            self.selection = Some(ix);
+        }
+
+        self.list_state.reset(self.entries.len());
+        if let Some(ix) = self.selection {
+            self.list_state.scroll_to(ListOffset {
+                item_ix: ix,
+                offset_in_item: 0.,
+            });
+        }
+        cx.notify();
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        let ix = self.selection.take().unwrap_or(0);
+        if ix > 0 {
+            self.selection = Some(ix - 1);
+        }
+
+        self.list_state.reset(self.entries.len());
+        if let Some(ix) = self.selection {
+            self.list_state.scroll_to(ListOffset {
+                item_ix: ix,
+                offset_in_item: 0.,
+            });
+        }
+        cx.notify();
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if self.confirm_channel_edit(cx) {
+            return;
+        }
+
+        if let Some(selection) = self.selection {
+            if let Some(entry) = self.entries.get(selection) {
+                match entry {
+                    ListEntry::Header(section, _) => match section {
+                        Section::ActiveCall => Self::leave_call(cx),
+                        Section::Channels => self.new_root_channel(cx),
+                        Section::Contacts => self.toggle_contact_finder(cx),
+                        Section::ContactRequests
+                        | Section::Online
+                        | Section::Offline
+                        | Section::ChannelInvites => {
+                            self.toggle_expanded(*section, cx);
+                        }
+                    },
+                    ListEntry::Contact { contact, calling } => {
+                        if contact.online && !contact.busy && !calling {
+                            self.call(contact.user.id, Some(self.project.clone()), cx);
+                        }
+                    }
+                    ListEntry::ParticipantProject {
+                        project_id,
+                        host_user_id,
+                        ..
+                    } => {
+                        if let Some(workspace) = self.workspace.upgrade(cx) {
+                            let app_state = workspace.read(cx).app_state().clone();
+                            workspace::join_remote_project(
+                                *project_id,
+                                *host_user_id,
+                                app_state,
+                                cx,
+                            )
+                            .detach_and_log_err(cx);
+                        }
+                    }
+                    ListEntry::ParticipantScreen { peer_id, .. } => {
+                        if let Some(workspace) = self.workspace.upgrade(cx) {
+                            workspace.update(cx, |workspace, cx| {
+                                workspace.open_shared_screen(*peer_id, cx)
+                            });
+                        }
+                    }
+                    ListEntry::Channel { channel, .. } => {
+                        self.join_channel(channel.id, cx);
+                    }
+                    ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
+                    _ => {}
+                }
+            }
+        }
+    }
+
+    fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
+        if let Some(editing_state) = &mut self.channel_editing_state {
+            match editing_state {
+                ChannelEditingState::Create {
+                    parent_id,
+                    pending_name,
+                    ..
+                } => {
+                    if pending_name.is_some() {
+                        return false;
+                    }
+                    let channel_name = self.channel_name_editor.read(cx).text(cx);
+
+                    *pending_name = Some(channel_name.clone());
+
+                    self.channel_store
+                        .update(cx, |channel_store, cx| {
+                            channel_store.create_channel(&channel_name, *parent_id, cx)
+                        })
+                        .detach();
+                    cx.notify();
+                }
+                ChannelEditingState::Rename {
+                    channel_id,
+                    pending_name,
+                } => {
+                    if pending_name.is_some() {
+                        return false;
+                    }
+                    let channel_name = self.channel_name_editor.read(cx).text(cx);
+                    *pending_name = Some(channel_name.clone());
+
+                    self.channel_store
+                        .update(cx, |channel_store, cx| {
+                            channel_store.rename(*channel_id, &channel_name, cx)
+                        })
+                        .detach();
+                    cx.notify();
+                }
+            }
+            cx.focus_self();
+            true
+        } else {
+            false
+        }
+    }
+
+    fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
+            self.collapsed_sections.remove(ix);
+        } else {
+            self.collapsed_sections.push(section);
+        }
+        self.update_entries(false, cx);
+    }
+
+    fn leave_call(cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.hang_up(cx))
+            .detach_and_log_err(cx);
+    }
+
+    fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(workspace) = self.workspace.upgrade(cx) {
+            workspace.update(cx, |workspace, cx| {
+                workspace.toggle_modal(cx, |_, cx| {
+                    cx.add_view(|cx| {
+                        let mut finder = ContactFinder::new(self.user_store.clone(), cx);
+                        finder.set_query(self.filter_editor.read(cx).text(cx), cx);
+                        finder
+                    })
+                });
+            });
+        }
+    }
+
+    fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
+        self.channel_editing_state = Some(ChannelEditingState::Create {
+            parent_id: None,
+            pending_name: None,
+        });
+        self.update_entries(false, cx);
+        self.select_channel_editor();
+        cx.focus(self.channel_name_editor.as_any());
+        cx.notify();
+    }
+
+    fn select_channel_editor(&mut self) {
+        self.selection = self.entries.iter().position(|entry| match entry {
+            ListEntry::ChannelEditor { .. } => true,
+            _ => false,
+        });
+    }
+
+    fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
+        self.channel_editing_state = Some(ChannelEditingState::Create {
+            parent_id: Some(action.channel_id),
+            pending_name: None,
+        });
+        self.update_entries(false, cx);
+        self.select_channel_editor();
+        cx.focus(self.channel_name_editor.as_any());
+        cx.notify();
+    }
+
+    fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
+        self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
+    }
+
+    fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
+        self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
+    }
+
+    fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
+        if let Some(channel) = self.selected_channel() {
+            self.remove_channel(channel.id, cx)
+        }
+    }
+
+    fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
+        if let Some(channel) = self.selected_channel() {
+            self.rename_channel(
+                &RenameChannel {
+                    channel_id: channel.id,
+                },
+                cx,
+            );
+        }
+    }
+
+    fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.read(cx);
+        if !channel_store.is_user_admin(action.channel_id) {
+            return;
+        }
+        if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() {
+            self.channel_editing_state = Some(ChannelEditingState::Rename {
+                channel_id: action.channel_id,
+                pending_name: None,
+            });
+            self.channel_name_editor.update(cx, |editor, cx| {
+                editor.set_text(channel.name.clone(), cx);
+                editor.select_all(&Default::default(), cx);
+            });
+            cx.focus(self.channel_name_editor.as_any());
+            self.update_entries(false, cx);
+            self.select_channel_editor();
+        }
+    }
+
+    fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
+        let Some(channel) = self.selected_channel() else {
+            return;
+        };
+
+        self.deploy_channel_context_menu(None, channel.id, cx);
+    }
+
+    fn selected_channel(&self) -> Option<&Arc<Channel>> {
+        self.selection
+            .and_then(|ix| self.entries.get(ix))
+            .and_then(|entry| match entry {
+                ListEntry::Channel { channel, .. } => Some(channel),
+                _ => None,
+            })
+    }
+
+    fn show_channel_modal(
+        &mut self,
+        channel_id: ChannelId,
+        mode: channel_modal::Mode,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let workspace = self.workspace.clone();
+        let user_store = self.user_store.clone();
+        let channel_store = self.channel_store.clone();
+        let members = self.channel_store.update(cx, |channel_store, cx| {
+            channel_store.get_channel_member_details(channel_id, cx)
+        });
+
+        cx.spawn(|_, mut cx| async move {
+            let members = members.await?;
+            workspace.update(&mut cx, |workspace, cx| {
+                workspace.toggle_modal(cx, |_, cx| {
+                    cx.add_view(|cx| {
+                        ChannelModal::new(
+                            user_store.clone(),
+                            channel_store.clone(),
+                            channel_id,
+                            mode,
+                            members,
+                            cx,
+                        )
+                    })
+                });
+            })
+        })
+        .detach();
+    }
+
+    fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
+        self.remove_channel(action.channel_id, cx)
+    }
+
+    fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.clone();
+        if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
+            let prompt_message = format!(
+                "Are you sure you want to remove the channel \"{}\"?",
+                channel.name
+            );
+            let mut answer =
+                cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+            let window = cx.window();
+            cx.spawn(|this, mut cx| async move {
+                if answer.next().await == Some(0) {
+                    if let Err(e) = channel_store
+                        .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
+                        .await
+                    {
+                        window.prompt(
+                            PromptLevel::Info,
+                            &format!("Failed to remove channel: {}", e),
+                            &["Ok"],
+                            &mut cx,
+                        );
+                    }
+                    this.update(&mut cx, |_, cx| cx.focus_self()).ok();
+                }
+            })
+            .detach();
+        }
+    }
+
+    // Should move to the filter editor if clicking on it
+    // Should move selection to the channel editor if activating it
+
+    fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
+        let user_store = self.user_store.clone();
+        let prompt_message = format!(
+            "Are you sure you want to remove \"{}\" from your contacts?",
+            github_login
+        );
+        let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+        let window = cx.window();
+        cx.spawn(|_, mut cx| async move {
+            if answer.next().await == Some(0) {
+                if let Err(e) = user_store
+                    .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
+                    .await
+                {
+                    window.prompt(
+                        PromptLevel::Info,
+                        &format!("Failed to remove contact: {}", e),
+                        &["Ok"],
+                        &mut cx,
+                    );
+                }
+            }
+        })
+        .detach();
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        user_id: u64,
+        accept: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(user_id, accept, cx)
+            })
+            .detach();
+    }
+
+    fn respond_to_channel_invite(
+        &mut self,
+        channel_id: u64,
+        accept: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let respond = self.channel_store.update(cx, |store, _| {
+            store.respond_to_channel_invite(channel_id, accept)
+        });
+        cx.foreground().spawn(respond).detach();
+    }
+
+    fn call(
+        &mut self,
+        recipient_user_id: u64,
+        initial_project: Option<ModelHandle<Project>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| {
+                call.invite(recipient_user_id, initial_project, cx)
+            })
+            .detach_and_log_err(cx);
+    }
+
+    fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.join_channel(channel, cx))
+            .detach_and_log_err(cx);
+    }
+}
+
+impl View for CollabPanel {
+    fn ui_name() -> &'static str {
+        "CollabPanel"
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.has_focus {
+            self.has_focus = true;
+            if !self.context_menu.is_focused(cx) {
+                if let Some(editing_state) = &self.channel_editing_state {
+                    if editing_state.pending_name().is_none() {
+                        cx.focus(&self.channel_name_editor);
+                    } else {
+                        cx.focus(&self.filter_editor);
+                    }
+                } else {
+                    cx.focus(&self.filter_editor);
+                }
+            }
+            cx.emit(Event::Focus);
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+
+    fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
+        let theme = &theme::current(cx).collab_panel;
+
+        if self.user_store.read(cx).current_user().is_none() {
+            enum LogInButton {}
+
+            return Flex::column()
+                .with_child(
+                    MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
+                        let button = theme.log_in_button.style_for(state);
+                        Label::new("Sign in to collaborate", button.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(button.container)
+                    })
+                    .on_click(MouseButton::Left, |_, this, cx| {
+                        let client = this.client.clone();
+                        cx.spawn(|_, cx| async move {
+                            client.authenticate_and_connect(true, &cx).await.log_err();
+                        })
+                        .detach();
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand),
+                )
+                .contained()
+                .with_style(theme.container)
+                .into_any();
+        }
+
+        enum PanelFocus {}
+        MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
+            Stack::new()
+                .with_child(
+                    Flex::column()
+                        .with_child(
+                            Flex::row()
+                                .with_child(
+                                    ChildView::new(&self.filter_editor, cx)
+                                        .contained()
+                                        .with_style(theme.user_query_editor.container)
+                                        .flex(1.0, true),
+                                )
+                                .constrained()
+                                .with_width(self.size(cx)),
+                        )
+                        .with_child(
+                            List::new(self.list_state.clone())
+                                .constrained()
+                                .with_width(self.size(cx))
+                                .flex(1., true)
+                                .into_any(),
+                        )
+                        .contained()
+                        .with_style(theme.container)
+                        .constrained()
+                        .with_width(self.size(cx))
+                        .into_any(),
+                )
+                .with_children(
+                    (!self.context_menu_on_selected)
+                        .then(|| ChildView::new(&self.context_menu, cx)),
+                )
+                .into_any()
+        })
+        .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
+        .into_any_named("channels panel")
+    }
+}
+
+impl Panel for CollabPanel {
+    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+        match settings::get::<CollaborationPanelSettings>(cx).dock {
+            CollaborationPanelDockPosition::Left => DockPosition::Left,
+            CollaborationPanelDockPosition::Right => DockPosition::Right,
+        }
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(position, DockPosition::Left | DockPosition::Right)
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<CollaborationPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| {
+                let dock = match position {
+                    DockPosition::Left | DockPosition::Bottom => {
+                        CollaborationPanelDockPosition::Left
+                    }
+                    DockPosition::Right => CollaborationPanelDockPosition::Right,
+                };
+                settings.dock = Some(dock);
+            },
+        );
+    }
+
+    fn size(&self, cx: &gpui::WindowContext) -> f32 {
+        self.width
+            .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+        self.width = Some(size);
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
+        settings::get::<CollaborationPanelSettings>(cx)
+            .button
+            .then(|| "icons/conversations.svg")
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+        ("Channels Panel".to_string(), Some(Box::new(ToggleFocus)))
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, Event::DockPositionChanged)
+    }
+
+    fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
+        self.has_focus
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, Event::Focus)
+    }
+}
+
+impl PartialEq for ListEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match self {
+            ListEntry::Header(section_1, depth_1) => {
+                if let ListEntry::Header(section_2, depth_2) = other {
+                    return section_1 == section_2 && depth_1 == depth_2;
+                }
+            }
+            ListEntry::CallParticipant { user: user_1, .. } => {
+                if let ListEntry::CallParticipant { user: user_2, .. } = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ListEntry::ParticipantProject {
+                project_id: project_id_1,
+                ..
+            } => {
+                if let ListEntry::ParticipantProject {
+                    project_id: project_id_2,
+                    ..
+                } = other
+                {
+                    return project_id_1 == project_id_2;
+                }
+            }
+            ListEntry::ParticipantScreen {
+                peer_id: peer_id_1, ..
+            } => {
+                if let ListEntry::ParticipantScreen {
+                    peer_id: peer_id_2, ..
+                } = other
+                {
+                    return peer_id_1 == peer_id_2;
+                }
+            }
+            ListEntry::Channel {
+                channel: channel_1,
+                depth: depth_1,
+            } => {
+                if let ListEntry::Channel {
+                    channel: channel_2,
+                    depth: depth_2,
+                } = other
+                {
+                    return channel_1.id == channel_2.id && depth_1 == depth_2;
+                }
+            }
+            ListEntry::ChannelInvite(channel_1) => {
+                if let ListEntry::ChannelInvite(channel_2) = other {
+                    return channel_1.id == channel_2.id;
+                }
+            }
+            ListEntry::IncomingRequest(user_1) => {
+                if let ListEntry::IncomingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ListEntry::OutgoingRequest(user_1) => {
+                if let ListEntry::OutgoingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ListEntry::Contact {
+                contact: contact_1, ..
+            } => {
+                if let ListEntry::Contact {
+                    contact: contact_2, ..
+                } = other
+                {
+                    return contact_1.user.id == contact_2.user.id;
+                }
+            }
+            ListEntry::ChannelEditor { depth } => {
+                if let ListEntry::ChannelEditor { depth: other_depth } = other {
+                    return depth == other_depth;
+                }
+            }
+            ListEntry::ContactPlaceholder => {
+                if let ListEntry::ContactPlaceholder = other {
+                    return true;
+                }
+            }
+        }
+        false
+    }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
+    Svg::new(svg_path)
+        .with_color(style.color)
+        .constrained()
+        .with_width(style.icon_width)
+        .aligned()
+        .constrained()
+        .with_width(style.button_width)
+        .with_height(style.button_width)
+        .contained()
+        .with_style(style.container)
+}

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -0,0 +1,615 @@
+use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore};
+use context_menu::{ContextMenu, ContextMenuItem};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::*,
+    platform::{CursorStyle, MouseButton},
+    AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+};
+use picker::{Picker, PickerDelegate, PickerEvent};
+use std::sync::Arc;
+use util::TryFutureExt;
+use workspace::Modal;
+
+actions!(
+    channel_modal,
+    [
+        SelectNextControl,
+        ToggleMode,
+        ToggleMemberAdmin,
+        RemoveMember
+    ]
+);
+
+pub fn init(cx: &mut AppContext) {
+    Picker::<ChannelModalDelegate>::init(cx);
+    cx.add_action(ChannelModal::toggle_mode);
+    cx.add_action(ChannelModal::toggle_member_admin);
+    cx.add_action(ChannelModal::remove_member);
+    cx.add_action(ChannelModal::dismiss);
+}
+
+pub struct ChannelModal {
+    picker: ViewHandle<Picker<ChannelModalDelegate>>,
+    channel_store: ModelHandle<ChannelStore>,
+    channel_id: ChannelId,
+    has_focus: bool,
+}
+
+impl ChannelModal {
+    pub fn new(
+        user_store: ModelHandle<UserStore>,
+        channel_store: ModelHandle<ChannelStore>,
+        channel_id: ChannelId,
+        mode: Mode,
+        members: Vec<ChannelMembership>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
+        let picker = cx.add_view(|cx| {
+            Picker::new(
+                ChannelModalDelegate {
+                    matching_users: Vec::new(),
+                    matching_member_indices: Vec::new(),
+                    selected_index: 0,
+                    user_store: user_store.clone(),
+                    channel_store: channel_store.clone(),
+                    channel_id,
+                    match_candidates: Vec::new(),
+                    members,
+                    mode,
+                    context_menu: cx.add_view(|cx| {
+                        let mut menu = ContextMenu::new(cx.view_id(), cx);
+                        menu.set_position_mode(OverlayPositionMode::Local);
+                        menu
+                    }),
+                },
+                cx,
+            )
+            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+        });
+
+        cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+        let has_focus = picker.read(cx).has_focus();
+
+        Self {
+            picker,
+            channel_store,
+            channel_id,
+            has_focus,
+        }
+    }
+
+    fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
+        let mode = match self.picker.read(cx).delegate().mode {
+            Mode::ManageMembers => Mode::InviteMembers,
+            Mode::InviteMembers => Mode::ManageMembers,
+        };
+        self.set_mode(mode, cx);
+    }
+
+    fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
+        let channel_store = self.channel_store.clone();
+        let channel_id = self.channel_id;
+        cx.spawn(|this, mut cx| async move {
+            if mode == Mode::ManageMembers {
+                let members = channel_store
+                    .update(&mut cx, |channel_store, cx| {
+                        channel_store.get_channel_member_details(channel_id, cx)
+                    })
+                    .await?;
+                this.update(&mut cx, |this, cx| {
+                    this.picker
+                        .update(cx, |picker, _| picker.delegate_mut().members = members);
+                })?;
+            }
+
+            this.update(&mut cx, |this, cx| {
+                this.picker.update(cx, |picker, cx| {
+                    let delegate = picker.delegate_mut();
+                    delegate.mode = mode;
+                    delegate.selected_index = 0;
+                    picker.set_query("", cx);
+                    picker.update_matches(picker.query(cx), cx);
+                    cx.notify()
+                });
+                cx.notify()
+            })
+        })
+        .detach();
+    }
+
+    fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate_mut().toggle_selected_member_admin(cx);
+        })
+    }
+
+    fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.delegate_mut().remove_selected_member(cx);
+        });
+    }
+
+    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+}
+
+impl Entity for ChannelModal {
+    type Event = PickerEvent;
+}
+
+impl View for ChannelModal {
+    fn ui_name() -> &'static str {
+        "ChannelModal"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let theme = &theme::current(cx).collab_panel.tabbed_modal;
+
+        let mode = self.picker.read(cx).delegate().mode;
+        let Some(channel) = self
+            .channel_store
+            .read(cx)
+            .channel_for_id(self.channel_id) else {
+                return Empty::new().into_any()
+            };
+
+        enum InviteMembers {}
+        enum ManageMembers {}
+
+        fn render_mode_button<T: 'static>(
+            mode: Mode,
+            text: &'static str,
+            current_mode: Mode,
+            theme: &theme::TabbedModal,
+            cx: &mut ViewContext<ChannelModal>,
+        ) -> AnyElement<ChannelModal> {
+            let active = mode == current_mode;
+            MouseEventHandler::new::<T, _>(0, cx, move |state, _| {
+                let contained_text = theme.tab_button.style_for(active, state);
+                Label::new(text, contained_text.text.clone())
+                    .contained()
+                    .with_style(contained_text.container.clone())
+            })
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                if !active {
+                    this.set_mode(mode, cx);
+                }
+            })
+            .with_cursor_style(CursorStyle::PointingHand)
+            .into_any()
+        }
+
+        Flex::column()
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(format!("#{}", channel.name), theme.title.text.clone())
+                            .contained()
+                            .with_style(theme.title.container.clone()),
+                    )
+                    .with_child(Flex::row().with_children([
+                        render_mode_button::<InviteMembers>(
+                            Mode::InviteMembers,
+                            "Invite members",
+                            mode,
+                            theme,
+                            cx,
+                        ),
+                        render_mode_button::<ManageMembers>(
+                            Mode::ManageMembers,
+                            "Manage members",
+                            mode,
+                            theme,
+                            cx,
+                        ),
+                    ]))
+                    .expanded()
+                    .contained()
+                    .with_style(theme.header),
+            )
+            .with_child(
+                ChildView::new(&self.picker, cx)
+                    .contained()
+                    .with_style(theme.body),
+            )
+            .constrained()
+            .with_max_height(theme.max_height)
+            .with_max_width(theme.max_width)
+            .contained()
+            .with_style(theme.modal)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        if cx.is_self_focused() {
+            cx.focus(&self.picker)
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Modal for ChannelModal {
+    fn has_focus(&self) -> bool {
+        self.has_focus
+    }
+
+    fn dismiss_on_event(event: &Self::Event) -> bool {
+        match event {
+            PickerEvent::Dismiss => true,
+        }
+    }
+}
+
+#[derive(Copy, Clone, PartialEq)]
+pub enum Mode {
+    ManageMembers,
+    InviteMembers,
+}
+
+pub struct ChannelModalDelegate {
+    matching_users: Vec<Arc<User>>,
+    matching_member_indices: Vec<usize>,
+    user_store: ModelHandle<UserStore>,
+    channel_store: ModelHandle<ChannelStore>,
+    channel_id: ChannelId,
+    selected_index: usize,
+    mode: Mode,
+    match_candidates: Vec<StringMatchCandidate>,
+    members: Vec<ChannelMembership>,
+    context_menu: ViewHandle<ContextMenu>,
+}
+
+impl PickerDelegate for ChannelModalDelegate {
+    fn placeholder_text(&self) -> Arc<str> {
+        "Search collaborator by username...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        match self.mode {
+            Mode::ManageMembers => self.matching_member_indices.len(),
+            Mode::InviteMembers => self.matching_users.len(),
+        }
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        match self.mode {
+            Mode::ManageMembers => {
+                self.match_candidates.clear();
+                self.match_candidates
+                    .extend(self.members.iter().enumerate().map(|(id, member)| {
+                        StringMatchCandidate {
+                            id,
+                            string: member.user.github_login.clone(),
+                            char_bag: member.user.github_login.chars().collect(),
+                        }
+                    }));
+
+                let matches = cx.background().block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    cx.background().clone(),
+                ));
+
+                cx.spawn(|picker, mut cx| async move {
+                    picker
+                        .update(&mut cx, |picker, cx| {
+                            let delegate = picker.delegate_mut();
+                            delegate.matching_member_indices.clear();
+                            delegate
+                                .matching_member_indices
+                                .extend(matches.into_iter().map(|m| m.candidate_id));
+                            cx.notify();
+                        })
+                        .ok();
+                })
+            }
+            Mode::InviteMembers => {
+                let search_users = self
+                    .user_store
+                    .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+                cx.spawn(|picker, mut cx| async move {
+                    async {
+                        let users = search_users.await?;
+                        picker.update(&mut cx, |picker, cx| {
+                            let delegate = picker.delegate_mut();
+                            delegate.matching_users = users;
+                            cx.notify();
+                        })?;
+                        anyhow::Ok(())
+                    }
+                    .log_err()
+                    .await;
+                })
+            }
+        }
+    }
+
+    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
+            match self.mode {
+                Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
+                Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
+                    Some(proto::channel_member::Kind::Invitee) => {
+                        self.remove_selected_member(cx);
+                    }
+                    Some(proto::channel_member::Kind::AncestorMember) | None => {
+                        self.invite_member(selected_user, cx)
+                    }
+                    Some(proto::channel_member::Kind::Member) => {}
+                },
+            }
+        }
+    }
+
+    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &mut MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> AnyElement<Picker<Self>> {
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.channel_modal;
+        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
+        let (user, admin) = self.user_at_index(ix).unwrap();
+        let request_status = self.member_status(user.id, cx);
+
+        let style = tabbed_modal
+            .picker
+            .item
+            .in_state(selected)
+            .style_for(mouse_state);
+
+        let in_manage = matches!(self.mode, Mode::ManageMembers);
+
+        let mut result = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::from_data(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+            }))
+            .with_child(
+                Label::new(user.github_login.clone(), style.label.clone())
+                    .contained()
+                    .with_style(theme.contact_username)
+                    .aligned()
+                    .left(),
+            )
+            .with_children({
+                (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
+                    || {
+                        Label::new("Invited", theme.member_tag.text.clone())
+                            .contained()
+                            .with_style(theme.member_tag.container)
+                            .aligned()
+                            .left()
+                    },
+                )
+            })
+            .with_children(admin.and_then(|admin| {
+                (in_manage && admin).then(|| {
+                    Label::new("Admin", theme.member_tag.text.clone())
+                        .contained()
+                        .with_style(theme.member_tag.container)
+                        .aligned()
+                        .left()
+                })
+            }))
+            .with_children({
+                let svg = match self.mode {
+                    Mode::ManageMembers => Some(
+                        Svg::new("icons/ellipsis.svg")
+                            .with_color(theme.member_icon.color)
+                            .constrained()
+                            .with_width(theme.member_icon.icon_width)
+                            .aligned()
+                            .constrained()
+                            .with_width(theme.member_icon.button_width)
+                            .with_height(theme.member_icon.button_width)
+                            .contained()
+                            .with_style(theme.member_icon.container),
+                    ),
+                    Mode::InviteMembers => match request_status {
+                        Some(proto::channel_member::Kind::Member) => Some(
+                            Svg::new("icons/check.svg")
+                                .with_color(theme.member_icon.color)
+                                .constrained()
+                                .with_width(theme.member_icon.icon_width)
+                                .aligned()
+                                .constrained()
+                                .with_width(theme.member_icon.button_width)
+                                .with_height(theme.member_icon.button_width)
+                                .contained()
+                                .with_style(theme.member_icon.container),
+                        ),
+                        Some(proto::channel_member::Kind::Invitee) => Some(
+                            Svg::new("icons/check.svg")
+                                .with_color(theme.invitee_icon.color)
+                                .constrained()
+                                .with_width(theme.invitee_icon.icon_width)
+                                .aligned()
+                                .constrained()
+                                .with_width(theme.invitee_icon.button_width)
+                                .with_height(theme.invitee_icon.button_width)
+                                .contained()
+                                .with_style(theme.invitee_icon.container),
+                        ),
+                        Some(proto::channel_member::Kind::AncestorMember) | None => None,
+                    },
+                };
+
+                svg.map(|svg| svg.aligned().flex_float().into_any())
+            })
+            .contained()
+            .with_style(style.container)
+            .constrained()
+            .with_height(tabbed_modal.row_height)
+            .into_any();
+
+        if selected {
+            result = Stack::new()
+                .with_child(result)
+                .with_child(
+                    ChildView::new(&self.context_menu, cx)
+                        .aligned()
+                        .top()
+                        .right(),
+                )
+                .into_any();
+        }
+
+        result
+    }
+}
+
+impl ChannelModalDelegate {
+    fn member_status(
+        &self,
+        user_id: UserId,
+        cx: &AppContext,
+    ) -> Option<proto::channel_member::Kind> {
+        self.members
+            .iter()
+            .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
+            .or_else(|| {
+                self.channel_store
+                    .read(cx)
+                    .has_pending_channel_invite(self.channel_id, user_id)
+                    .then_some(proto::channel_member::Kind::Invitee)
+            })
+    }
+
+    fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
+        match self.mode {
+            Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
+                let channel_membership = self.members.get(*ix)?;
+                Some((
+                    channel_membership.user.clone(),
+                    Some(channel_membership.admin),
+                ))
+            }),
+            Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
+        }
+    }
+
+    fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+        let (user, admin) = self.user_at_index(self.selected_index)?;
+        let admin = !admin.unwrap_or(false);
+        let update = self.channel_store.update(cx, |store, cx| {
+            store.set_member_admin(self.channel_id, user.id, admin, cx)
+        });
+        cx.spawn(|picker, mut cx| async move {
+            update.await?;
+            picker.update(&mut cx, |picker, cx| {
+                let this = picker.delegate_mut();
+                if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
+                    member.admin = admin;
+                }
+                cx.focus_self();
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+        Some(())
+    }
+
+    fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+        let (user, _) = self.user_at_index(self.selected_index)?;
+        let user_id = user.id;
+        let update = self.channel_store.update(cx, |store, cx| {
+            store.remove_member(self.channel_id, user_id, cx)
+        });
+        cx.spawn(|picker, mut cx| async move {
+            update.await?;
+            picker.update(&mut cx, |picker, cx| {
+                let this = picker.delegate_mut();
+                if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
+                    this.members.remove(ix);
+                    this.matching_member_indices.retain_mut(|member_ix| {
+                        if *member_ix == ix {
+                            return false;
+                        } else if *member_ix > ix {
+                            *member_ix -= 1;
+                        }
+                        true
+                    })
+                }
+
+                this.selected_index = this
+                    .selected_index
+                    .min(this.matching_member_indices.len().saturating_sub(1));
+
+                cx.focus_self();
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+        Some(())
+    }
+
+    fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
+        let invite_member = self.channel_store.update(cx, |store, cx| {
+            store.invite_member(self.channel_id, user.id, false, cx)
+        });
+
+        cx.spawn(|this, mut cx| async move {
+            invite_member.await?;
+
+            this.update(&mut cx, |this, cx| {
+                this.delegate_mut().members.push(ChannelMembership {
+                    user,
+                    kind: proto::channel_member::Kind::Invitee,
+                    admin: false,
+                });
+                cx.notify();
+            })
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
+        self.context_menu.update(cx, |context_menu, cx| {
+            context_menu.show(
+                Default::default(),
+                AnchorCorner::TopRight,
+                vec![
+                    ContextMenuItem::action("Remove", RemoveMember),
+                    ContextMenuItem::action(
+                        if user_is_admin {
+                            "Make non-admin"
+                        } else {
+                            "Make admin"
+                        },
+                        ToggleMemberAdmin,
+                    ),
+                ],
+                cx,
+            )
+        })
+    }
+}

crates/collab_ui/src/contact_finder.rs → crates/collab_ui/src/collab_panel/contact_finder.rs 🔗

@@ -1,28 +1,132 @@
 use client::{ContactRequestStatus, User, UserStore};
-use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext};
+use gpui::{
+    elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+};
 use picker::{Picker, PickerDelegate, PickerEvent};
 use std::sync::Arc;
 use util::TryFutureExt;
+use workspace::Modal;
 
 pub fn init(cx: &mut AppContext) {
     Picker::<ContactFinderDelegate>::init(cx);
+    cx.add_action(ContactFinder::dismiss)
 }
 
-pub type ContactFinder = Picker<ContactFinderDelegate>;
+pub struct ContactFinder {
+    picker: ViewHandle<Picker<ContactFinderDelegate>>,
+    has_focus: bool,
+}
 
-pub fn build_contact_finder(
-    user_store: ModelHandle<UserStore>,
-    cx: &mut ViewContext<ContactFinder>,
-) -> ContactFinder {
-    Picker::new(
-        ContactFinderDelegate {
-            user_store,
-            potential_contacts: Arc::from([]),
-            selected_index: 0,
-        },
-        cx,
-    )
-    .with_theme(|theme| theme.contact_finder.picker.clone())
+impl ContactFinder {
+    pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+        let picker = cx.add_view(|cx| {
+            Picker::new(
+                ContactFinderDelegate {
+                    user_store,
+                    potential_contacts: Arc::from([]),
+                    selected_index: 0,
+                },
+                cx,
+            )
+            .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+        });
+
+        cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+        Self {
+            picker,
+            has_focus: false,
+        }
+    }
+
+    pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
+        self.picker.update(cx, |picker, cx| {
+            picker.set_query(query, cx);
+        });
+    }
+
+    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(PickerEvent::Dismiss);
+    }
+}
+
+impl Entity for ContactFinder {
+    type Event = PickerEvent;
+}
+
+impl View for ContactFinder {
+    fn ui_name() -> &'static str {
+        "ContactFinder"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.tabbed_modal;
+
+        fn render_mode_button(
+            text: &'static str,
+            theme: &theme::TabbedModal,
+            _cx: &mut ViewContext<ContactFinder>,
+        ) -> AnyElement<ContactFinder> {
+            let contained_text = &theme.tab_button.active_state().default;
+            Label::new(text, contained_text.text.clone())
+                .contained()
+                .with_style(contained_text.container.clone())
+                .into_any()
+        }
+
+        Flex::column()
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new("Contacts", theme.title.text.clone())
+                            .contained()
+                            .with_style(theme.title.container.clone()),
+                    )
+                    .with_child(Flex::row().with_children([render_mode_button(
+                        "Invite new contacts",
+                        &theme,
+                        cx,
+                    )]))
+                    .expanded()
+                    .contained()
+                    .with_style(theme.header),
+            )
+            .with_child(
+                ChildView::new(&self.picker, cx)
+                    .contained()
+                    .with_style(theme.body),
+            )
+            .constrained()
+            .with_max_height(theme.max_height)
+            .with_max_width(theme.max_width)
+            .contained()
+            .with_style(theme.modal)
+            .into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_focus = true;
+        if cx.is_self_focused() {
+            cx.focus(&self.picker)
+        }
+    }
+
+    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Modal for ContactFinder {
+    fn has_focus(&self) -> bool {
+        self.has_focus
+    }
+
+    fn dismiss_on_event(event: &Self::Event) -> bool {
+        match event {
+            PickerEvent::Dismiss => true,
+        }
+    }
 }
 
 pub struct ContactFinderDelegate {
@@ -97,7 +201,9 @@ impl PickerDelegate for ContactFinderDelegate {
         selected: bool,
         cx: &gpui::AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let theme = &theme::current(cx);
+        let full_theme = &theme::current(cx);
+        let theme = &full_theme.collab_panel.contact_finder;
+        let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
         let user = &self.potential_contacts[ix];
         let request_status = self.user_store.read(cx).contact_request_status(user);
 
@@ -109,12 +215,11 @@ impl PickerDelegate for ContactFinderDelegate {
             ContactRequestStatus::RequestAccepted => None,
         };
         let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
-            &theme.contact_finder.disabled_contact_button
+            &theme.disabled_contact_button
         } else {
-            &theme.contact_finder.contact_button
+            &theme.contact_button
         };
-        let style = theme
-            .contact_finder
+        let style = tabbed_modal
             .picker
             .item
             .in_state(selected)
@@ -122,14 +227,14 @@ impl PickerDelegate for ContactFinderDelegate {
         Flex::row()
             .with_children(user.avatar.clone().map(|avatar| {
                 Image::from_data(avatar)
-                    .with_style(theme.contact_finder.contact_avatar)
+                    .with_style(theme.contact_avatar)
                     .aligned()
                     .left()
             }))
             .with_child(
                 Label::new(user.github_login.clone(), style.label.clone())
                     .contained()
-                    .with_style(theme.contact_finder.contact_username)
+                    .with_style(theme.contact_username)
                     .aligned()
                     .left(),
             )
@@ -150,7 +255,7 @@ impl PickerDelegate for ContactFinderDelegate {
             .contained()
             .with_style(style.container)
             .constrained()
-            .with_height(theme.contact_finder.row_height)
+            .with_height(tabbed_modal.row_height)
             .into_any()
     }
 }

crates/collab_ui/src/collab_panel/panel_settings.rs 🔗

@@ -0,0 +1,39 @@
+use anyhow;
+use schemars::JsonSchema;
+use serde_derive::{Deserialize, Serialize};
+use settings::Setting;
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum CollaborationPanelDockPosition {
+    Left,
+    Right,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct CollaborationPanelSettings {
+    pub button: bool,
+    pub dock: CollaborationPanelDockPosition,
+    pub default_width: f32,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct CollaborationPanelSettingsContent {
+    pub button: Option<bool>,
+    pub dock: Option<CollaborationPanelDockPosition>,
+    pub default_width: Option<f32>,
+}
+
+impl Setting for CollaborationPanelSettings {
+    const KEY: Option<&'static str> = Some("collaboration_panel");
+
+    type FileContent = CollaborationPanelSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -1,12 +1,10 @@
 use crate::{
-    contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
-    toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute,
-    ToggleScreenSharing,
+    contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
+    toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
 };
 use call::{ActiveCall, ParticipantLocation, Room};
 use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
 use clock::ReplicaId;
-use contacts_popover::ContactsPopover;
 use context_menu::{ContextMenu, ContextMenuItem};
 use gpui::{
     actions,
@@ -33,7 +31,6 @@ const MAX_BRANCH_NAME_LENGTH: usize = 40;
 actions!(
     collab,
     [
-        ToggleContactsMenu,
         ToggleUserMenu,
         ToggleProjectMenu,
         SwitchBranch,
@@ -43,7 +40,6 @@ actions!(
 );
 
 pub fn init(cx: &mut AppContext) {
-    cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
     cx.add_action(CollabTitlebarItem::share_project);
     cx.add_action(CollabTitlebarItem::unshare_project);
     cx.add_action(CollabTitlebarItem::toggle_user_menu);
@@ -56,7 +52,6 @@ pub struct CollabTitlebarItem {
     user_store: ModelHandle<UserStore>,
     client: Arc<Client>,
     workspace: WeakViewHandle<Workspace>,
-    contacts_popover: Option<ViewHandle<ContactsPopover>>,
     branch_popover: Option<ViewHandle<BranchList>>,
     project_popover: Option<ViewHandle<recent_projects::RecentProjects>>,
     user_menu: ViewHandle<ContextMenu>,
@@ -95,7 +90,7 @@ impl View for CollabTitlebarItem {
             right_container
                 .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
             right_container.add_child(self.render_leave_call(&theme, cx));
-            let muted = room.read(cx).is_muted();
+            let muted = room.read(cx).is_muted(cx);
             let speaking = room.read(cx).is_speaking();
             left_container.add_child(
                 self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
@@ -109,7 +104,6 @@ impl View for CollabTitlebarItem {
         let status = workspace.read(cx).client().status();
         let status = &*status.borrow();
         if matches!(status, client::Status::Connected { .. }) {
-            right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
             let avatar = user.as_ref().and_then(|user| user.avatar.clone());
             right_container.add_child(self.render_user_menu_button(&theme, avatar, cx));
         } else {
@@ -184,7 +178,6 @@ impl CollabTitlebarItem {
             project,
             user_store,
             client,
-            contacts_popover: None,
             user_menu: cx.add_view(|cx| {
                 let view_id = cx.view_id();
                 let mut menu = ContextMenu::new(view_id, cx);
@@ -315,9 +308,6 @@ impl CollabTitlebarItem {
     }
 
     fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
-        if ActiveCall::global(cx).read(cx).room().is_none() {
-            self.contacts_popover = None;
-        }
         cx.notify();
     }
 
@@ -337,32 +327,6 @@ impl CollabTitlebarItem {
             .log_err();
     }
 
-    pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
-        if self.contacts_popover.take().is_none() {
-            let view = cx.add_view(|cx| {
-                ContactsPopover::new(
-                    self.project.clone(),
-                    self.user_store.clone(),
-                    self.workspace.clone(),
-                    cx,
-                )
-            });
-            cx.subscribe(&view, |this, _, event, cx| {
-                match event {
-                    contacts_popover::Event::Dismissed => {
-                        this.contacts_popover = None;
-                    }
-                }
-
-                cx.notify();
-            })
-            .detach();
-            self.contacts_popover = Some(view);
-        }
-
-        cx.notify();
-    }
-
     pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
         self.user_menu.update(cx, |user_menu, cx| {
             let items = if let Some(_) = self.user_store.read(cx).current_user() {
@@ -390,6 +354,7 @@ impl CollabTitlebarItem {
             user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx);
         });
     }
+
     fn render_branches_popover_host<'a>(
         &'a self,
         _theme: &'a theme::Titlebar,
@@ -403,8 +368,8 @@ impl CollabTitlebarItem {
                     .flex(1., true)
                     .contained()
                     .constrained()
-                    .with_width(theme.contacts_popover.width)
-                    .with_height(theme.contacts_popover.height)
+                    .with_width(theme.titlebar.menu.width)
+                    .with_height(theme.titlebar.menu.height)
             })
             .on_click(MouseButton::Left, |_, _, _| {})
             .on_down_out(MouseButton::Left, move |_, this, cx| {
@@ -425,6 +390,7 @@ impl CollabTitlebarItem {
                 .into_any()
         })
     }
+
     fn render_project_popover_host<'a>(
         &'a self,
         _theme: &'a theme::Titlebar,
@@ -438,8 +404,8 @@ impl CollabTitlebarItem {
                     .flex(1., true)
                     .contained()
                     .constrained()
-                    .with_width(theme.contacts_popover.width)
-                    .with_height(theme.contacts_popover.height)
+                    .with_width(theme.titlebar.menu.width)
+                    .with_height(theme.titlebar.menu.height)
             })
             .on_click(MouseButton::Left, |_, _, _| {})
             .on_down_out(MouseButton::Left, move |_, this, cx| {
@@ -459,6 +425,7 @@ impl CollabTitlebarItem {
                 .into_any()
         })
     }
+
     pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
         if self.branch_popover.take().is_none() {
             if let Some(workspace) = self.workspace.upgrade(cx) {
@@ -519,79 +486,7 @@ impl CollabTitlebarItem {
         }
         cx.notify();
     }
-    fn render_toggle_contacts_button(
-        &self,
-        theme: &Theme,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        let titlebar = &theme.titlebar;
-
-        let badge = if self
-            .user_store
-            .read(cx)
-            .incoming_contact_requests()
-            .is_empty()
-        {
-            None
-        } else {
-            Some(
-                Empty::new()
-                    .collapsed()
-                    .contained()
-                    .with_style(titlebar.toggle_contacts_badge)
-                    .contained()
-                    .with_margin_left(
-                        titlebar
-                            .toggle_contacts_button
-                            .inactive_state()
-                            .default
-                            .icon_width,
-                    )
-                    .with_margin_top(
-                        titlebar
-                            .toggle_contacts_button
-                            .inactive_state()
-                            .default
-                            .icon_width,
-                    )
-                    .aligned(),
-            )
-        };
 
-        Stack::new()
-            .with_child(
-                MouseEventHandler::new::<ToggleContactsMenu, _>(0, cx, |state, _| {
-                    let style = titlebar
-                        .toggle_contacts_button
-                        .in_state(self.contacts_popover.is_some())
-                        .style_for(state);
-                    Svg::new("icons/radix/person.svg")
-                        .with_color(style.color)
-                        .constrained()
-                        .with_width(style.icon_width)
-                        .aligned()
-                        .constrained()
-                        .with_width(style.button_width)
-                        .with_height(style.button_width)
-                        .contained()
-                        .with_style(style.container)
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, this, cx| {
-                    this.toggle_contacts_popover(&Default::default(), cx)
-                })
-                .with_tooltip::<ToggleContactsMenu>(
-                    0,
-                    "Show contacts menu",
-                    Some(Box::new(ToggleContactsMenu)),
-                    theme.tooltip.clone(),
-                    cx,
-                ),
-            )
-            .with_children(badge)
-            .with_children(self.render_contacts_popover_host(titlebar, cx))
-            .into_any()
-    }
     fn render_toggle_screen_sharing_button(
         &self,
         theme: &Theme,
@@ -649,7 +544,7 @@ impl CollabTitlebarItem {
     ) -> AnyElement<Self> {
         let icon;
         let tooltip;
-        let is_muted = room.read(cx).is_muted();
+        let is_muted = room.read(cx).is_muted(cx);
         if is_muted {
             icon = "icons/radix/mic-mute.svg";
             tooltip = "Unmute microphone";
@@ -923,23 +818,6 @@ impl CollabTitlebarItem {
         .into_any()
     }
 
-    fn render_contacts_popover_host<'a>(
-        &'a self,
-        _theme: &'a theme::Titlebar,
-        cx: &'a ViewContext<Self>,
-    ) -> Option<AnyElement<Self>> {
-        self.contacts_popover.as_ref().map(|popover| {
-            Overlay::new(ChildView::new(popover, cx))
-                .with_fit_mode(OverlayFitMode::SwitchAnchor)
-                .with_anchor_corner(AnchorCorner::TopLeft)
-                .with_z_index(999)
-                .aligned()
-                .bottom()
-                .right()
-                .into_any()
-        })
-    }
-
     fn render_collaborators(
         &self,
         workspace: &ViewHandle<Workspace>,

crates/collab_ui/src/collab_ui.rs 🔗

@@ -1,8 +1,6 @@
+pub mod collab_panel;
 mod collab_titlebar_item;
-mod contact_finder;
-mod contact_list;
 mod contact_notification;
-mod contacts_popover;
 mod face_pile;
 mod incoming_call_notification;
 mod notifications;
@@ -10,7 +8,7 @@ mod project_shared_notification;
 mod sharing_status_indicator;
 
 use call::{ActiveCall, Room};
-pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu};
+pub use collab_titlebar_item::CollabTitlebarItem;
 use gpui::{actions, AppContext, Task};
 use std::sync::Arc;
 use util::ResultExt;
@@ -24,9 +22,7 @@ actions!(
 pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
     vcs_menu::init(cx);
     collab_titlebar_item::init(cx);
-    contact_list::init(cx);
-    contact_finder::init(cx);
-    contacts_popover::init(cx);
+    collab_panel::init(app_state.client.clone(), cx);
     incoming_call_notification::init(&app_state, cx);
     project_shared_notification::init(&app_state, cx);
     sharing_status_indicator::init(cx);
@@ -68,7 +64,7 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
     if let Some(room) = call.room().cloned() {
         let client = call.client();
         room.update(cx, |room, cx| {
-            if room.is_muted() {
+            if room.is_muted(cx) {
                 ActiveCall::report_call_event_for_room("enable microphone", room.id(), &client, cx);
             } else {
                 ActiveCall::report_call_event_for_room(

crates/collab_ui/src/contact_list.rs 🔗

@@ -1,1385 +0,0 @@
-use call::ActiveCall;
-use client::{proto::PeerId, Contact, User, UserStore};
-use editor::{Cancel, Editor};
-use futures::StreamExt;
-use fuzzy::{match_strings, StringMatchCandidate};
-use gpui::{
-    elements::*,
-    geometry::{rect::RectF, vector::vec2f},
-    impl_actions,
-    keymap_matcher::KeymapContext,
-    platform::{CursorStyle, MouseButton, PromptLevel},
-    AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
-};
-use menu::{Confirm, SelectNext, SelectPrev};
-use project::Project;
-use serde::Deserialize;
-use std::{mem, sync::Arc};
-use theme::IconButton;
-use workspace::Workspace;
-
-impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
-
-pub fn init(cx: &mut AppContext) {
-    cx.add_action(ContactList::remove_contact);
-    cx.add_action(ContactList::respond_to_contact_request);
-    cx.add_action(ContactList::cancel);
-    cx.add_action(ContactList::select_next);
-    cx.add_action(ContactList::select_prev);
-    cx.add_action(ContactList::confirm);
-}
-
-#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
-enum Section {
-    ActiveCall,
-    Requests,
-    Online,
-    Offline,
-}
-
-#[derive(Clone)]
-enum ContactEntry {
-    Header(Section),
-    CallParticipant {
-        user: Arc<User>,
-        is_pending: bool,
-    },
-    ParticipantProject {
-        project_id: u64,
-        worktree_root_names: Vec<String>,
-        host_user_id: u64,
-        is_last: bool,
-    },
-    ParticipantScreen {
-        peer_id: PeerId,
-        is_last: bool,
-    },
-    IncomingRequest(Arc<User>),
-    OutgoingRequest(Arc<User>),
-    Contact {
-        contact: Arc<Contact>,
-        calling: bool,
-    },
-}
-
-impl PartialEq for ContactEntry {
-    fn eq(&self, other: &Self) -> bool {
-        match self {
-            ContactEntry::Header(section_1) => {
-                if let ContactEntry::Header(section_2) = other {
-                    return section_1 == section_2;
-                }
-            }
-            ContactEntry::CallParticipant { user: user_1, .. } => {
-                if let ContactEntry::CallParticipant { user: user_2, .. } = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::ParticipantProject {
-                project_id: project_id_1,
-                ..
-            } => {
-                if let ContactEntry::ParticipantProject {
-                    project_id: project_id_2,
-                    ..
-                } = other
-                {
-                    return project_id_1 == project_id_2;
-                }
-            }
-            ContactEntry::ParticipantScreen {
-                peer_id: peer_id_1, ..
-            } => {
-                if let ContactEntry::ParticipantScreen {
-                    peer_id: peer_id_2, ..
-                } = other
-                {
-                    return peer_id_1 == peer_id_2;
-                }
-            }
-            ContactEntry::IncomingRequest(user_1) => {
-                if let ContactEntry::IncomingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::OutgoingRequest(user_1) => {
-                if let ContactEntry::OutgoingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::Contact {
-                contact: contact_1, ..
-            } => {
-                if let ContactEntry::Contact {
-                    contact: contact_2, ..
-                } = other
-                {
-                    return contact_1.user.id == contact_2.user.id;
-                }
-            }
-        }
-        false
-    }
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RequestContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RemoveContact {
-    user_id: u64,
-    github_login: String,
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RespondToContactRequest {
-    pub user_id: u64,
-    pub accept: bool,
-}
-
-pub enum Event {
-    ToggleContactFinder,
-    Dismissed,
-}
-
-pub struct ContactList {
-    entries: Vec<ContactEntry>,
-    match_candidates: Vec<StringMatchCandidate>,
-    list_state: ListState<Self>,
-    project: ModelHandle<Project>,
-    workspace: WeakViewHandle<Workspace>,
-    user_store: ModelHandle<UserStore>,
-    filter_editor: ViewHandle<Editor>,
-    collapsed_sections: Vec<Section>,
-    selection: Option<usize>,
-    _subscriptions: Vec<Subscription>,
-}
-
-impl ContactList {
-    pub fn new(
-        project: ModelHandle<Project>,
-        user_store: ModelHandle<UserStore>,
-        workspace: WeakViewHandle<Workspace>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let filter_editor = cx.add_view(|cx| {
-            let mut editor = Editor::single_line(
-                Some(Arc::new(|theme| {
-                    theme.contact_list.user_query_editor.clone()
-                })),
-                cx,
-            );
-            editor.set_placeholder_text("Filter contacts", cx);
-            editor
-        });
-
-        cx.subscribe(&filter_editor, |this, _, event, cx| {
-            if let editor::Event::BufferEdited = event {
-                let query = this.filter_editor.read(cx).text(cx);
-                if !query.is_empty() {
-                    this.selection.take();
-                }
-                this.update_entries(cx);
-                if !query.is_empty() {
-                    this.selection = this
-                        .entries
-                        .iter()
-                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
-                }
-            }
-        })
-        .detach();
-
-        let list_state = ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
-            let theme = theme::current(cx).clone();
-            let is_selected = this.selection == Some(ix);
-            let current_project_id = this.project.read(cx).remote_id();
-
-            match &this.entries[ix] {
-                ContactEntry::Header(section) => {
-                    let is_collapsed = this.collapsed_sections.contains(section);
-                    Self::render_header(
-                        *section,
-                        &theme.contact_list,
-                        is_selected,
-                        is_collapsed,
-                        cx,
-                    )
-                }
-                ContactEntry::CallParticipant { user, is_pending } => {
-                    Self::render_call_participant(
-                        user,
-                        *is_pending,
-                        is_selected,
-                        &theme.contact_list,
-                    )
-                }
-                ContactEntry::ParticipantProject {
-                    project_id,
-                    worktree_root_names,
-                    host_user_id,
-                    is_last,
-                } => Self::render_participant_project(
-                    *project_id,
-                    worktree_root_names,
-                    *host_user_id,
-                    Some(*project_id) == current_project_id,
-                    *is_last,
-                    is_selected,
-                    &theme.contact_list,
-                    cx,
-                ),
-                ContactEntry::ParticipantScreen { peer_id, is_last } => {
-                    Self::render_participant_screen(
-                        *peer_id,
-                        *is_last,
-                        is_selected,
-                        &theme.contact_list,
-                        cx,
-                    )
-                }
-                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contact_list,
-                    true,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contact_list,
-                    false,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::Contact { contact, calling } => Self::render_contact(
-                    contact,
-                    *calling,
-                    &this.project,
-                    &theme.contact_list,
-                    is_selected,
-                    cx,
-                ),
-            }
-        });
-
-        let active_call = ActiveCall::global(cx);
-        let mut subscriptions = Vec::new();
-        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
-        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
-
-        let mut this = Self {
-            list_state,
-            selection: None,
-            collapsed_sections: Default::default(),
-            entries: Default::default(),
-            match_candidates: Default::default(),
-            filter_editor,
-            _subscriptions: subscriptions,
-            project,
-            workspace,
-            user_store,
-        };
-        this.update_entries(cx);
-        this
-    }
-
-    pub fn editor_text(&self, cx: &AppContext) -> String {
-        self.filter_editor.read(cx).text(cx)
-    }
-
-    pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
-        self.filter_editor
-            .update(cx, |picker, cx| picker.set_text(editor_text, cx));
-        self
-    }
-
-    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
-        let user_id = request.user_id;
-        let github_login = &request.github_login;
-        let user_store = self.user_store.clone();
-        let prompt_message = format!(
-            "Are you sure you want to remove \"{}\" from your contacts?",
-            github_login
-        );
-        let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
-        let window = cx.window();
-        cx.spawn(|_, mut cx| async move {
-            if answer.next().await == Some(0) {
-                if let Err(e) = user_store
-                    .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
-                    .await
-                {
-                    window.prompt(
-                        PromptLevel::Info,
-                        &format!("Failed to remove contact: {}", e),
-                        &["Ok"],
-                        &mut cx,
-                    );
-                }
-            }
-        })
-        .detach();
-    }
-
-    fn respond_to_contact_request(
-        &mut self,
-        action: &RespondToContactRequest,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.user_store
-            .update(cx, |store, cx| {
-                store.respond_to_contact_request(action.user_id, action.accept, cx)
-            })
-            .detach();
-    }
-
-    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
-        let did_clear = self.filter_editor.update(cx, |editor, cx| {
-            if editor.buffer().read(cx).len(cx) > 0 {
-                editor.set_text("", cx);
-                true
-            } else {
-                false
-            }
-        });
-
-        if !did_clear {
-            cx.emit(Event::Dismissed);
-        }
-    }
-
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if self.entries.len() > ix + 1 {
-                self.selection = Some(ix + 1);
-            }
-        } else if !self.entries.is_empty() {
-            self.selection = Some(0);
-        }
-        self.list_state.reset(self.entries.len());
-        if let Some(ix) = self.selection {
-            self.list_state.scroll_to(ListOffset {
-                item_ix: ix,
-                offset_in_item: 0.,
-            });
-        }
-        cx.notify();
-    }
-
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if ix > 0 {
-                self.selection = Some(ix - 1);
-            } else {
-                self.selection = None;
-            }
-        }
-        self.list_state.reset(self.entries.len());
-        if let Some(ix) = self.selection {
-            self.list_state.scroll_to(ListOffset {
-                item_ix: ix,
-                offset_in_item: 0.,
-            });
-        }
-        cx.notify();
-    }
-
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        if let Some(selection) = self.selection {
-            if let Some(entry) = self.entries.get(selection) {
-                match entry {
-                    ContactEntry::Header(section) => {
-                        self.toggle_expanded(*section, cx);
-                    }
-                    ContactEntry::Contact { contact, calling } => {
-                        if contact.online && !contact.busy && !calling {
-                            self.call(contact.user.id, Some(self.project.clone()), cx);
-                        }
-                    }
-                    ContactEntry::ParticipantProject {
-                        project_id,
-                        host_user_id,
-                        ..
-                    } => {
-                        if let Some(workspace) = self.workspace.upgrade(cx) {
-                            let app_state = workspace.read(cx).app_state().clone();
-                            workspace::join_remote_project(
-                                *project_id,
-                                *host_user_id,
-                                app_state,
-                                cx,
-                            )
-                            .detach_and_log_err(cx);
-                        }
-                    }
-                    ContactEntry::ParticipantScreen { peer_id, .. } => {
-                        if let Some(workspace) = self.workspace.upgrade(cx) {
-                            workspace.update(cx, |workspace, cx| {
-                                workspace.open_shared_screen(*peer_id, cx)
-                            });
-                        }
-                    }
-                    _ => {}
-                }
-            }
-        }
-    }
-
-    fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
-            self.collapsed_sections.remove(ix);
-        } else {
-            self.collapsed_sections.push(section);
-        }
-        self.update_entries(cx);
-    }
-
-    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
-        let user_store = self.user_store.read(cx);
-        let query = self.filter_editor.read(cx).text(cx);
-        let executor = cx.background().clone();
-
-        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
-        let old_entries = mem::take(&mut self.entries);
-
-        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
-            let room = room.read(cx);
-            let mut participant_entries = Vec::new();
-
-            // Populate the active user.
-            if let Some(user) = user_store.current_user() {
-                self.match_candidates.clear();
-                self.match_candidates.push(StringMatchCandidate {
-                    id: 0,
-                    string: user.github_login.clone(),
-                    char_bag: user.github_login.chars().collect(),
-                });
-                let matches = executor.block(match_strings(
-                    &self.match_candidates,
-                    &query,
-                    true,
-                    usize::MAX,
-                    &Default::default(),
-                    executor.clone(),
-                ));
-                if !matches.is_empty() {
-                    let user_id = user.id;
-                    participant_entries.push(ContactEntry::CallParticipant {
-                        user,
-                        is_pending: false,
-                    });
-                    let mut projects = room.local_participant().projects.iter().peekable();
-                    while let Some(project) = projects.next() {
-                        participant_entries.push(ContactEntry::ParticipantProject {
-                            project_id: project.id,
-                            worktree_root_names: project.worktree_root_names.clone(),
-                            host_user_id: user_id,
-                            is_last: projects.peek().is_none(),
-                        });
-                    }
-                }
-            }
-
-            // Populate remote participants.
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(room.remote_participants().iter().map(|(_, participant)| {
-                    StringMatchCandidate {
-                        id: participant.user.id as usize,
-                        string: participant.user.github_login.clone(),
-                        char_bag: participant.user.github_login.chars().collect(),
-                    }
-                }));
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            for mat in matches {
-                let user_id = mat.candidate_id as u64;
-                let participant = &room.remote_participants()[&user_id];
-                participant_entries.push(ContactEntry::CallParticipant {
-                    user: participant.user.clone(),
-                    is_pending: false,
-                });
-                let mut projects = participant.projects.iter().peekable();
-                while let Some(project) = projects.next() {
-                    participant_entries.push(ContactEntry::ParticipantProject {
-                        project_id: project.id,
-                        worktree_root_names: project.worktree_root_names.clone(),
-                        host_user_id: participant.user.id,
-                        is_last: projects.peek().is_none() && participant.video_tracks.is_empty(),
-                    });
-                }
-                if !participant.video_tracks.is_empty() {
-                    participant_entries.push(ContactEntry::ParticipantScreen {
-                        peer_id: participant.peer_id,
-                        is_last: true,
-                    });
-                }
-            }
-
-            // Populate pending participants.
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    room.pending_participants()
-                        .iter()
-                        .enumerate()
-                        .map(|(id, participant)| StringMatchCandidate {
-                            id,
-                            string: participant.github_login.clone(),
-                            char_bag: participant.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
-                user: room.pending_participants()[mat.candidate_id].clone(),
-                is_pending: true,
-            }));
-
-            if !participant_entries.is_empty() {
-                self.entries.push(ContactEntry::Header(Section::ActiveCall));
-                if !self.collapsed_sections.contains(&Section::ActiveCall) {
-                    self.entries.extend(participant_entries);
-                }
-            }
-        }
-
-        let mut request_entries = Vec::new();
-        let incoming = user_store.incoming_contact_requests();
-        if !incoming.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    incoming
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, user)| StringMatchCandidate {
-                            id: ix,
-                            string: user.github_login.clone(),
-                            char_bag: user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            request_entries.extend(
-                matches
-                    .iter()
-                    .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
-            );
-        }
-
-        let outgoing = user_store.outgoing_contact_requests();
-        if !outgoing.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    outgoing
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, user)| StringMatchCandidate {
-                            id: ix,
-                            string: user.github_login.clone(),
-                            char_bag: user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            request_entries.extend(
-                matches
-                    .iter()
-                    .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
-            );
-        }
-
-        if !request_entries.is_empty() {
-            self.entries.push(ContactEntry::Header(Section::Requests));
-            if !self.collapsed_sections.contains(&Section::Requests) {
-                self.entries.append(&mut request_entries);
-            }
-        }
-
-        let contacts = user_store.contacts();
-        if !contacts.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    contacts
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, contact)| StringMatchCandidate {
-                            id: ix,
-                            string: contact.user.github_login.clone(),
-                            char_bag: contact.user.github_login.chars().collect(),
-                        }),
-                );
-
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-
-            let (mut online_contacts, offline_contacts) = matches
-                .iter()
-                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
-            if let Some(room) = ActiveCall::global(cx).read(cx).room() {
-                let room = room.read(cx);
-                online_contacts.retain(|contact| {
-                    let contact = &contacts[contact.candidate_id];
-                    !room.contains_participant(contact.user.id)
-                });
-            }
-
-            for (matches, section) in [
-                (online_contacts, Section::Online),
-                (offline_contacts, Section::Offline),
-            ] {
-                if !matches.is_empty() {
-                    self.entries.push(ContactEntry::Header(section));
-                    if !self.collapsed_sections.contains(&section) {
-                        let active_call = &ActiveCall::global(cx).read(cx);
-                        for mat in matches {
-                            let contact = &contacts[mat.candidate_id];
-                            self.entries.push(ContactEntry::Contact {
-                                contact: contact.clone(),
-                                calling: active_call.pending_invites().contains(&contact.user.id),
-                            });
-                        }
-                    }
-                }
-            }
-        }
-
-        if let Some(prev_selected_entry) = prev_selected_entry {
-            self.selection.take();
-            for (ix, entry) in self.entries.iter().enumerate() {
-                if *entry == prev_selected_entry {
-                    self.selection = Some(ix);
-                    break;
-                }
-            }
-        }
-
-        let old_scroll_top = self.list_state.logical_scroll_top();
-        self.list_state.reset(self.entries.len());
-
-        // Attempt to maintain the same scroll position.
-        if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
-            let new_scroll_top = self
-                .entries
-                .iter()
-                .position(|entry| entry == old_top_entry)
-                .map(|item_ix| ListOffset {
-                    item_ix,
-                    offset_in_item: old_scroll_top.offset_in_item,
-                })
-                .or_else(|| {
-                    let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
-                    let item_ix = self
-                        .entries
-                        .iter()
-                        .position(|entry| entry == entry_after_old_top)?;
-                    Some(ListOffset {
-                        item_ix,
-                        offset_in_item: 0.,
-                    })
-                })
-                .or_else(|| {
-                    let entry_before_old_top =
-                        old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
-                    let item_ix = self
-                        .entries
-                        .iter()
-                        .position(|entry| entry == entry_before_old_top)?;
-                    Some(ListOffset {
-                        item_ix,
-                        offset_in_item: 0.,
-                    })
-                });
-
-            self.list_state
-                .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
-        }
-
-        cx.notify();
-    }
-
-    fn render_call_participant(
-        user: &User,
-        is_pending: bool,
-        is_selected: bool,
-        theme: &theme::ContactList,
-    ) -> AnyElement<Self> {
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::from_data(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true),
-            )
-            .with_children(if is_pending {
-                Some(
-                    Label::new("Calling", theme.calling_indicator.text.clone())
-                        .contained()
-                        .with_style(theme.calling_indicator.container)
-                        .aligned(),
-                )
-            } else {
-                None
-            })
-            .constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(
-                *theme
-                    .contact_row
-                    .in_state(is_selected)
-                    .style_for(&mut Default::default()),
-            )
-            .into_any()
-    }
-
-    fn render_participant_project(
-        project_id: u64,
-        worktree_root_names: &[String],
-        host_user_id: u64,
-        is_current: bool,
-        is_last: bool,
-        is_selected: bool,
-        theme: &theme::ContactList,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        enum JoinProject {}
-
-        let font_cache = cx.font_cache();
-        let host_avatar_height = theme
-            .contact_avatar
-            .width
-            .or(theme.contact_avatar.height)
-            .unwrap_or(0.);
-        let row = &theme.project_row.inactive_state().default;
-        let tree_branch = theme.tree_branch;
-        let line_height = row.name.text.line_height(font_cache);
-        let cap_height = row.name.text.cap_height(font_cache);
-        let baseline_offset =
-            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
-        let project_name = if worktree_root_names.is_empty() {
-            "untitled".to_string()
-        } else {
-            worktree_root_names.join(", ")
-        };
-
-        MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, _| {
-            let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
-            let row = theme
-                .project_row
-                .in_state(is_selected)
-                .style_for(mouse_state);
-
-            Flex::row()
-                .with_child(
-                    Stack::new()
-                        .with_child(Canvas::new(move |scene, bounds, _, _, _| {
-                            let start_x =
-                                bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
-                            let end_x = bounds.max_x();
-                            let start_y = bounds.min_y();
-                            let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
-                            scene.push_quad(gpui::Quad {
-                                bounds: RectF::from_points(
-                                    vec2f(start_x, start_y),
-                                    vec2f(
-                                        start_x + tree_branch.width,
-                                        if is_last { end_y } else { bounds.max_y() },
-                                    ),
-                                ),
-                                background: Some(tree_branch.color),
-                                border: gpui::Border::default(),
-                                corner_radii: Default::default(),
-                            });
-                            scene.push_quad(gpui::Quad {
-                                bounds: RectF::from_points(
-                                    vec2f(start_x, end_y),
-                                    vec2f(end_x, end_y + tree_branch.width),
-                                ),
-                                background: Some(tree_branch.color),
-                                border: gpui::Border::default(),
-                                corner_radii: Default::default(),
-                            });
-                        }))
-                        .constrained()
-                        .with_width(host_avatar_height),
-                )
-                .with_child(
-                    Label::new(project_name, row.name.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_style(row.name.container)
-                        .flex(1., false),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-        })
-        .with_cursor_style(if !is_current {
-            CursorStyle::PointingHand
-        } else {
-            CursorStyle::Arrow
-        })
-        .on_click(MouseButton::Left, move |_, this, cx| {
-            if !is_current {
-                if let Some(workspace) = this.workspace.upgrade(cx) {
-                    let app_state = workspace.read(cx).app_state().clone();
-                    workspace::join_remote_project(project_id, host_user_id, app_state, cx)
-                        .detach_and_log_err(cx);
-                }
-            }
-        })
-        .into_any()
-    }
-
-    fn render_participant_screen(
-        peer_id: PeerId,
-        is_last: bool,
-        is_selected: bool,
-        theme: &theme::ContactList,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        enum OpenSharedScreen {}
-
-        let font_cache = cx.font_cache();
-        let host_avatar_height = theme
-            .contact_avatar
-            .width
-            .or(theme.contact_avatar.height)
-            .unwrap_or(0.);
-        let row = &theme.project_row.inactive_state().default;
-        let tree_branch = theme.tree_branch;
-        let line_height = row.name.text.line_height(font_cache);
-        let cap_height = row.name.text.cap_height(font_cache);
-        let baseline_offset =
-            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
-
-        MouseEventHandler::new::<OpenSharedScreen, _>(
-            peer_id.as_u64() as usize,
-            cx,
-            |mouse_state, _| {
-                let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
-                let row = theme
-                    .project_row
-                    .in_state(is_selected)
-                    .style_for(mouse_state);
-
-                Flex::row()
-                    .with_child(
-                        Stack::new()
-                            .with_child(Canvas::new(move |scene, bounds, _, _, _| {
-                                let start_x = bounds.min_x() + (bounds.width() / 2.)
-                                    - (tree_branch.width / 2.);
-                                let end_x = bounds.max_x();
-                                let start_y = bounds.min_y();
-                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
-                                scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, start_y),
-                                        vec2f(
-                                            start_x + tree_branch.width,
-                                            if is_last { end_y } else { bounds.max_y() },
-                                        ),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radii: Default::default(),
-                                });
-                                scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, end_y),
-                                        vec2f(end_x, end_y + tree_branch.width),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radii: Default::default(),
-                                });
-                            }))
-                            .constrained()
-                            .with_width(host_avatar_height),
-                    )
-                    .with_child(
-                        Svg::new("icons/disable_screen_sharing_12.svg")
-                            .with_color(row.icon.color)
-                            .constrained()
-                            .with_width(row.icon.width)
-                            .aligned()
-                            .left()
-                            .contained()
-                            .with_style(row.icon.container),
-                    )
-                    .with_child(
-                        Label::new("Screen", row.name.text.clone())
-                            .aligned()
-                            .left()
-                            .contained()
-                            .with_style(row.name.container)
-                            .flex(1., false),
-                    )
-                    .constrained()
-                    .with_height(theme.row_height)
-                    .contained()
-                    .with_style(row.container)
-            },
-        )
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, move |_, this, cx| {
-            if let Some(workspace) = this.workspace.upgrade(cx) {
-                workspace.update(cx, |workspace, cx| {
-                    workspace.open_shared_screen(peer_id, cx)
-                });
-            }
-        })
-        .into_any()
-    }
-
-    fn render_header(
-        section: Section,
-        theme: &theme::ContactList,
-        is_selected: bool,
-        is_collapsed: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        enum Header {}
-        enum LeaveCallContactList {}
-
-        let header_style = theme
-            .header_row
-            .in_state(is_selected)
-            .style_for(&mut Default::default());
-        let text = match section {
-            Section::ActiveCall => "Collaborators",
-            Section::Requests => "Contact Requests",
-            Section::Online => "Online",
-            Section::Offline => "Offline",
-        };
-        let leave_call = if section == Section::ActiveCall {
-            Some(
-                MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
-                    let style = theme.leave_call.style_for(state);
-                    Label::new("Leave Call", style.text.clone())
-                        .contained()
-                        .with_style(style.container)
-                })
-                .on_click(MouseButton::Left, |_, _, cx| {
-                    ActiveCall::global(cx)
-                        .update(cx, |call, cx| call.hang_up(cx))
-                        .detach_and_log_err(cx);
-                })
-                .aligned(),
-            )
-        } else {
-            None
-        };
-
-        let icon_size = theme.section_icon_size;
-        MouseEventHandler::new::<Header, _>(section as usize, cx, |_, _| {
-            Flex::row()
-                .with_child(
-                    Svg::new(if is_collapsed {
-                        "icons/chevron_right_8.svg"
-                    } else {
-                        "icons/chevron_down_8.svg"
-                    })
-                    .with_color(header_style.text.color)
-                    .constrained()
-                    .with_max_width(icon_size)
-                    .with_max_height(icon_size)
-                    .aligned()
-                    .constrained()
-                    .with_width(icon_size),
-                )
-                .with_child(
-                    Label::new(text, header_style.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_margin_left(theme.contact_username.container.margin.left)
-                        .flex(1., true),
-                )
-                .with_children(leave_call)
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(header_style.container)
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, move |_, this, cx| {
-            this.toggle_expanded(section, cx);
-        })
-        .into_any()
-    }
-
-    fn render_contact(
-        contact: &Contact,
-        calling: bool,
-        project: &ModelHandle<Project>,
-        theme: &theme::ContactList,
-        is_selected: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        let online = contact.online;
-        let busy = contact.busy || calling;
-        let user_id = contact.user.id;
-        let github_login = contact.user.github_login.clone();
-        let initial_project = project.clone();
-        let mut event_handler =
-            MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |_, cx| {
-                Flex::row()
-                    .with_children(contact.user.avatar.clone().map(|avatar| {
-                        let status_badge = if contact.online {
-                            Some(
-                                Empty::new()
-                                    .collapsed()
-                                    .contained()
-                                    .with_style(if busy {
-                                        theme.contact_status_busy
-                                    } else {
-                                        theme.contact_status_free
-                                    })
-                                    .aligned(),
-                            )
-                        } else {
-                            None
-                        };
-                        Stack::new()
-                            .with_child(
-                                Image::from_data(avatar)
-                                    .with_style(theme.contact_avatar)
-                                    .aligned()
-                                    .left(),
-                            )
-                            .with_children(status_badge)
-                    }))
-                    .with_child(
-                        Label::new(
-                            contact.user.github_login.clone(),
-                            theme.contact_username.text.clone(),
-                        )
-                        .contained()
-                        .with_style(theme.contact_username.container)
-                        .aligned()
-                        .left()
-                        .flex(1., true),
-                    )
-                    .with_child(
-                        MouseEventHandler::new::<Cancel, _>(
-                            contact.user.id as usize,
-                            cx,
-                            |mouse_state, _| {
-                                let button_style = theme.contact_button.style_for(mouse_state);
-                                render_icon_button(button_style, "icons/x_mark_8.svg")
-                                    .aligned()
-                                    .flex_float()
-                            },
-                        )
-                        .with_padding(Padding::uniform(2.))
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(MouseButton::Left, move |_, this, cx| {
-                            this.remove_contact(
-                                &RemoveContact {
-                                    user_id,
-                                    github_login: github_login.clone(),
-                                },
-                                cx,
-                            );
-                        })
-                        .flex_float(),
-                    )
-                    .with_children(if calling {
-                        Some(
-                            Label::new("Calling", theme.calling_indicator.text.clone())
-                                .contained()
-                                .with_style(theme.calling_indicator.container)
-                                .aligned(),
-                        )
-                    } else {
-                        None
-                    })
-                    .constrained()
-                    .with_height(theme.row_height)
-                    .contained()
-                    .with_style(
-                        *theme
-                            .contact_row
-                            .in_state(is_selected)
-                            .style_for(&mut Default::default()),
-                    )
-            })
-            .on_click(MouseButton::Left, move |_, this, cx| {
-                if online && !busy {
-                    this.call(user_id, Some(initial_project.clone()), cx);
-                }
-            });
-
-        if online {
-            event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
-        }
-
-        event_handler.into_any()
-    }
-
-    fn render_contact_request(
-        user: Arc<User>,
-        user_store: ModelHandle<UserStore>,
-        theme: &theme::ContactList,
-        is_incoming: bool,
-        is_selected: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        enum Decline {}
-        enum Accept {}
-        enum Cancel {}
-
-        let mut row = Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::from_data(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true),
-            );
-
-        let user_id = user.id;
-        let github_login = user.github_login.clone();
-        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
-        let button_spacing = theme.contact_button_spacing;
-
-        if is_incoming {
-            row.add_child(
-                MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, this, cx| {
-                    this.respond_to_contact_request(
-                        &RespondToContactRequest {
-                            user_id,
-                            accept: false,
-                        },
-                        cx,
-                    );
-                })
-                .contained()
-                .with_margin_right(button_spacing),
-            );
-
-            row.add_child(
-                MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state)
-                    };
-                    render_icon_button(button_style, "icons/check_8.svg")
-                        .aligned()
-                        .flex_float()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, this, cx| {
-                    this.respond_to_contact_request(
-                        &RespondToContactRequest {
-                            user_id,
-                            accept: true,
-                        },
-                        cx,
-                    );
-                }),
-            );
-        } else {
-            row.add_child(
-                MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        .flex_float()
-                })
-                .with_padding(Padding::uniform(2.))
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, this, cx| {
-                    this.remove_contact(
-                        &RemoveContact {
-                            user_id,
-                            github_login: github_login.clone(),
-                        },
-                        cx,
-                    );
-                })
-                .flex_float(),
-            );
-        }
-
-        row.constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(
-                *theme
-                    .contact_row
-                    .in_state(is_selected)
-                    .style_for(&mut Default::default()),
-            )
-            .into_any()
-    }
-
-    fn call(
-        &mut self,
-        recipient_user_id: u64,
-        initial_project: Option<ModelHandle<Project>>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        ActiveCall::global(cx)
-            .update(cx, |call, cx| {
-                call.invite(recipient_user_id, initial_project, cx)
-            })
-            .detach_and_log_err(cx);
-    }
-}
-
-impl Entity for ContactList {
-    type Event = Event;
-}
-
-impl View for ContactList {
-    fn ui_name() -> &'static str {
-        "ContactList"
-    }
-
-    fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
-        Self::reset_to_default_keymap_context(keymap);
-        keymap.add_identifier("menu");
-    }
-
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        enum AddContact {}
-        let theme = theme::current(cx).clone();
-
-        Flex::column()
-            .with_child(
-                Flex::row()
-                    .with_child(
-                        ChildView::new(&self.filter_editor, cx)
-                            .contained()
-                            .with_style(theme.contact_list.user_query_editor.container)
-                            .flex(1., true),
-                    )
-                    .with_child(
-                        MouseEventHandler::new::<AddContact, _>(0, cx, |_, _| {
-                            render_icon_button(
-                                &theme.contact_list.add_contact_button,
-                                "icons/user_plus_16.svg",
-                            )
-                        })
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(MouseButton::Left, |_, _, cx| {
-                            cx.emit(Event::ToggleContactFinder)
-                        })
-                        .with_tooltip::<AddContact>(
-                            0,
-                            "Search for new contact",
-                            None,
-                            theme.tooltip.clone(),
-                            cx,
-                        ),
-                    )
-                    .constrained()
-                    .with_height(theme.contact_list.user_query_editor_height),
-            )
-            .with_child(List::new(self.list_state.clone()).flex(1., false))
-            .into_any()
-    }
-
-    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if !self.filter_editor.is_focused(cx) {
-            cx.focus(&self.filter_editor);
-        }
-    }
-
-    fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if !self.filter_editor.is_focused(cx) {
-            cx.emit(Event::Dismissed);
-        }
-    }
-}
-
-fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<ContactList> {
-    Svg::new(svg_path)
-        .with_color(style.color)
-        .constrained()
-        .with_width(style.icon_width)
-        .aligned()
-        .contained()
-        .with_style(style.container)
-        .constrained()
-        .with_width(style.button_width)
-        .with_height(style.button_width)
-}

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -1,137 +0,0 @@
-use crate::{
-    contact_finder::{build_contact_finder, ContactFinder},
-    contact_list::ContactList,
-};
-use client::UserStore;
-use gpui::{
-    actions, elements::*, platform::MouseButton, AppContext, Entity, ModelHandle, View,
-    ViewContext, ViewHandle, WeakViewHandle,
-};
-use picker::PickerEvent;
-use project::Project;
-use workspace::Workspace;
-
-actions!(contacts_popover, [ToggleContactFinder]);
-
-pub fn init(cx: &mut AppContext) {
-    cx.add_action(ContactsPopover::toggle_contact_finder);
-}
-
-pub enum Event {
-    Dismissed,
-}
-
-enum Child {
-    ContactList(ViewHandle<ContactList>),
-    ContactFinder(ViewHandle<ContactFinder>),
-}
-
-pub struct ContactsPopover {
-    child: Child,
-    project: ModelHandle<Project>,
-    user_store: ModelHandle<UserStore>,
-    workspace: WeakViewHandle<Workspace>,
-    _subscription: Option<gpui::Subscription>,
-}
-
-impl ContactsPopover {
-    pub fn new(
-        project: ModelHandle<Project>,
-        user_store: ModelHandle<UserStore>,
-        workspace: WeakViewHandle<Workspace>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let mut this = Self {
-            child: Child::ContactList(cx.add_view(|cx| {
-                ContactList::new(project.clone(), user_store.clone(), workspace.clone(), cx)
-            })),
-            project,
-            user_store,
-            workspace,
-            _subscription: None,
-        };
-        this.show_contact_list(String::new(), cx);
-        this
-    }
-
-    fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
-        match &self.child {
-            Child::ContactList(list) => self.show_contact_finder(list.read(cx).editor_text(cx), cx),
-            Child::ContactFinder(finder) => self.show_contact_list(finder.read(cx).query(cx), cx),
-        }
-    }
-
-    fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
-        let child = cx.add_view(|cx| {
-            let finder = build_contact_finder(self.user_store.clone(), cx);
-            finder.set_query(editor_text, cx);
-            finder
-        });
-        cx.focus(&child);
-        self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
-            PickerEvent::Dismiss => cx.emit(Event::Dismissed),
-        }));
-        self.child = Child::ContactFinder(child);
-        cx.notify();
-    }
-
-    fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
-        let child = cx.add_view(|cx| {
-            ContactList::new(
-                self.project.clone(),
-                self.user_store.clone(),
-                self.workspace.clone(),
-                cx,
-            )
-            .with_editor_text(editor_text, cx)
-        });
-        cx.focus(&child);
-        self._subscription = Some(cx.subscribe(&child, |this, _, event, cx| match event {
-            crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
-            crate::contact_list::Event::ToggleContactFinder => {
-                this.toggle_contact_finder(&Default::default(), cx)
-            }
-        }));
-        self.child = Child::ContactList(child);
-        cx.notify();
-    }
-}
-
-impl Entity for ContactsPopover {
-    type Event = Event;
-}
-
-impl View for ContactsPopover {
-    fn ui_name() -> &'static str {
-        "ContactsPopover"
-    }
-
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = theme::current(cx).clone();
-        let child = match &self.child {
-            Child::ContactList(child) => ChildView::new(child, cx),
-            Child::ContactFinder(child) => ChildView::new(child, cx),
-        };
-
-        MouseEventHandler::new::<ContactsPopover, _>(0, cx, |_, _| {
-            Flex::column()
-                .with_child(child.flex(1., true))
-                .contained()
-                .with_style(theme.contacts_popover.container)
-                .constrained()
-                .with_width(theme.contacts_popover.width)
-                .with_height(theme.contacts_popover.height)
-        })
-        .on_down_out(MouseButton::Left, move |_, _, cx| cx.emit(Event::Dismissed))
-        .into_any()
-    }
-
-    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if cx.is_self_focused() {
-            match &self.child {
-                Child::ContactList(child) => cx.focus(child),
-                Child::ContactFinder(child) => cx.focus(child),
-            }
-        }
-    }
-}

crates/collab_ui/src/face_pile.rs 🔗

@@ -7,44 +7,48 @@ use gpui::{
     },
     json::ToJson,
     serde_json::{self, json},
-    AnyElement, Axis, Element, LayoutContext, PaintContext, SceneBuilder, ViewContext,
+    AnyElement, Axis, Element, LayoutContext, PaintContext, SceneBuilder, View, ViewContext,
 };
 
-use crate::CollabTitlebarItem;
-
-pub(crate) struct FacePile {
+pub(crate) struct FacePile<V: View> {
     overlap: f32,
-    faces: Vec<AnyElement<CollabTitlebarItem>>,
+    faces: Vec<AnyElement<V>>,
 }
 
-impl FacePile {
-    pub fn new(overlap: f32) -> FacePile {
-        FacePile {
+impl<V: View> FacePile<V> {
+    pub fn new(overlap: f32) -> Self {
+        Self {
             overlap,
             faces: Vec::new(),
         }
     }
 }
 
-impl Element<CollabTitlebarItem> for FacePile {
+impl<V: View> Element<V> for FacePile<V> {
     type LayoutState = ();
     type PaintState = ();
 
     fn layout(
         &mut self,
         constraint: gpui::SizeConstraint,
-        view: &mut CollabTitlebarItem,
-        cx: &mut LayoutContext<CollabTitlebarItem>,
+        view: &mut V,
+        cx: &mut LayoutContext<V>,
     ) -> (Vector2F, Self::LayoutState) {
         debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
 
         let mut width = 0.;
+        let mut max_height = 0.;
         for face in &mut self.faces {
-            width += face.layout(constraint, view, cx).x();
+            let layout = face.layout(constraint, view, cx);
+            width += layout.x();
+            max_height = f32::max(max_height, layout.y());
         }
         width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
 
-        (Vector2F::new(width, constraint.max.y()), ())
+        (
+            Vector2F::new(width, max_height.clamp(1., constraint.max.y())),
+            (),
+        )
     }
 
     fn paint(
@@ -53,8 +57,8 @@ impl Element<CollabTitlebarItem> for FacePile {
         bounds: RectF,
         visible_bounds: RectF,
         _layout: &mut Self::LayoutState,
-        view: &mut CollabTitlebarItem,
-        cx: &mut PaintContext<CollabTitlebarItem>,
+        view: &mut V,
+        cx: &mut PaintContext<V>,
     ) -> Self::PaintState {
         let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
 
@@ -64,6 +68,7 @@ impl Element<CollabTitlebarItem> for FacePile {
         for face in self.faces.iter_mut().rev() {
             let size = face.size();
             origin_x -= size.x();
+            let origin_y = origin_y + (bounds.height() - size.y()) / 2.0;
             scene.paint_layer(None, |scene| {
                 face.paint(scene, vec2f(origin_x, origin_y), visible_bounds, view, cx);
             });
@@ -80,8 +85,8 @@ impl Element<CollabTitlebarItem> for FacePile {
         _: RectF,
         _: &Self::LayoutState,
         _: &Self::PaintState,
-        _: &CollabTitlebarItem,
-        _: &ViewContext<CollabTitlebarItem>,
+        _: &V,
+        _: &ViewContext<V>,
     ) -> Option<RectF> {
         None
     }
@@ -91,8 +96,8 @@ impl Element<CollabTitlebarItem> for FacePile {
         bounds: RectF,
         _: &Self::LayoutState,
         _: &Self::PaintState,
-        _: &CollabTitlebarItem,
-        _: &ViewContext<CollabTitlebarItem>,
+        _: &V,
+        _: &ViewContext<V>,
     ) -> serde_json::Value {
         json!({
             "type": "FacePile",
@@ -101,8 +106,8 @@ impl Element<CollabTitlebarItem> for FacePile {
     }
 }
 
-impl Extend<AnyElement<CollabTitlebarItem>> for FacePile {
-    fn extend<T: IntoIterator<Item = AnyElement<CollabTitlebarItem>>>(&mut self, children: T) {
+impl<V: View> Extend<AnyElement<V>> for FacePile<V> {
+    fn extend<T: IntoIterator<Item = AnyElement<V>>>(&mut self, children: T) {
         self.faces.extend(children);
     }
 }

crates/diagnostics/src/items.rs 🔗

@@ -105,7 +105,7 @@ impl View for DiagnosticIndicator {
                 let mut summary_row = Flex::row();
                 if self.summary.error_count > 0 {
                     summary_row.add_child(
-                        Svg::new("icons/circle_x_mark_16.svg")
+                        Svg::new("icons/error.svg")
                             .with_color(style.icon_color_error)
                             .constrained()
                             .with_width(style.icon_width)
@@ -121,7 +121,7 @@ impl View for DiagnosticIndicator {
 
                 if self.summary.warning_count > 0 {
                     summary_row.add_child(
-                        Svg::new("icons/triangle_exclamation_16.svg")
+                        Svg::new("icons/warning.svg")
                             .with_color(style.icon_color_warning)
                             .constrained()
                             .with_width(style.icon_width)
@@ -142,7 +142,7 @@ impl View for DiagnosticIndicator {
 
                 if self.summary.error_count == 0 && self.summary.warning_count == 0 {
                     summary_row.add_child(
-                        Svg::new("icons/circle_check_16.svg")
+                        Svg::new("icons/check_circle.svg")
                             .with_color(style.icon_color_ok)
                             .constrained()
                             .with_width(style.icon_width)

crates/feedback/src/deploy_feedback_button.rs 🔗

@@ -44,7 +44,7 @@ impl View for DeployFeedbackButton {
                         .in_state(active)
                         .style_for(state);
 
-                    Svg::new("icons/feedback_16.svg")
+                    Svg::new("icons/feedback.svg")
                         .with_color(style.icon_color)
                         .constrained()
                         .with_width(style.icon_size)

crates/gpui/src/elements.rs 🔗

@@ -48,6 +48,10 @@ pub trait Element<V: View>: 'static {
     type LayoutState;
     type PaintState;
 
+    fn view_name(&self) -> &'static str {
+        V::ui_name()
+    }
+
     fn layout(
         &mut self,
         constraint: SizeConstraint,
@@ -272,8 +276,16 @@ impl<V: View, E: Element<V>> AnyElementState<V> for ElementState<V, E> {
             | ElementState::PostLayout { mut element, .. }
             | ElementState::PostPaint { mut element, .. } => {
                 let (size, layout) = element.layout(constraint, view, cx);
-                debug_assert!(size.x().is_finite());
-                debug_assert!(size.y().is_finite());
+                debug_assert!(
+                    size.x().is_finite(),
+                    "Element for {:?} had infinite x size after layout",
+                    element.view_name()
+                );
+                debug_assert!(
+                    size.y().is_finite(),
+                    "Element for {:?} had infinite y size after layout",
+                    element.view_name()
+                );
 
                 result = size;
                 ElementState::PostLayout {

crates/menu/src/menu.rs 🔗

@@ -7,6 +7,7 @@ gpui::actions!(
         SelectPrev,
         SelectNext,
         SelectFirst,
-        SelectLast
+        SelectLast,
+        ShowContextMenu
     ]
 );

crates/picker/src/picker.rs 🔗

@@ -13,6 +13,7 @@ use std::{cmp, sync::Arc};
 use util::ResultExt;
 use workspace::Modal;
 
+#[derive(Clone, Copy)]
 pub enum PickerEvent {
     Dismiss,
 }

crates/project_panel/src/project_panel.rs 🔗

@@ -1657,24 +1657,8 @@ impl workspace::dock::Panel for ProjectPanel {
         cx.notify();
     }
 
-    fn should_zoom_in_on_event(_: &Self::Event) -> bool {
-        false
-    }
-
-    fn should_zoom_out_on_event(_: &Self::Event) -> bool {
-        false
-    }
-
-    fn is_zoomed(&self, _: &WindowContext) -> bool {
-        false
-    }
-
-    fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
-
-    fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
-
-    fn icon_path(&self) -> &'static str {
-        "icons/folder_tree_16.svg"
+    fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
+        Some("icons/project.svg")
     }
 
     fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
@@ -1685,14 +1669,6 @@ impl workspace::dock::Panel for ProjectPanel {
         matches!(event, Event::DockPositionChanged)
     }
 
-    fn should_activate_on_event(_: &Self::Event) -> bool {
-        false
-    }
-
-    fn should_close_on_event(_: &Self::Event) -> bool {
-        false
-    }
-
     fn has_focus(&self, _: &WindowContext) -> bool {
         self.has_focus
     }

crates/rpc/proto/zed.proto 🔗

@@ -102,17 +102,6 @@ message Envelope {
         SearchProject search_project = 80;
         SearchProjectResponse search_project_response = 81;
 
-        GetChannels get_channels = 82;
-        GetChannelsResponse get_channels_response = 83;
-        JoinChannel join_channel = 84;
-        JoinChannelResponse join_channel_response = 85;
-        LeaveChannel leave_channel = 86;
-        SendChannelMessage send_channel_message = 87;
-        SendChannelMessageResponse send_channel_message_response = 88;
-        ChannelMessageSent channel_message_sent = 89;
-        GetChannelMessages get_channel_messages = 90;
-        GetChannelMessagesResponse get_channel_messages_response = 91;
-
         UpdateContacts update_contacts = 92;
         UpdateInviteInfo update_invite_info = 93;
         ShowContacts show_contacts = 94;
@@ -140,6 +129,19 @@ message Envelope {
         InlayHints inlay_hints = 116;
         InlayHintsResponse inlay_hints_response = 117;
         RefreshInlayHints refresh_inlay_hints = 118;
+
+        CreateChannel create_channel = 119;
+        ChannelResponse channel_response = 120;
+        InviteChannelMember invite_channel_member = 121;
+        RemoveChannelMember remove_channel_member = 122;
+        RespondToChannelInvite respond_to_channel_invite = 123;
+        UpdateChannels update_channels = 124;
+        JoinChannel join_channel = 125;
+        RemoveChannel remove_channel = 126;
+        GetChannelMembers get_channel_members = 127;
+        GetChannelMembersResponse get_channel_members_response = 128;
+        SetChannelMemberAdmin set_channel_member_admin = 129;
+        RenameChannel rename_channel = 130;
     }
 }
 
@@ -174,7 +176,8 @@ message JoinRoom {
 
 message JoinRoomResponse {
     Room room = 1;
-    optional LiveKitConnectionInfo live_kit_connection_info = 2;
+    optional uint64 channel_id = 2;
+    optional LiveKitConnectionInfo live_kit_connection_info = 3;
 }
 
 message RejoinRoom {
@@ -867,23 +870,87 @@ message LspDiskBasedDiagnosticsUpdating {}
 
 message LspDiskBasedDiagnosticsUpdated {}
 
-message GetChannels {}
-
-message GetChannelsResponse {
+message UpdateChannels {
     repeated Channel channels = 1;
+    repeated uint64 remove_channels = 2;
+    repeated Channel channel_invitations = 3;
+    repeated uint64 remove_channel_invitations = 4;
+    repeated ChannelParticipants channel_participants = 5;
+    repeated ChannelPermission channel_permissions = 6;
+}
+
+message ChannelPermission {
+    uint64 channel_id = 1;
+    bool is_admin = 2;
+}
+
+message ChannelParticipants {
+    uint64 channel_id = 1;
+    repeated uint64 participant_user_ids = 2;
 }
 
 message JoinChannel {
     uint64 channel_id = 1;
 }
 
-message JoinChannelResponse {
-    repeated ChannelMessage messages = 1;
-    bool done = 2;
+message RemoveChannel {
+    uint64 channel_id = 1;
+}
+
+message GetChannelMembers {
+    uint64 channel_id = 1;
+}
+
+message GetChannelMembersResponse {
+    repeated ChannelMember members = 1;
+}
+
+message ChannelMember {
+    uint64 user_id = 1;
+    bool admin = 2;
+    Kind kind = 3;
+
+    enum Kind {
+        Member = 0;
+        Invitee = 1;
+        AncestorMember = 2;
+    }
+}
+
+message CreateChannel {
+    string name = 1;
+    optional uint64 parent_id = 2;
 }
 
-message LeaveChannel {
+message ChannelResponse {
+    Channel channel = 1;
+}
+
+message InviteChannelMember {
     uint64 channel_id = 1;
+    uint64 user_id = 2;
+    bool admin = 3;
+}
+
+message RemoveChannelMember {
+    uint64 channel_id = 1;
+    uint64 user_id = 2;
+}
+
+message SetChannelMemberAdmin {
+    uint64 channel_id = 1;
+    uint64 user_id = 2;
+    bool admin = 3;
+}
+
+message RenameChannel {
+    uint64 channel_id = 1;
+    string name = 2;
+}
+
+message RespondToChannelInvite {
+    uint64 channel_id = 1;
+    bool accept = 2;
 }
 
 message GetUsers {
@@ -918,31 +985,6 @@ enum ContactRequestResponse {
     Dismiss = 3;
 }
 
-message SendChannelMessage {
-    uint64 channel_id = 1;
-    string body = 2;
-    Nonce nonce = 3;
-}
-
-message SendChannelMessageResponse {
-    ChannelMessage message = 1;
-}
-
-message ChannelMessageSent {
-    uint64 channel_id = 1;
-    ChannelMessage message = 2;
-}
-
-message GetChannelMessages {
-    uint64 channel_id = 1;
-    uint64 before_message_id = 2;
-}
-
-message GetChannelMessagesResponse {
-    repeated ChannelMessage messages = 1;
-    bool done = 2;
-}
-
 message UpdateContacts {
     repeated Contact contacts = 1;
     repeated uint64 remove_contacts = 2;
@@ -1274,14 +1316,7 @@ message Nonce {
 message Channel {
     uint64 id = 1;
     string name = 2;
-}
-
-message ChannelMessage {
-    uint64 id = 1;
-    string body = 2;
-    uint64 timestamp = 3;
-    uint64 sender_id = 4;
-    Nonce nonce = 5;
+    optional uint64 parent_id = 3;
 }
 
 message Contact {

crates/rpc/src/proto.rs 🔗

@@ -1,3 +1,5 @@
+#![allow(non_snake_case)]
+
 use super::{entity_messages, messages, request_messages, ConnectionId, TypedEnvelope};
 use anyhow::{anyhow, Result};
 use async_tungstenite::tungstenite::Message as WebSocketMessage;
@@ -141,9 +143,10 @@ messages!(
     (Call, Foreground),
     (CallCanceled, Foreground),
     (CancelCall, Foreground),
-    (ChannelMessageSent, Foreground),
     (CopyProjectEntry, Foreground),
     (CreateBufferForPeer, Foreground),
+    (CreateChannel, Foreground),
+    (ChannelResponse, Foreground),
     (CreateProjectEntry, Foreground),
     (CreateRoom, Foreground),
     (CreateRoomResponse, Foreground),
@@ -156,10 +159,6 @@ messages!(
     (FormatBuffers, Foreground),
     (FormatBuffersResponse, Foreground),
     (FuzzySearchUsers, Foreground),
-    (GetChannelMessages, Foreground),
-    (GetChannelMessagesResponse, Foreground),
-    (GetChannels, Foreground),
-    (GetChannelsResponse, Foreground),
     (GetCodeActions, Background),
     (GetCodeActionsResponse, Background),
     (GetHover, Background),
@@ -179,14 +178,12 @@ messages!(
     (GetUsers, Foreground),
     (Hello, Foreground),
     (IncomingCall, Foreground),
+    (InviteChannelMember, Foreground),
     (UsersResponse, Foreground),
-    (JoinChannel, Foreground),
-    (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
     (JoinProjectResponse, Foreground),
     (JoinRoom, Foreground),
     (JoinRoomResponse, Foreground),
-    (LeaveChannel, Foreground),
     (LeaveProject, Foreground),
     (LeaveRoom, Foreground),
     (OpenBufferById, Background),
@@ -209,18 +206,21 @@ messages!(
     (RejoinRoom, Foreground),
     (RejoinRoomResponse, Foreground),
     (RemoveContact, Foreground),
+    (RemoveChannelMember, Foreground),
     (ReloadBuffers, Foreground),
     (ReloadBuffersResponse, Foreground),
     (RemoveProjectCollaborator, Foreground),
     (RenameProjectEntry, Foreground),
     (RequestContact, Foreground),
     (RespondToContactRequest, Foreground),
+    (RespondToChannelInvite, Foreground),
+    (JoinChannel, Foreground),
     (RoomUpdated, Foreground),
     (SaveBuffer, Foreground),
+    (RenameChannel, Foreground),
+    (SetChannelMemberAdmin, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
-    (SendChannelMessage, Foreground),
-    (SendChannelMessageResponse, Foreground),
     (ShareProject, Foreground),
     (ShareProjectResponse, Foreground),
     (ShowContacts, Foreground),
@@ -233,6 +233,8 @@ messages!(
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
+    (RemoveChannel, Foreground),
+    (UpdateChannels, Foreground),
     (UpdateDiagnosticSummary, Foreground),
     (UpdateFollowers, Foreground),
     (UpdateInviteInfo, Foreground),
@@ -245,6 +247,8 @@ messages!(
     (UpdateDiffBase, Foreground),
     (GetPrivateUserInfo, Foreground),
     (GetPrivateUserInfoResponse, Foreground),
+    (GetChannelMembers, Foreground),
+    (GetChannelMembersResponse, Foreground)
 );
 
 request_messages!(
@@ -258,13 +262,12 @@ request_messages!(
     (CopyProjectEntry, ProjectEntryResponse),
     (CreateProjectEntry, ProjectEntryResponse),
     (CreateRoom, CreateRoomResponse),
+    (CreateChannel, ChannelResponse),
     (DeclineCall, Ack),
     (DeleteProjectEntry, ProjectEntryResponse),
     (ExpandProjectEntry, ExpandProjectEntryResponse),
     (Follow, FollowResponse),
     (FormatBuffers, FormatBuffersResponse),
-    (GetChannelMessages, GetChannelMessagesResponse),
-    (GetChannels, GetChannelsResponse),
     (GetCodeActions, GetCodeActionsResponse),
     (GetHover, GetHoverResponse),
     (GetCompletions, GetCompletionsResponse),
@@ -276,7 +279,7 @@ request_messages!(
     (GetProjectSymbols, GetProjectSymbolsResponse),
     (FuzzySearchUsers, UsersResponse),
     (GetUsers, UsersResponse),
-    (JoinChannel, JoinChannelResponse),
+    (InviteChannelMember, Ack),
     (JoinProject, JoinProjectResponse),
     (JoinRoom, JoinRoomResponse),
     (LeaveRoom, Ack),
@@ -293,12 +296,18 @@ request_messages!(
     (RefreshInlayHints, Ack),
     (ReloadBuffers, ReloadBuffersResponse),
     (RequestContact, Ack),
+    (RemoveChannelMember, Ack),
     (RemoveContact, Ack),
     (RespondToContactRequest, Ack),
+    (RespondToChannelInvite, Ack),
+    (SetChannelMemberAdmin, Ack),
+    (GetChannelMembers, GetChannelMembersResponse),
+    (JoinChannel, JoinRoomResponse),
+    (RemoveChannel, Ack),
     (RenameProjectEntry, ProjectEntryResponse),
+    (RenameChannel, ChannelResponse),
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),
-    (SendChannelMessage, SendChannelMessageResponse),
     (ShareProject, ShareProjectResponse),
     (SynchronizeBuffers, SynchronizeBuffersResponse),
     (Test, Test),
@@ -361,8 +370,6 @@ entity_messages!(
     UpdateDiffBase
 );
 
-entity_messages!(channel_id, ChannelMessageSent);
-
 const KIB: usize = 1024;
 const MIB: usize = KIB * 1024;
 const MAX_BUFFER_LEN: usize = MIB;

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 59;
+pub const PROTOCOL_VERSION: u32 = 60;

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -393,8 +393,8 @@ impl Panel for TerminalPanel {
         }
     }
 
-    fn icon_path(&self) -> &'static str {
-        "icons/terminal_12.svg"
+    fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
+        Some("icons/terminal.svg")
     }
 
     fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {

crates/terminal_view/src/terminal_view.rs 🔗

@@ -673,7 +673,7 @@ impl Item for TerminalView {
 
         Flex::row()
             .with_child(
-                gpui::elements::Svg::new("icons/terminal_12.svg")
+                gpui::elements::Svg::new("icons/terminal.svg")
                     .with_color(tab_theme.label.text.color)
                     .constrained()
                     .with_width(tab_theme.type_icon_width)

crates/theme/src/theme.rs 🔗

@@ -43,11 +43,9 @@ pub struct Theme {
     pub meta: ThemeMeta,
     pub workspace: Workspace,
     pub context_menu: ContextMenu,
-    pub contacts_popover: ContactsPopover,
-    pub contact_list: ContactList,
     pub toolbar_dropdown_menu: DropdownMenu,
     pub copilot: Copilot,
-    pub contact_finder: ContactFinder,
+    pub collab_panel: CollabPanel,
     pub project_panel: ProjectPanel,
     pub command_palette: CommandPalette,
     pub picker: Picker,
@@ -117,6 +115,7 @@ pub struct Titlebar {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub height: f32,
+    pub menu: TitlebarMenu,
     pub project_menu_button: Toggleable<Interactive<ContainedText>>,
     pub project_name_divider: ContainedText,
     pub git_menu_button: Toggleable<Interactive<ContainedText>>,
@@ -143,6 +142,12 @@ pub struct Titlebar {
     pub user_menu: UserMenu,
 }
 
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct TitlebarMenu {
+    pub width: f32,
+    pub height: f32,
+}
+
 #[derive(Clone, Deserialize, Default, JsonSchema)]
 pub struct UserMenu {
     pub user_menu_button_online: UserMenuButton,
@@ -211,33 +216,69 @@ pub struct CopilotAuthAuthorized {
 }
 
 #[derive(Deserialize, Default, JsonSchema)]
-pub struct ContactsPopover {
+pub struct CollabPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub height: f32,
-    pub width: f32,
-}
-
-#[derive(Deserialize, Default, JsonSchema)]
-pub struct ContactList {
+    pub list_empty_state: Toggleable<Interactive<ContainedText>>,
+    pub list_empty_icon: Icon,
+    pub list_empty_label_container: ContainerStyle,
+    pub log_in_button: Interactive<ContainedText>,
+    pub channel_editor: ContainerStyle,
+    pub channel_hash: Icon,
+    pub tabbed_modal: TabbedModal,
+    pub contact_finder: ContactFinder,
+    pub channel_modal: ChannelModal,
     pub user_query_editor: FieldEditor,
     pub user_query_editor_height: f32,
-    pub add_contact_button: IconButton,
-    pub header_row: Toggleable<Interactive<ContainedText>>,
+    pub leave_call_button: Toggleable<Interactive<IconButton>>,
+    pub add_contact_button: Toggleable<Interactive<IconButton>>,
+    pub add_channel_button: Toggleable<Interactive<IconButton>>,
+    pub header_row: ContainedText,
+    pub subheader_row: Toggleable<Interactive<ContainedText>>,
     pub leave_call: Interactive<ContainedText>,
     pub contact_row: Toggleable<Interactive<ContainerStyle>>,
+    pub channel_row: Toggleable<Interactive<ContainerStyle>>,
+    pub channel_name: ContainedText,
     pub row_height: f32,
     pub project_row: Toggleable<Interactive<ProjectRow>>,
     pub tree_branch: Toggleable<Interactive<TreeBranch>>,
     pub contact_avatar: ImageStyle,
+    pub channel_avatar: ImageStyle,
+    pub extra_participant_label: ContainedText,
     pub contact_status_free: ContainerStyle,
     pub contact_status_busy: ContainerStyle,
     pub contact_username: ContainedText,
     pub contact_button: Interactive<IconButton>,
     pub contact_button_spacing: f32,
+    pub channel_indent: f32,
     pub disabled_button: IconButton,
     pub section_icon_size: f32,
     pub calling_indicator: ContainedText,
+    pub face_overlap: f32,
+}
+
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct TabbedModal {
+    pub tab_button: Toggleable<Interactive<ContainedText>>,
+    pub modal: ContainerStyle,
+    pub header: ContainerStyle,
+    pub body: ContainerStyle,
+    pub title: ContainedText,
+    pub picker: Picker,
+    pub max_height: f32,
+    pub max_width: f32,
+    pub row_height: f32,
+}
+
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct ChannelModal {
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainerStyle,
+    pub remove_member_button: ContainedText,
+    pub cancel_invite_button: ContainedText,
+    pub member_icon: IconButton,
+    pub invitee_icon: IconButton,
+    pub member_tag: ContainedText,
 }
 
 #[derive(Deserialize, Default, JsonSchema)]
@@ -256,8 +297,6 @@ pub struct TreeBranch {
 
 #[derive(Deserialize, Default, JsonSchema)]
 pub struct ContactFinder {
-    pub picker: Picker,
-    pub row_height: f32,
     pub contact_avatar: ImageStyle,
     pub contact_username: ContainerStyle,
     pub contact_button: IconButton,
@@ -860,6 +899,7 @@ impl<T> Toggleable<T> {
     pub fn active_state(&self) -> &T {
         self.in_state(true)
     }
+
     pub fn inactive_state(&self) -> &T {
         self.in_state(false)
     }
@@ -880,6 +920,16 @@ impl<T> Interactive<T> {
     }
 }
 
+impl<T> Toggleable<Interactive<T>> {
+    pub fn style_for(&self, active: bool, state: &mut MouseState) -> &T {
+        self.in_state(active).style_for(state)
+    }
+
+    pub fn default_style(&self) -> &T {
+        &self.inactive.default
+    }
+}
+
 impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
@@ -1045,6 +1095,12 @@ pub struct AssistantStyle {
     pub saved_conversation: SavedConversation,
 }
 
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct Contained<T> {
+    container: ContainerStyle,
+    contained: T,
+}
+
 #[derive(Clone, Deserialize, Default, JsonSchema)]
 pub struct SavedConversation {
     pub container: Interactive<ContainerStyle>,

crates/theme/src/ui.rs 🔗

@@ -107,6 +107,16 @@ pub struct IconStyle {
     pub container: ContainerStyle,
 }
 
+impl IconStyle {
+    pub fn width(&self) -> f32 {
+        self.icon.dimensions.width
+            + self.container.padding.left
+            + self.container.padding.right
+            + self.container.margin.left
+            + self.container.margin.right
+    }
+}
+
 pub fn icon<V: View>(style: &IconStyle) -> Container<V> {
     svg(&style.icon).contained().with_style(style.container)
 }

crates/vcs_menu/src/lib.rs 🔗

@@ -256,7 +256,7 @@ impl PickerDelegate for BranchListDelegate {
             .contained()
             .with_style(style.container)
             .constrained()
-            .with_height(theme.contact_finder.row_height)
+            .with_height(theme.collab_panel.tabbed_modal.row_height)
             .into_any()
     }
     fn render_header(

crates/workspace/src/dock.rs 🔗

@@ -14,19 +14,29 @@ pub trait Panel: View {
     fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>);
     fn size(&self, cx: &WindowContext) -> f32;
     fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>);
-    fn icon_path(&self) -> &'static str;
+    fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>;
     fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>);
     fn icon_label(&self, _: &WindowContext) -> Option<String> {
         None
     }
     fn should_change_position_on_event(_: &Self::Event) -> bool;
-    fn should_zoom_in_on_event(_: &Self::Event) -> bool;
-    fn should_zoom_out_on_event(_: &Self::Event) -> bool;
-    fn is_zoomed(&self, cx: &WindowContext) -> bool;
-    fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>);
-    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>);
-    fn should_activate_on_event(_: &Self::Event) -> bool;
-    fn should_close_on_event(_: &Self::Event) -> bool;
+    fn should_zoom_in_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn should_zoom_out_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn is_zoomed(&self, _cx: &WindowContext) -> bool {
+        false
+    }
+    fn set_zoomed(&mut self, _zoomed: bool, _cx: &mut ViewContext<Self>) {}
+    fn set_active(&mut self, _active: bool, _cx: &mut ViewContext<Self>) {}
+    fn should_activate_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn should_close_on_event(_: &Self::Event) -> bool {
+        false
+    }
     fn has_focus(&self, cx: &WindowContext) -> bool;
     fn is_focus_event(_: &Self::Event) -> bool;
 }
@@ -41,7 +51,7 @@ pub trait PanelHandle {
     fn set_active(&self, active: bool, cx: &mut WindowContext);
     fn size(&self, cx: &WindowContext) -> f32;
     fn set_size(&self, size: f32, cx: &mut WindowContext);
-    fn icon_path(&self, cx: &WindowContext) -> &'static str;
+    fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>;
     fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option<Box<dyn Action>>);
     fn icon_label(&self, cx: &WindowContext) -> Option<String>;
     fn has_focus(&self, cx: &WindowContext) -> bool;
@@ -88,8 +98,8 @@ where
         self.update(cx, |this, cx| this.set_active(active, cx))
     }
 
-    fn icon_path(&self, cx: &WindowContext) -> &'static str {
-        self.read(cx).icon_path()
+    fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> {
+        self.read(cx).icon_path(cx)
     }
 
     fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option<Box<dyn Action>>) {
@@ -480,8 +490,9 @@ impl View for PanelButtons {
             .map(|item| (item.panel.clone(), item.context_menu.clone()))
             .collect::<Vec<_>>();
         Flex::row()
-            .with_children(panels.into_iter().enumerate().map(
+            .with_children(panels.into_iter().enumerate().filter_map(
                 |(panel_ix, (view, context_menu))| {
+                    let icon_path = view.icon_path(cx)?;
                     let is_active = is_open && panel_ix == active_ix;
                     let (tooltip, tooltip_action) = if is_active {
                         (
@@ -495,92 +506,95 @@ impl View for PanelButtons {
                     } else {
                         view.icon_tooltip(cx)
                     };
-                    Stack::new()
-                        .with_child(
-                            MouseEventHandler::new::<Self, _>(panel_ix, cx, |state, cx| {
-                                let style = button_style.in_state(is_active);
-                                let style = style.style_for(state);
-                                Flex::row()
-                                    .with_child(
-                                        Svg::new(view.icon_path(cx))
-                                            .with_color(style.icon_color)
-                                            .constrained()
-                                            .with_width(style.icon_size)
-                                            .aligned(),
-                                    )
-                                    .with_children(if let Some(label) = view.icon_label(cx) {
-                                        Some(
-                                            Label::new(label, style.label.text.clone())
-                                                .contained()
-                                                .with_style(style.label.container)
+                    Some(
+                        Stack::new()
+                            .with_child(
+                                MouseEventHandler::new::<Self, _>(panel_ix, cx, |state, cx| {
+                                    let style = button_style.in_state(is_active);
+
+                                    let style = style.style_for(state);
+                                    Flex::row()
+                                        .with_child(
+                                            Svg::new(icon_path)
+                                                .with_color(style.icon_color)
+                                                .constrained()
+                                                .with_width(style.icon_size)
                                                 .aligned(),
                                         )
-                                    } else {
-                                        None
-                                    })
-                                    .constrained()
-                                    .with_height(style.icon_size)
-                                    .contained()
-                                    .with_style(style.container)
-                            })
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, {
-                                let tooltip_action =
-                                    tooltip_action.as_ref().map(|action| action.boxed_clone());
-                                move |_, this, cx| {
-                                    if let Some(tooltip_action) = &tooltip_action {
-                                        let window = cx.window();
-                                        let view_id = this.workspace.id();
-                                        let tooltip_action = tooltip_action.boxed_clone();
-                                        cx.spawn(|_, mut cx| async move {
-                                            window.dispatch_action(
-                                                view_id,
-                                                &*tooltip_action,
-                                                &mut cx,
-                                            );
+                                        .with_children(if let Some(label) = view.icon_label(cx) {
+                                            Some(
+                                                Label::new(label, style.label.text.clone())
+                                                    .contained()
+                                                    .with_style(style.label.container)
+                                                    .aligned(),
+                                            )
+                                        } else {
+                                            None
                                         })
-                                        .detach();
-                                    }
-                                }
-                            })
-                            .on_click(MouseButton::Right, {
-                                let view = view.clone();
-                                let menu = context_menu.clone();
-                                move |_, _, cx| {
-                                    const POSITIONS: [DockPosition; 3] = [
-                                        DockPosition::Left,
-                                        DockPosition::Right,
-                                        DockPosition::Bottom,
-                                    ];
-
-                                    menu.update(cx, |menu, cx| {
-                                        let items = POSITIONS
-                                            .into_iter()
-                                            .filter(|position| {
-                                                *position != dock_position
-                                                    && view.position_is_valid(*position, cx)
-                                            })
-                                            .map(|position| {
-                                                let view = view.clone();
-                                                ContextMenuItem::handler(
-                                                    format!("Dock {}", position.to_label()),
-                                                    move |cx| view.set_position(position, cx),
-                                                )
+                                        .constrained()
+                                        .with_height(style.icon_size)
+                                        .contained()
+                                        .with_style(style.container)
+                                })
+                                .with_cursor_style(CursorStyle::PointingHand)
+                                .on_click(MouseButton::Left, {
+                                    let tooltip_action =
+                                        tooltip_action.as_ref().map(|action| action.boxed_clone());
+                                    move |_, this, cx| {
+                                        if let Some(tooltip_action) = &tooltip_action {
+                                            let window = cx.window();
+                                            let view_id = this.workspace.id();
+                                            let tooltip_action = tooltip_action.boxed_clone();
+                                            cx.spawn(|_, mut cx| async move {
+                                                window.dispatch_action(
+                                                    view_id,
+                                                    &*tooltip_action,
+                                                    &mut cx,
+                                                );
                                             })
-                                            .collect();
-                                        menu.show(Default::default(), menu_corner, items, cx);
-                                    })
-                                }
-                            })
-                            .with_tooltip::<Self>(
-                                panel_ix,
-                                tooltip,
-                                tooltip_action,
-                                tooltip_style.clone(),
-                                cx,
-                            ),
-                        )
-                        .with_child(ChildView::new(&context_menu, cx))
+                                            .detach();
+                                        }
+                                    }
+                                })
+                                .on_click(MouseButton::Right, {
+                                    let view = view.clone();
+                                    let menu = context_menu.clone();
+                                    move |_, _, cx| {
+                                        const POSITIONS: [DockPosition; 3] = [
+                                            DockPosition::Left,
+                                            DockPosition::Right,
+                                            DockPosition::Bottom,
+                                        ];
+
+                                        menu.update(cx, |menu, cx| {
+                                            let items = POSITIONS
+                                                .into_iter()
+                                                .filter(|position| {
+                                                    *position != dock_position
+                                                        && view.position_is_valid(*position, cx)
+                                                })
+                                                .map(|position| {
+                                                    let view = view.clone();
+                                                    ContextMenuItem::handler(
+                                                        format!("Dock {}", position.to_label()),
+                                                        move |cx| view.set_position(position, cx),
+                                                    )
+                                                })
+                                                .collect();
+                                            menu.show(Default::default(), menu_corner, items, cx);
+                                        })
+                                    }
+                                })
+                                .with_tooltip::<Self>(
+                                    panel_ix,
+                                    tooltip,
+                                    tooltip_action,
+                                    tooltip_style.clone(),
+                                    cx,
+                                ),
+                            )
+                            .with_child(ChildView::new(&context_menu, cx)),
+                    )
                 },
             ))
             .contained()
@@ -690,8 +704,8 @@ pub mod test {
             self.size = size;
         }
 
-        fn icon_path(&self) -> &'static str {
-            "icons/test_panel.svg"
+        fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
+            Some("icons/test_panel.svg")
         }
 
         fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {

crates/workspace/src/workspace.rs 🔗

@@ -14,7 +14,7 @@ use anyhow::{anyhow, Context, Result};
 use call::ActiveCall;
 use client::{
     proto::{self, PeerId},
-    Client, TypedEnvelope, UserStore,
+    ChannelStore, Client, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
 use drag_and_drop::DragAndDrop;
@@ -400,8 +400,9 @@ pub fn register_deserializable_item<I: Item>(cx: &mut AppContext) {
 
 pub struct AppState {
     pub languages: Arc<LanguageRegistry>,
-    pub client: Arc<client::Client>,
-    pub user_store: ModelHandle<client::UserStore>,
+    pub client: Arc<Client>,
+    pub user_store: ModelHandle<UserStore>,
+    pub channel_store: ModelHandle<ChannelStore>,
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options:
         fn(Option<WindowBounds>, Option<uuid::Uuid>, &dyn Platform) -> WindowOptions<'static>,
@@ -424,6 +425,8 @@ impl AppState {
         let http_client = util::http::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone(), cx);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
+        let channel_store =
+            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
 
         theme::init((), cx);
         client::init(&client, cx);
@@ -434,6 +437,7 @@ impl AppState {
             fs,
             languages,
             user_store,
+            channel_store,
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             build_window_options: |_, _, _| Default::default(),
             background_actions: || &[],
@@ -3403,10 +3407,16 @@ impl Workspace {
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn test_new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+        let client = project.read(cx).client();
+        let user_store = project.read(cx).user_store();
+
+        let channel_store =
+            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let app_state = Arc::new(AppState {
             languages: project.read(cx).languages().clone(),
-            client: project.read(cx).client(),
-            user_store: project.read(cx).user_store(),
+            client,
+            user_store,
+            channel_store,
             fs: project.read(cx).fs().clone(),
             build_window_options: |_, _, _| Default::default(),
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
@@ -3750,11 +3760,19 @@ impl View for Workspace {
                                         )
                                     }))
                                     .with_children(self.modal.as_ref().map(|modal| {
-                                        ChildView::new(modal.view.as_any(), cx)
-                                            .contained()
-                                            .with_style(theme.workspace.modal)
-                                            .aligned()
-                                            .top()
+                                        // Prevent clicks within the modal from falling
+                                        // through to the rest of the workspace.
+                                        enum ModalBackground {}
+                                        MouseEventHandler::new::<ModalBackground, _>(
+                                            0,
+                                            cx,
+                                            |_, cx| ChildView::new(modal.view.as_any(), cx),
+                                        )
+                                        .on_click(MouseButton::Left, |_, _, _| {})
+                                        .contained()
+                                        .with_style(theme.workspace.modal)
+                                        .aligned()
+                                        .top()
                                     }))
                                     .with_children(self.render_notifications(&theme.workspace, cx)),
                             ))

crates/zed/src/main.rs 🔗

@@ -7,7 +7,9 @@ use cli::{
     ipc::{self, IpcSender},
     CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
 };
-use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{
+    self, ChannelStore, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
+};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{scroll::autoscroll::Autoscroll, Editor};
 use futures::{
@@ -140,6 +142,8 @@ fn main() {
 
         languages::init(languages.clone(), node_runtime.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
+        let channel_store =
+            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
 
         cx.set_global(client.clone());
 
@@ -181,6 +185,7 @@ fn main() {
             languages,
             client: client.clone(),
             user_store,
+            channel_store,
             fs,
             build_window_options,
             initialize_workspace,

crates/zed/src/zed.rs 🔗

@@ -10,7 +10,7 @@ use anyhow::Context;
 use assets::Assets;
 use breadcrumbs::Breadcrumbs;
 pub use client;
-use collab_ui::{CollabTitlebarItem, ToggleContactsMenu};
+use collab_ui::CollabTitlebarItem; // TODO: Add back toggle collab ui shortcut
 use collections::VecDeque;
 pub use editor;
 use editor::{Editor, MultiBuffer};
@@ -85,20 +85,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
             cx.toggle_full_screen();
         },
     );
-    cx.add_action(
-        |workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext<Workspace>| {
-            if let Some(item) = workspace
-                .titlebar_item()
-                .and_then(|item| item.downcast::<CollabTitlebarItem>())
-            {
-                cx.defer(move |_, cx| {
-                    item.update(cx, |item, cx| {
-                        item.toggle_contacts_popover(&Default::default(), cx);
-                    });
-                });
-            }
-        },
-    );
     cx.add_global_action(quit);
     cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
     cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
@@ -220,6 +206,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
             workspace.toggle_panel_focus::<ProjectPanel>(cx);
         },
     );
+    cx.add_action(
+        |workspace: &mut Workspace,
+         _: &collab_ui::collab_panel::ToggleFocus,
+         cx: &mut ViewContext<Workspace>| {
+            workspace.toggle_panel_focus::<collab_ui::collab_panel::CollabPanel>(cx);
+        },
+    );
     cx.add_action(
         |workspace: &mut Workspace,
          _: &terminal_panel::ToggleFocus,
@@ -338,9 +331,14 @@ pub fn initialize_workspace(
         let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
         let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
         let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone());
-        let (project_panel, terminal_panel, assistant_panel) =
-            futures::try_join!(project_panel, terminal_panel, assistant_panel)?;
-
+        let channels_panel =
+            collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
+        let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!(
+            project_panel,
+            terminal_panel,
+            assistant_panel,
+            channels_panel
+        )?;
         workspace_handle.update(&mut cx, |workspace, cx| {
             let project_panel_position = project_panel.position(cx);
             workspace.add_panel_with_extra_event_handler(
@@ -358,6 +356,7 @@ pub fn initialize_workspace(
             );
             workspace.add_panel(terminal_panel, cx);
             workspace.add_panel(assistant_panel, cx);
+            workspace.add_panel(channels_panel, cx);
 
             if !was_deserialized
                 && workspace
@@ -2382,6 +2381,7 @@ mod tests {
             language::init(cx);
             editor::init(cx);
             project_panel::init_settings(cx);
+            collab_ui::init(&app_state, cx);
             pane::init(cx);
             project_panel::init((), cx);
             terminal_view::init(cx);

script/start-local-collaboration 🔗

@@ -53,6 +53,6 @@ sleep 0.5
 
 # Start the two Zed child processes. Open the given paths with the first instance.
 trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
-ZED_IMPERSONATE=${username_1} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ &
+ZED_IMPERSONATE=${ZED_IMPERSONATE:=${username_1}} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ &
 SECOND=true ZED_IMPERSONATE=${username_2} ZED_WINDOW_POSITION=${position_2} target/debug/Zed &
 wait

script/zed-with-local-servers 🔗

@@ -1,3 +1,6 @@
 #!/bin/bash
 
-ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:3000 cargo run $@
+: "${ZED_IMPERSONATE:=as-cii}"
+export ZED_IMPERSONATE
+
+ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:8080 cargo run $@

styles/.eslintrc.js 🔗

@@ -28,6 +28,7 @@ module.exports = {
     },
     rules: {
         "linebreak-style": ["error", "unix"],
+        "@typescript-eslint/no-explicit-any": "off",
         semi: ["error", "never"],
     },
 }

styles/src/common.ts 🔗

@@ -1,5 +1,6 @@
 import chroma from "chroma-js"
 export * from "./theme"
+export * from "./theme/theme_config"
 export { chroma }
 
 export const font_families = {

styles/src/component/button.ts 🔗

@@ -0,0 +1,118 @@
+import { font_sizes, useTheme } from "../common"
+import { Layer, Theme } from "../theme"
+import { TextStyle, background } from "../style_tree/components"
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace Button {
+    export type Options = {
+        layer: Layer,
+        background: keyof Theme["lowest"]
+        color: keyof Theme["lowest"]
+        variant: Button.Variant
+        size: Button.Size
+        shape: Button.Shape
+        margin: {
+            top?: number
+            bottom?: number
+            left?: number
+            right?: number
+        },
+        states: {
+            enabled?: boolean,
+            hovered?: boolean,
+            pressed?: boolean,
+            focused?: boolean,
+            disabled?: boolean,
+        }
+    }
+
+    export type ToggleableOptions = Options & {
+        active_background: keyof Theme["lowest"]
+        active_color: keyof Theme["lowest"]
+    }
+
+    /** Padding added to each side of a Shape.Rectangle button */
+    export const RECTANGLE_PADDING = 2
+    export const FONT_SIZE = font_sizes.sm
+    export const ICON_SIZE = 14
+    export const CORNER_RADIUS = 6
+
+    export const variant = {
+        Default: 'filled',
+        Outline: 'outline',
+        Ghost: 'ghost'
+    } as const
+
+    export type Variant = typeof variant[keyof typeof variant]
+
+    export const shape = {
+        Rectangle: 'rectangle',
+        Square: 'square'
+    } as const
+
+    export type Shape = typeof shape[keyof typeof shape]
+
+    export const size = {
+        Small: "sm",
+        Medium: "md"
+    } as const
+
+    export type Size = typeof size[keyof typeof size]
+
+    export type BaseStyle = {
+        corder_radius: number
+        background: string | null
+        padding: {
+            top: number
+            bottom: number
+            left: number
+            right: number
+        },
+        margin: Button.Options['margin']
+        button_height: number
+    }
+
+    export type LabelButtonStyle = BaseStyle & TextStyle
+    // export type IconButtonStyle = ButtonStyle
+
+    export const button_base = (
+        options: Partial<Button.Options> = {
+            variant: Button.variant.Default,
+            shape: Button.shape.Rectangle,
+            states: {
+                hovered: true,
+                pressed: true
+            }
+        }
+    ): BaseStyle => {
+        const theme = useTheme()
+
+        const layer = options.layer ?? theme.middle
+        const color = options.color ?? "base"
+        const background_color = options.variant === Button.variant.Ghost ? null : background(layer, options.background ?? color)
+
+        const m = {
+            top: options.margin?.top ?? 0,
+            bottom: options.margin?.bottom ?? 0,
+            left: options.margin?.left ?? 0,
+            right: options.margin?.right ?? 0,
+        }
+        const size = options.size || Button.size.Medium
+        const padding = 2
+
+        const base: BaseStyle = {
+            background: background_color,
+            corder_radius: Button.CORNER_RADIUS,
+            padding: {
+                top: padding,
+                bottom: padding,
+                left: options.shape === Button.shape.Rectangle ? padding + Button.RECTANGLE_PADDING : padding,
+                right: options.shape === Button.shape.Rectangle ? padding + Button.RECTANGLE_PADDING : padding
+            },
+            margin: m,
+            button_height: 16,
+        }
+
+        return base
+    }
+}

styles/src/component/icon_button.ts 🔗

@@ -1,6 +1,7 @@
 import { interactive, toggleable } from "../element"
 import { background, foreground } from "../style_tree/components"
-import { useTheme, Theme } from "../theme"
+import { useTheme, Theme, Layer } from "../theme"
+import { Button } from "./button"
 
 export type Margin = {
     top: number
@@ -16,17 +17,25 @@ interface IconButtonOptions {
     | Theme["highest"]
     color?: keyof Theme["lowest"]
     margin?: Partial<Margin>
+    variant?: Button.Variant
+    size?: Button.Size
 }
 
 type ToggleableIconButtonOptions = IconButtonOptions & {
     active_color?: keyof Theme["lowest"]
+    active_layer?: Layer
 }
 
-export function icon_button({ color, margin, layer }: IconButtonOptions) {
+export function icon_button({ color, margin, layer, variant, size }: IconButtonOptions = {
+    variant: Button.variant.Default,
+    size: Button.size.Medium,
+}) {
     const theme = useTheme()
 
     if (!color) color = "base"
 
+    const background_color = variant === Button.variant.Ghost ? null : background(layer ?? theme.lowest, color)
+
     const m = {
         top: margin?.top ?? 0,
         bottom: margin?.bottom ?? 0,
@@ -34,15 +43,17 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) {
         right: margin?.right ?? 0,
     }
 
+    const padding = {
+        top: size === Button.size.Small ? 0 : 2,
+        bottom: size === Button.size.Small ? 0 : 2,
+        left: size === Button.size.Small ? 0 : 4,
+        right: size === Button.size.Small ? 0 : 4,
+    }
+
     return interactive({
         base: {
             corner_radius: 6,
-            padding: {
-                top: 2,
-                bottom: 2,
-                left: 4,
-                right: 4,
-            },
+            padding: padding,
             margin: m,
             icon_width: 14,
             icon_height: 14,
@@ -51,7 +62,7 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) {
         },
         state: {
             default: {
-                background: background(layer ?? theme.lowest, color),
+                background: background_color,
                 color: foreground(layer ?? theme.lowest, color),
             },
             hovered: {
@@ -68,17 +79,18 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) {
 
 export function toggleable_icon_button(
     theme: Theme,
-    { color, active_color, margin }: ToggleableIconButtonOptions
+    { color, active_color, margin, variant, size, active_layer }: ToggleableIconButtonOptions
 ) {
     if (!color) color = "base"
 
     return toggleable({
         state: {
-            inactive: icon_button({ color, margin }),
+            inactive: icon_button({ color, margin, variant, size }),
             active: icon_button({
                 color: active_color ? active_color : color,
                 margin,
-                layer: theme.middle,
+                layer: active_layer,
+                size
             }),
         },
     })

styles/src/component/indicator.ts 🔗

@@ -0,0 +1,9 @@
+import { background } from "../style_tree/components"
+import { Layer, StyleSets } from "../theme"
+
+export const indicator = ({ layer, color }: { layer: Layer, color: StyleSets }) => ({
+    corner_radius: 4,
+    padding: 4,
+    margin: { top: 12, left: 12 },
+    background: background(layer, color),
+})

styles/src/component/input.ts 🔗

@@ -0,0 +1,23 @@
+import { useTheme } from "../common"
+import { background, border, text } from "../style_tree/components"
+
+export const input = () => {
+    const theme = useTheme()
+
+    return {
+        background: background(theme.highest),
+        corner_radius: 8,
+        min_width: 200,
+        max_width: 500,
+        placeholder_text: text(theme.highest, "mono", "disabled"),
+        selection: theme.players[0],
+        text: text(theme.highest, "mono", "default"),
+        border: border(theme.highest),
+        padding: {
+            top: 3,
+            bottom: 3,
+            left: 12,
+            right: 8,
+        }
+    }
+}

styles/src/component/label_button.ts 🔗

@@ -0,0 +1,78 @@
+import { Interactive, interactive, toggleable, Toggleable } from "../element"
+import { TextStyle, background, text } from "../style_tree/components"
+import { useTheme } from "../theme"
+import { Button } from "./button"
+
+type LabelButtonStyle = {
+    corder_radius: number
+    background: string | null
+    padding: {
+        top: number
+        bottom: number
+        left: number
+        right: number
+    },
+    margin: Button.Options['margin']
+    button_height: number
+} & TextStyle
+
+/** Styles an Interactive&lt;ContainedText> */
+export function label_button_style(
+    options: Partial<Button.Options> = {
+        variant: Button.variant.Default,
+        shape: Button.shape.Rectangle,
+        states: {
+            hovered: true,
+            pressed: true
+        }
+    }
+): Interactive<LabelButtonStyle> {
+    const theme = useTheme()
+
+    const base = Button.button_base(options)
+    const layer = options.layer ?? theme.middle
+    const color = options.color ?? "base"
+
+    const default_state = {
+        ...base,
+        ...text(layer ?? theme.lowest, "sans", color),
+        font_size: Button.FONT_SIZE,
+    }
+
+    return interactive({
+        base: default_state,
+        state: {
+            hovered: {
+                background: background(layer, options.background ?? color, "hovered")
+            },
+            clicked: {
+                background: background(layer, options.background ?? color, "pressed")
+            }
+        }
+    })
+}
+
+/** Styles an Toggleable&lt;Interactive&lt;ContainedText>> */
+export function toggle_label_button_style(
+    options: Partial<Button.ToggleableOptions> = {
+        variant: Button.variant.Default,
+        shape: Button.shape.Rectangle,
+        states: {
+            hovered: true,
+            pressed: true
+        }
+    }
+): Toggleable<Interactive<LabelButtonStyle>> {
+    const activeOptions = {
+        ...options,
+        color: options.active_color || options.color,
+        background: options.active_background || options.background
+    }
+
+    return toggleable({
+        state: {
+            inactive: label_button_style(options),
+            active: label_button_style(activeOptions),
+        },
+    })
+}

styles/src/component/tab.ts 🔗

@@ -0,0 +1,73 @@
+import { Layer } from "../common"
+import { interactive, toggleable } from "../element"
+import { Border, text } from "../style_tree/components"
+
+type TabProps = {
+    layer: Layer
+}
+
+export const tab = ({ layer }: TabProps) => {
+    const active_color = text(layer, "sans", "base").color
+    const inactive_border: Border = {
+        color: '#FFFFFF00',
+        width: 1,
+        bottom: true,
+        left: false,
+        right: false,
+        top: false,
+    }
+    const active_border: Border = {
+        ...inactive_border,
+        color: active_color,
+    }
+
+    const base = {
+        ...text(layer, "sans", "variant"),
+        padding: {
+            top: 8,
+            left: 8,
+            right: 8,
+            bottom: 6
+        },
+        border: inactive_border,
+    }
+
+    const i = interactive({
+        state: {
+            default: {
+                ...base
+            },
+            hovered: {
+                ...base,
+                ...text(layer, "sans", "base", "hovered")
+            },
+            clicked: {
+                ...base,
+                ...text(layer, "sans", "base", "pressed")
+            },
+        }
+    })
+
+    return toggleable({
+        base: i,
+        state: {
+            active: {
+                default: {
+                    ...i,
+                    ...text(layer, "sans", "base"),
+                    border: active_border,
+                },
+                hovered: {
+                    ...i,
+                    ...text(layer, "sans", "base", "hovered"),
+                    border: active_border
+                },
+                clicked: {
+                    ...i,
+                    ...text(layer, "sans", "base", "pressed"),
+                    border: active_border
+                },
+            }
+        }
+    })
+}

styles/src/component/text_button.ts 🔗

@@ -6,6 +6,7 @@ import {
     text,
 } from "../style_tree/components"
 import { useTheme, Theme } from "../theme"
+import { Button } from "./button"
 import { Margin } from "./icon_button"
 
 interface TextButtonOptions {
@@ -13,6 +14,7 @@ interface TextButtonOptions {
     | Theme["lowest"]
     | Theme["middle"]
     | Theme["highest"]
+    variant?: Button.Variant
     color?: keyof Theme["lowest"]
     margin?: Partial<Margin>
     text_properties?: TextProperties
@@ -23,14 +25,17 @@ type ToggleableTextButtonOptions = TextButtonOptions & {
 }
 
 export function text_button({
+    variant = Button.variant.Default,
     color,
     layer,
     margin,
     text_properties,
-}: TextButtonOptions) {
+}: TextButtonOptions = {}) {
     const theme = useTheme()
     if (!color) color = "base"
 
+    const background_color = variant === Button.variant.Ghost ? null : background(layer ?? theme.lowest, color)
+
     const text_options: TextProperties = {
         size: "xs",
         weight: "normal",
@@ -59,7 +64,7 @@ export function text_button({
         },
         state: {
             default: {
-                background: background(layer ?? theme.lowest, color),
+                background: background_color,
                 color: foreground(layer ?? theme.lowest, color),
             },
             hovered: {
@@ -76,14 +81,15 @@ export function text_button({
 
 export function toggleable_text_button(
     theme: Theme,
-    { color, active_color, margin }: ToggleableTextButtonOptions
+    { variant, color, active_color, margin }: ToggleableTextButtonOptions = {}
 ) {
     if (!color) color = "base"
 
     return toggleable({
         state: {
-            inactive: text_button({ color, margin }),
+            inactive: text_button({ variant, color, margin }),
             active: text_button({
+                variant,
                 color: active_color ? active_color : color,
                 margin,
                 layer: theme.middle,

styles/src/element/index.ts 🔗

@@ -1,4 +1,4 @@
 import { interactive, Interactive } from "./interactive"
-import { toggleable } from "./toggle"
+import { toggleable, Toggleable } from "./toggle"
 
-export { interactive, Interactive, toggleable }
+export { interactive, Interactive, toggleable, Toggleable }

styles/src/element/toggle.ts 🔗

@@ -3,7 +3,7 @@ import { DeepPartial } from "utility-types"
 
 type ToggleState = "inactive" | "active"
 
-type Toggleable<T> = Record<ToggleState, T>
+export type Toggleable<T> = Record<ToggleState, T>
 
 export const NO_INACTIVE_OR_BASE_ERROR =
     "A toggleable object must have an inactive state, or a base property."

styles/src/style_tree/app.ts 🔗

@@ -1,5 +1,3 @@
-import contact_finder from "./contact_finder"
-import contacts_popover from "./contacts_popover"
 import command_palette from "./command_palette"
 import project_panel from "./project_panel"
 import search from "./search"
@@ -14,7 +12,8 @@ import simple_message_notification from "./simple_message_notification"
 import project_shared_notification from "./project_shared_notification"
 import tooltip from "./tooltip"
 import terminal from "./terminal"
-import contact_list from "./contact_list"
+import contact_finder from "./contact_finder"
+import collab_panel from "./collab_panel"
 import toolbar_dropdown_menu from "./toolbar_dropdown_menu"
 import incoming_call_notification from "./incoming_call_notification"
 import welcome from "./welcome"
@@ -46,9 +45,7 @@ export default function app(): any {
         editor: editor(),
         project_diagnostics: project_diagnostics(),
         project_panel: project_panel(),
-        contacts_popover: contacts_popover(),
-        contact_finder: contact_finder(),
-        contact_list: contact_list(),
+        collab_panel: collab_panel(),
         toolbar_dropdown_menu: toolbar_dropdown_menu(),
         search: search(),
         shared_screen: shared_screen(),

styles/src/style_tree/collab_modals.ts 🔗

@@ -0,0 +1,151 @@
+import { useTheme } from "../theme"
+import { background, border, foreground, text } from "./components"
+import picker from "./picker"
+import { input } from "../component/input"
+import contact_finder from "./contact_finder"
+import { tab } from "../component/tab"
+import { icon_button } from "../component/icon_button"
+
+export default function channel_modal(): any {
+    const theme = useTheme()
+
+    const SPACING = 12 as const
+    const BUTTON_OFFSET = 6 as const
+    const ITEM_HEIGHT = 36 as const
+
+    const contact_button = {
+        background: background(theme.middle, "variant"),
+        color: foreground(theme.middle, "variant"),
+        icon_width: 8,
+        button_width: 16,
+        corner_radius: 8,
+    }
+
+    const picker_style = picker()
+    delete picker_style.shadow
+    delete picker_style.border
+
+    const picker_input = input()
+
+    const member_icon_style = icon_button({
+        variant: "ghost",
+        size: "sm",
+    }).default
+
+    return {
+        contact_finder: contact_finder(),
+        tabbed_modal: {
+            tab_button: tab({ layer: theme.middle }),
+            row_height: ITEM_HEIGHT,
+            header: {
+                background: background(theme.lowest),
+                border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }),
+                padding: {
+                    top: SPACING,
+                    left: SPACING - BUTTON_OFFSET,
+                    right: SPACING - BUTTON_OFFSET,
+                },
+                corner_radii: {
+                    top_right: 12,
+                    top_left: 12,
+                }
+            },
+            body: {
+                background: background(theme.middle),
+                padding: {
+                    top: SPACING - 4,
+                    left: SPACING,
+                    right: SPACING,
+                    bottom: SPACING,
+
+                },
+                corner_radii: {
+                    bottom_right: 12,
+                    bottom_left: 12,
+                }
+            },
+            modal: {
+                background: background(theme.middle),
+                shadow: theme.modal_shadow,
+                corner_radius: 12,
+                padding: {
+                    bottom: 0,
+                    left: 0,
+                    right: 0,
+                    top: 0,
+                },
+
+            },
+            max_height: 400,
+            max_width: 540,
+            title: {
+                ...text(theme.middle, "sans", "on", { size: "lg" }),
+                padding: {
+                    left: BUTTON_OFFSET,
+                }
+            },
+            picker: {
+                empty_container: {},
+                item: {
+                    ...picker_style.item,
+                    margin: { left: SPACING, right: SPACING },
+                },
+                no_matches: picker_style.no_matches,
+                input_editor: picker_input,
+                empty_input_editor: picker_input,
+                header: picker_style.header,
+                footer: picker_style.footer,
+            },
+        },
+        channel_modal: {
+            // This is used for the icons that are rendered to the right of channel Members in both UIs
+            member_icon: member_icon_style,
+            // This is used for the icons that are rendered to the right of channel invites in both UIs
+            invitee_icon: member_icon_style,
+            remove_member_button: {
+                ...text(theme.middle, "sans", { size: "xs" }),
+                background: background(theme.middle),
+                padding: {
+                    left: 7,
+                    right: 7
+                }
+            },
+            cancel_invite_button: {
+                ...text(theme.middle, "sans", { size: "xs" }),
+                background: background(theme.middle),
+            },
+            member_tag: {
+                ...text(theme.middle, "sans", { size: "xs" }),
+                border: border(theme.middle, "active"),
+                background: background(theme.middle),
+                margin: {
+                    left: 8,
+                },
+                padding: {
+                    left: 4,
+                    right: 4,
+                }
+            },
+            contact_avatar: {
+                corner_radius: 10,
+                width: 18,
+            },
+            contact_username: {
+                padding: {
+                    left: 8,
+                },
+            },
+            contact_button: {
+                ...contact_button,
+                hover: {
+                    background: background(theme.middle, "variant", "hovered"),
+                },
+            },
+            disabled_contact_button: {
+                ...contact_button,
+                background: background(theme.middle, "disabled"),
+                color: foreground(theme.middle, "disabled"),
+            },
+        }
+    }
+}

styles/src/style_tree/collab_panel.ts 🔗

@@ -0,0 +1,406 @@
+import {
+    background,
+    border,
+    border_color,
+    foreground,
+    text,
+} from "./components"
+import { interactive, toggleable } from "../element"
+import { useTheme } from "../theme"
+import collab_modals from "./collab_modals"
+import { text_button } from "../component/text_button"
+import { icon_button, toggleable_icon_button } from "../component/icon_button"
+import { indicator } from "../component/indicator"
+
+export default function contacts_panel(): any {
+    const theme = useTheme()
+
+    const NAME_MARGIN = 6 as const
+    const SPACING = 12 as const
+    const INDENT_SIZE = 8 as const
+    const ITEM_HEIGHT = 28 as const
+
+    const layer = theme.middle
+
+    const contact_button = {
+        background: background(layer, "on"),
+        color: foreground(layer, "on"),
+        icon_width: 14,
+        button_width: 16,
+        corner_radius: 8
+    }
+
+    const project_row = {
+        guest_avatar_spacing: 4,
+        height: 24,
+        guest_avatar: {
+            corner_radius: 8,
+            width: 14,
+        },
+        name: {
+            ...text(layer, "ui_sans", { size: "sm" }),
+            margin: {
+                left: NAME_MARGIN,
+                right: 4,
+            },
+        },
+        guests: {
+            margin: {
+                left: NAME_MARGIN,
+                right: NAME_MARGIN,
+            },
+        },
+        padding: {
+            left: SPACING,
+            right: SPACING,
+        },
+    }
+
+    const icon_style = {
+        color: foreground(layer, "variant"),
+        width: 14,
+    }
+
+    const header_icon_button = toggleable_icon_button(theme, {
+        variant: "ghost",
+        size: "sm",
+        active_layer: theme.lowest,
+    })
+
+    const subheader_row = toggleable({
+        base: interactive({
+            base: {
+                ...text(layer, "ui_sans", { size: "sm" }),
+                padding: {
+                    left: SPACING,
+                    right: SPACING,
+                },
+            },
+            state: {
+                hovered: {
+                    background: background(layer, "hovered"),
+                },
+                clicked: {
+                    background: background(layer, "pressed"),
+                },
+            },
+        }),
+        state: {
+            active: {
+                default: {
+                    ...text(theme.lowest, "ui_sans", { size: "sm" }),
+                    background: background(theme.lowest),
+                },
+                clicked: {
+                    background: background(layer, "pressed"),
+                },
+            },
+        },
+    })
+
+    const filter_input = {
+        background: background(layer, "on"),
+        corner_radius: 6,
+        text: text(layer, "ui_sans", "base"),
+        placeholder_text: text(layer, "ui_sans", "base", "disabled", {
+            size: "xs",
+        }),
+        selection: theme.players[0],
+        border: border(layer, "on"),
+        padding: {
+            bottom: 4,
+            left: 8,
+            right: 8,
+            top: 4,
+        },
+        margin: {
+            left: SPACING,
+            right: SPACING,
+        },
+    }
+
+    const item_row = toggleable({
+        base: interactive({
+            base: {
+                padding: {
+                    left: SPACING,
+                    right: SPACING,
+                },
+            },
+            state: {
+                clicked: {
+                    background: background(layer, "pressed"),
+                },
+            },
+        }),
+        state: {
+            inactive: {
+                hovered: {
+                    background: background(layer, "hovered"),
+                },
+            },
+            active: {
+                default: {
+                    ...text(theme.lowest, "ui_sans", { size: "sm" }),
+                    background: background(theme.lowest),
+                },
+                clicked: {
+                    background: background(layer, "pressed"),
+                },
+            },
+        },
+    })
+
+    return {
+        ...collab_modals(),
+        log_in_button: interactive({
+            base: {
+                background: background(theme.middle),
+                border: border(theme.middle, "active"),
+                corner_radius: 4,
+                margin: {
+                    top: 4,
+                    left: 16,
+                    right: 16,
+                },
+                padding: {
+                    top: 3,
+                    bottom: 3,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(theme.middle, "sans", "default", { size: "sm" }),
+            },
+            state: {
+                hovered: {
+                    ...text(theme.middle, "sans", "default", { size: "sm" }),
+                    background: background(theme.middle, "hovered"),
+                    border: border(theme.middle, "active"),
+                },
+                clicked: {
+                    ...text(theme.middle, "sans", "default", { size: "sm" }),
+                    background: background(theme.middle, "pressed"),
+                    border: border(theme.middle, "active"),
+                },
+            },
+        }),
+        background: background(layer),
+        padding: {
+            top: SPACING,
+        },
+        user_query_editor: filter_input,
+        channel_hash: icon_style,
+        user_query_editor_height: 33,
+        add_contact_button: header_icon_button,
+        add_channel_button: header_icon_button,
+        leave_call_button: header_icon_button,
+        row_height: ITEM_HEIGHT,
+        channel_indent: INDENT_SIZE,
+        section_icon_size: 14,
+        header_row: {
+            ...text(layer, "ui_sans", { size: "sm", weight: "bold" }),
+            margin: { top: SPACING },
+            padding: {
+                left: SPACING,
+                right: SPACING,
+            },
+        },
+        subheader_row,
+        leave_call: interactive({
+            base: {
+                background: background(layer),
+                border: border(layer),
+                corner_radius: 6,
+                margin: {
+                    top: 1,
+                },
+                padding: {
+                    top: 1,
+                    bottom: 1,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(layer, "sans", "variant", { size: "xs" }),
+            },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "hovered", { size: "xs" }),
+                    background: background(layer, "hovered"),
+                    border: border(layer, "hovered"),
+                },
+            },
+        }),
+        contact_row: toggleable({
+            base: interactive({
+                base: {
+                    padding: {
+                        left: SPACING,
+                        right: SPACING,
+                    },
+                },
+                state: {
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                },
+            }),
+            state: {
+                inactive: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+                active: {
+                    default: {
+                        ...text(theme.lowest, "ui_sans", { size: "sm" }),
+                        background: background(theme.lowest),
+                    },
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                },
+            },
+        }),
+        channel_row: item_row,
+        channel_name: {
+            ...text(layer, "ui_sans", { size: "sm" }),
+            margin: {
+                left: NAME_MARGIN,
+            },
+        },
+        list_empty_label_container: {
+            margin: {
+                left: NAME_MARGIN,
+            }
+        },
+        list_empty_icon: {
+            color: foreground(layer, "variant"),
+            width: 14,
+        },
+        list_empty_state: toggleable({
+            base: interactive({
+                base: {
+                    ...text(layer, "ui_sans", "variant", { size: "sm" }),
+                    padding: {
+                        top: SPACING / 2,
+                        bottom: SPACING / 2,
+                        left: SPACING,
+                        right: SPACING
+                    },
+                },
+                state: {
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                },
+            }),
+            state: {
+                inactive: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+                active: {
+                    default: {
+                        ...text(theme.lowest, "ui_sans", { size: "sm" }),
+                        background: background(theme.lowest),
+                    },
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                },
+            },
+        }),
+        contact_avatar: {
+            corner_radius: 10,
+            width: 20,
+        },
+        channel_avatar: {
+            corner_radius: 10,
+            width: 20,
+        },
+        extra_participant_label: {
+            corner_radius: 10,
+            padding: {
+                left: 10,
+                right: 4,
+            },
+            background: background(layer, "hovered"),
+            ...text(layer, "ui_sans", "hovered", { size: "xs" })
+        },
+        contact_status_free: indicator({ layer, color: "positive" }),
+        contact_status_busy: indicator({ layer, color: "negative" }),
+        contact_username: {
+            ...text(layer, "ui_sans", { size: "sm" }),
+            margin: {
+                left: NAME_MARGIN,
+            },
+        },
+        contact_button_spacing: NAME_MARGIN,
+        contact_button: icon_button({
+            variant: "ghost",
+            color: "variant",
+            size: "sm",
+        }),
+        disabled_button: {
+            ...contact_button,
+            background: background(layer, "on"),
+            color: foreground(layer, "on"),
+        },
+        calling_indicator: {
+            ...text(layer, "mono", "variant", { size: "xs" }),
+        },
+        tree_branch: toggleable({
+            base: interactive({
+                base: {
+                    color: border_color(layer),
+                    width: 1,
+                },
+                state: {
+                    hovered: {
+                        color: border_color(layer),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        color: border_color(layer),
+                    },
+                },
+            },
+        }),
+        project_row: toggleable({
+            base: interactive({
+                base: {
+                    ...project_row,
+                    icon: {
+                        margin: { left: NAME_MARGIN },
+                        color: foreground(layer, "variant"),
+                        width: 14,
+                    },
+                    name: {
+                        ...project_row.name,
+                        ...text(layer, "mono", { size: "sm" }),
+                    },
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: { background: background(theme.lowest) },
+                },
+            },
+        }),
+        face_overlap: 8,
+        channel_editor: {
+            padding: {
+                left: NAME_MARGIN,
+            }
+        }
+    }
+}

styles/src/style_tree/contact_finder.ts 🔗

@@ -1,11 +1,11 @@
-import picker from "./picker"
+// import picker from "./picker"
 import { background, border, foreground, text } from "./components"
 import { useTheme } from "../theme"
 
 export default function contact_finder(): any {
     const theme = useTheme()
 
-    const side_margin = 6
+    // const side_margin = 6
     const contact_button = {
         background: background(theme.middle, "variant"),
         color: foreground(theme.middle, "variant"),
@@ -14,42 +14,42 @@ export default function contact_finder(): any {
         corner_radius: 8,
     }
 
-    const picker_style = picker()
-    const picker_input = {
-        background: background(theme.middle, "on"),
-        corner_radius: 6,
-        text: text(theme.middle, "mono"),
-        placeholder_text: text(theme.middle, "mono", "on", "disabled", {
-            size: "xs",
-        }),
-        selection: theme.players[0],
-        border: border(theme.middle),
-        padding: {
-            bottom: 4,
-            left: 8,
-            right: 8,
-            top: 4,
-        },
-        margin: {
-            left: side_margin,
-            right: side_margin,
-        },
-    }
+    // const picker_style = picker()
+    // const picker_input = {
+    //     background: background(theme.middle, "on"),
+    //     corner_radius: 6,
+    //     text: text(theme.middle, "mono"),
+    //     placeholder_text: text(theme.middle, "mono", "on", "disabled", {
+    //         size: "xs",
+    //     }),
+    //     selection: theme.players[0],
+    //     border: border(theme.middle),
+    //     padding: {
+    //         bottom: 4,
+    //         left: 8,
+    //         right: 8,
+    //         top: 4,
+    //     },
+    //     margin: {
+    //         left: side_margin,
+    //         right: side_margin,
+    //     },
+    // }
 
     return {
-        picker: {
-            empty_container: {},
-            item: {
-                ...picker_style.item,
-                margin: { left: side_margin, right: side_margin },
-            },
-            no_matches: picker_style.no_matches,
-            input_editor: picker_input,
-            empty_input_editor: picker_input,
-            header: picker_style.header,
-            footer: picker_style.footer,
-        },
-        row_height: 28,
+        // picker: {
+        //     empty_container: {},
+        //     item: {
+        //         ...picker_style.item,
+        //         margin: { left: side_margin, right: side_margin },
+        //     },
+        //     no_matches: picker_style.no_matches,
+        //     input_editor: picker_input,
+        //     empty_input_editor: picker_input,
+        //     header: picker_style.header,
+        //     footer: picker_style.footer,
+        // },
+        // row_height: 28,
         contact_avatar: {
             corner_radius: 10,
             width: 18,

styles/src/style_tree/contact_list.ts 🔗

@@ -1,247 +0,0 @@
-import {
-    background,
-    border,
-    border_color,
-    foreground,
-    text,
-} from "./components"
-import { interactive, toggleable } from "../element"
-import { useTheme } from "../theme"
-export default function contacts_panel(): any {
-    const theme = useTheme()
-
-    const name_margin = 8
-    const side_padding = 12
-
-    const layer = theme.middle
-
-    const contact_button = {
-        background: background(layer, "on"),
-        color: foreground(layer, "on"),
-        icon_width: 8,
-        button_width: 16,
-        corner_radius: 8,
-    }
-    const project_row = {
-        guest_avatar_spacing: 4,
-        height: 24,
-        guest_avatar: {
-            corner_radius: 8,
-            width: 14,
-        },
-        name: {
-            ...text(layer, "mono", { size: "sm" }),
-            margin: {
-                left: name_margin,
-                right: 6,
-            },
-        },
-        guests: {
-            margin: {
-                left: name_margin,
-                right: name_margin,
-            },
-        },
-        padding: {
-            left: side_padding,
-            right: side_padding,
-        },
-    }
-
-    return {
-        background: background(layer),
-        padding: { top: 12 },
-        user_query_editor: {
-            background: background(layer, "on"),
-            corner_radius: 6,
-            text: text(layer, "mono", "on"),
-            placeholder_text: text(layer, "mono", "on", "disabled", {
-                size: "xs",
-            }),
-            selection: theme.players[0],
-            border: border(layer, "on"),
-            padding: {
-                bottom: 4,
-                left: 8,
-                right: 8,
-                top: 4,
-            },
-            margin: {
-                left: 6,
-            },
-        },
-        user_query_editor_height: 33,
-        add_contact_button: {
-            margin: { left: 6, right: 12 },
-            color: foreground(layer, "on"),
-            button_width: 28,
-            icon_width: 16,
-        },
-        row_height: 28,
-        section_icon_size: 8,
-        header_row: toggleable({
-            base: interactive({
-                base: {
-                    ...text(layer, "mono", { size: "sm" }),
-                    margin: { top: 14 },
-                    padding: {
-                        left: side_padding,
-                        right: side_padding,
-                    },
-                    background: background(layer, "default"), // posiewic: breaking change
-                },
-                state: {
-                    hovered: {
-                        background: background(layer, "hovered"),
-                    },
-                    clicked: {
-                        background: background(layer, "pressed"),
-                    },
-                }, // hack, we want headerRow to be interactive for whatever reason. It probably shouldn't be interactive in the first place.
-            }),
-            state: {
-                active: {
-                    default: {
-                        ...text(layer, "mono", "active", { size: "sm" }),
-                        background: background(layer, "active"),
-                    },
-                    hovered: {
-                        background: background(layer, "hovered"),
-                    },
-                    clicked: {
-                        background: background(layer, "pressed"),
-                    },
-                },
-            },
-        }),
-        leave_call: interactive({
-            base: {
-                background: background(layer),
-                border: border(layer),
-                corner_radius: 6,
-                margin: {
-                    top: 1,
-                },
-                padding: {
-                    top: 1,
-                    bottom: 1,
-                    left: 7,
-                    right: 7,
-                },
-                ...text(layer, "sans", "variant", { size: "xs" }),
-            },
-            state: {
-                hovered: {
-                    ...text(layer, "sans", "hovered", { size: "xs" }),
-                    background: background(layer, "hovered"),
-                    border: border(layer, "hovered"),
-                },
-            },
-        }),
-        contact_row: {
-            inactive: {
-                default: {
-                    padding: {
-                        left: side_padding,
-                        right: side_padding,
-                    },
-                },
-            },
-            active: {
-                default: {
-                    background: background(layer, "active"),
-                    padding: {
-                        left: side_padding,
-                        right: side_padding,
-                    },
-                },
-            },
-        },
-        contact_avatar: {
-            corner_radius: 10,
-            width: 18,
-        },
-        contact_status_free: {
-            corner_radius: 4,
-            padding: 4,
-            margin: { top: 12, left: 12 },
-            background: foreground(layer, "positive"),
-        },
-        contact_status_busy: {
-            corner_radius: 4,
-            padding: 4,
-            margin: { top: 12, left: 12 },
-            background: foreground(layer, "negative"),
-        },
-        contact_username: {
-            ...text(layer, "mono", { size: "sm" }),
-            margin: {
-                left: name_margin,
-            },
-        },
-        contact_button_spacing: name_margin,
-        contact_button: interactive({
-            base: { ...contact_button },
-            state: {
-                hovered: {
-                    background: background(layer, "hovered"),
-                },
-            },
-        }),
-        disabled_button: {
-            ...contact_button,
-            background: background(layer, "on"),
-            color: foreground(layer, "on"),
-        },
-        calling_indicator: {
-            ...text(layer, "mono", "variant", { size: "xs" }),
-        },
-        tree_branch: toggleable({
-            base: interactive({
-                base: {
-                    color: border_color(layer),
-                    width: 1,
-                },
-                state: {
-                    hovered: {
-                        color: border_color(layer),
-                    },
-                },
-            }),
-            state: {
-                active: {
-                    default: {
-                        color: border_color(layer),
-                    },
-                },
-            },
-        }),
-        project_row: toggleable({
-            base: interactive({
-                base: {
-                    ...project_row,
-                    background: background(layer),
-                    icon: {
-                        margin: { left: name_margin },
-                        color: foreground(layer, "variant"),
-                        width: 12,
-                    },
-                    name: {
-                        ...project_row.name,
-                        ...text(layer, "mono", { size: "sm" }),
-                    },
-                },
-                state: {
-                    hovered: {
-                        background: background(layer, "hovered"),
-                    },
-                },
-            }),
-            state: {
-                active: {
-                    default: { background: background(layer, "active") },
-                },
-            },
-        }),
-    }
-}

styles/src/style_tree/contacts_popover.ts 🔗

@@ -4,13 +4,4 @@ import { background, border } from "./components"
 export default function contacts_popover(): any {
     const theme = useTheme()
 
-    return {
-        background: background(theme.middle),
-        corner_radius: 6,
-        padding: { top: 6, bottom: 6 },
-        shadow: theme.popover_shadow,
-        border: border(theme.middle),
-        width: 300,
-        height: 400,
-    }
 }

styles/src/style_tree/context_menu.ts 🔗

@@ -19,7 +19,7 @@ export default function context_menu(): any {
                     icon_width: 14,
                     padding: { left: 6, right: 6, top: 2, bottom: 2 },
                     corner_radius: 6,
-                    label: text(theme.middle, "sans", { size: "sm" }),
+                    label: text(theme.middle, "ui_sans", { size: "sm" }),
                     keystroke: {
                         ...text(theme.middle, "sans", "variant", {
                             size: "sm",
@@ -31,16 +31,6 @@ export default function context_menu(): any {
                 state: {
                     hovered: {
                         background: background(theme.middle, "hovered"),
-                        label: text(theme.middle, "sans", "hovered", {
-                            size: "sm",
-                        }),
-                        keystroke: {
-                            ...text(theme.middle, "sans", "hovered", {
-                                size: "sm",
-                                weight: "bold",
-                            }),
-                            padding: { left: 3, right: 3 },
-                        },
                     },
                     clicked: {
                         background: background(theme.middle, "pressed"),

styles/src/style_tree/status_bar.ts 🔗

@@ -28,16 +28,16 @@ export default function status_bar(): any {
             right: 6,
         },
         border: border(layer, { top: true, overlay: true }),
-        cursor_position: text(layer, "sans", "variant", { size: "xs" }),
+        cursor_position: text(layer, "sans", "base", { size: "xs" }),
         vim_mode_indicator: {
             margin: { left: 6 },
-            ...text(layer, "mono", "variant", { size: "xs" }),
+            ...text(layer, "mono", "base", { size: "xs" }),
         },
         active_language: text_button({
-            color: "variant"
+            color: "base"
         }),
-        auto_update_progress_message: text(layer, "sans", "variant", { size: "xs" }),
-        auto_update_done_message: text(layer, "sans", "variant", { size: "xs" }),
+        auto_update_progress_message: text(layer, "sans", "base", { size: "xs" }),
+        auto_update_done_message: text(layer, "sans", "base", { size: "xs" }),
         lsp_status: interactive({
             base: {
                 ...diagnostic_status_container,
@@ -64,11 +64,11 @@ export default function status_bar(): any {
         diagnostic_summary: interactive({
             base: {
                 height: 20,
-                icon_width: 16,
+                icon_width: 14,
                 icon_spacing: 2,
                 summary_spacing: 6,
                 text: text(layer, "sans", { size: "sm" }),
-                icon_color_ok: foreground(layer, "variant"),
+                icon_color_ok: foreground(layer, "base"),
                 icon_color_warning: foreground(layer, "warning"),
                 icon_color_error: foreground(layer, "negative"),
                 container_ok: {
@@ -111,8 +111,9 @@ export default function status_bar(): any {
                 base: interactive({
                     base: {
                         ...status_container,
-                        icon_size: 16,
-                        icon_color: foreground(layer, "variant"),
+                        icon_size: 14,
+                        icon_color: foreground(layer, "base"),
+                        background: background(layer, "default"),
                         label: {
                             margin: { left: 6 },
                             ...text(layer, "sans", { size: "xs" }),
@@ -120,23 +121,25 @@ export default function status_bar(): any {
                     },
                     state: {
                         hovered: {
-                            icon_color: foreground(layer, "hovered"),
-                            background: background(layer, "variant"),
+                            background: background(layer, "hovered"),
                         },
+                        clicked: {
+                            background: background(layer, "pressed"),
+                        }
                     },
                 }),
                 state: {
                     active: {
                         default: {
-                            icon_color: foreground(layer, "active"),
-                            background: background(layer, "active"),
+                            icon_color: foreground(layer, "accent", "default"),
+                            background: background(layer, "default"),
                         },
                         hovered: {
-                            icon_color: foreground(layer, "hovered"),
+                            icon_color: foreground(layer, "accent", "hovered"),
                             background: background(layer, "hovered"),
                         },
                         clicked: {
-                            icon_color: foreground(layer, "pressed"),
+                            icon_color: foreground(layer, "accent", "pressed"),
                             background: background(layer, "pressed"),
                         },
                     },

styles/src/style_tree/titlebar.ts 🔗

@@ -178,6 +178,10 @@ export function titlebar(): any {
             left: 80,
             right: 0,
         },
+        menu: {
+            width: 300,
+            height: 400,
+        },
 
         // Project
         project_name_divider: text(theme.lowest, "sans", "variant"),