Merge pull request #979 from zed-industries/contacts

Manage users' contact relationships on the server
This commit is contained in:
Max Brunsfeld 2022-05-10 15:11:30 -07:00 committed by GitHub
commit c5360172e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 4857 additions and 948 deletions

143
Cargo.lock generated
View File

@ -841,6 +841,7 @@ dependencies = [
"async-tungstenite",
"axum",
"base64 0.13.0",
"clap 3.1.12",
"client",
"collections",
"ctor",
@ -859,6 +860,7 @@ dependencies = [
"parking_lot",
"project",
"rand 0.8.3",
"reqwest",
"rpc",
"scrypt",
"serde",
@ -930,10 +932,17 @@ name = "contacts_panel"
version = "0.1.0"
dependencies = [
"client",
"editor",
"futures",
"fuzzy",
"gpui",
"log",
"picker",
"postage",
"serde",
"settings",
"theme",
"util",
"workspace",
]
@ -2133,6 +2142,19 @@ dependencies = [
"tokio-io-timeout",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "idna"
version = "0.2.3"
@ -2237,6 +2259,12 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "ipnet"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b"
[[package]]
name = "isahc"
version = "1.7.0"
@ -2720,6 +2748,24 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
[[package]]
name = "native-tls"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "nb-connect"
version = "1.0.3"
@ -2903,6 +2949,32 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "openssl"
version = "0.10.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb81a6430ac911acb25fe5ac8f1d2af1b4ea8a4fdfda0f1ee4292af2e2d8eb0e"
dependencies = [
"bitflags",
"cfg-if 1.0.0",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.4"
@ -2911,9 +2983,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]]
name = "openssl-sys"
version = "0.9.65"
version = "0.9.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d"
checksum = "9d5fd19fb3e0a8191c1e34935718976a3e70c112ab9a24af6d7cadccd9d90bc0"
dependencies = [
"autocfg 1.0.1",
"cc",
@ -3677,6 +3749,42 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "reqwest"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f242f1488a539a79bac6dbe7c8609ae43b7914b7736210f239a37cccb32525"
dependencies = [
"base64 0.13.0",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"lazy_static",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite 0.2.9",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tokio-native-tls",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
]
[[package]]
name = "resvg"
version = "0.14.0"
@ -4957,6 +5065,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.22.0"
@ -5632,6 +5750,18 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fba7978c679d53ce2d0ac80c8c175840feb849a161664365d1287b41f2e67f1"
dependencies = [
"cfg-if 1.0.0",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.74"
@ -5777,6 +5907,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winreg"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69"
dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "wio"
version = "0.2.2"

3
assets/icons/accept.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.25 1L3.25 5L1 2.75" stroke="white" stroke-width="1.33333"/>
</svg>

After

Width:  |  Height:  |  Size: 171 B

View File

@ -0,0 +1,3 @@
<svg width="12" height="10" viewBox="0 0 12 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.2 5.0002C5.52563 5.0002 6.6 3.92563 6.6 2.6002C6.6 1.27476 5.52563 0.200195 4.2 0.200195C2.87438 0.200195 1.8 1.27476 1.8 2.6002C1.8 3.92563 2.87438 5.0002 4.2 5.0002ZM5.15063 5.9002H3.24938C1.45519 5.9002 0 7.3552 0 9.14957C0 9.50957 0.291 9.8002 0.649875 9.8002H7.7505C8.10938 9.8002 8.4 9.50957 8.4 9.14957C8.4 7.3552 6.945 5.9002 5.15063 5.9002ZM11.55 3.9502H10.65V3.0502C10.65 2.8027 10.4494 2.6002 10.2 2.6002C9.95063 2.6002 9.75 2.80176 9.75 3.0502V3.9502H8.85C8.6025 3.9502 8.4 4.1527 8.4 4.4002C8.4 4.6477 8.60156 4.8502 8.85 4.8502H9.75V5.7502C9.75 5.99957 9.9525 6.2002 10.2 6.2002C10.4475 6.2002 10.65 5.99863 10.65 5.7502V4.8502H11.55C11.7994 4.8502 12 4.64957 12 4.4002C12 4.15082 11.7994 3.9502 11.55 3.9502Z" fill="#9C9C9C"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

3
assets/icons/reject.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7L4 4M7 1L4 4M4 4L1 1M4 4L7 7" stroke="#9C9C9C" stroke-width="1.33333"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#19171c",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#e2dfe7",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#7e7887",
"size": 14
},
"selection": {
"cursor": "#576ddb",
"selection": "#576ddb3d"
},
"border": {
"color": "#26232a",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#e2dfe7",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#8b8792",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#655f6d",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#e2dfe7",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#26232a",
"color": "#e2dfe7",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#5852603d"
}
},
"disabled_contact_button": {
"background": "#26232a",
"color": "#8b8792",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#26232a",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#8b8792",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#576ddb",
"weight": "bold",
"size": 14
},
"active": {
"background": "#5852605c",
"text": {
"family": "Zed Sans",
"color": "#e2dfe7",
"size": 14
}
},
"hover": {
"background": "#5852603d"
}
},
"border": {
"color": "#19171c",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#7e7887",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#19171c",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#7e7887",
"size": 14
},
"selection": {
"cursor": "#576ddb",
"selection": "#576ddb3d"
},
"text": {
"family": "Zed Mono",
"color": "#e2dfe7",
"size": 14
},
"border": {
"color": "#26232a",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000003d",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#26232a",
"color": "#e2dfe7",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#5852603d"
}
},
"disabled_contact_button": {
"background": "#26232a",
"color": "#8b8792",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#955ae77a",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#efecf4",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#26232a",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#655f6d",
"size": 14
},
"selection": {
"cursor": "#576ddb",
"selection": "#576ddb3d"
},
"border": {
"color": "#e2dfe7",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#26232a",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#585260",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#7e7887",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#26232a",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#e2dfe7",
"color": "#26232a",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#8b87921f"
}
},
"disabled_contact_button": {
"background": "#e2dfe7",
"color": "#585260",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#e2dfe7",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#585260",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#576ddb",
"weight": "bold",
"size": 14
},
"active": {
"background": "#8b87922e",
"text": {
"family": "Zed Sans",
"color": "#26232a",
"size": 14
}
},
"hover": {
"background": "#8b87921f"
}
},
"border": {
"color": "#efecf4",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#655f6d",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#efecf4",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#655f6d",
"size": 14
},
"selection": {
"cursor": "#576ddb",
"selection": "#576ddb3d"
},
"text": {
"family": "Zed Mono",
"color": "#26232a",
"size": 14
},
"border": {
"color": "#e2dfe7",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000001f",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#e2dfe7",
"color": "#26232a",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#8b87921f"
}
},
"disabled_contact_button": {
"background": "#e2dfe7",
"color": "#585260",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#955ae73d",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#000000",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#f1f1f1",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#474747",
"size": 14
},
"selection": {
"cursor": "#2472f2",
"selection": "#2472f23d"
},
"border": {
"color": "#232323",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#c6c6c6",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#9c9c9c",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#404040",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#f1f1f1",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#2b2b2b",
"color": "#c6c6c6",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#323232"
}
},
"disabled_contact_button": {
"background": "#2b2b2b",
"color": "#555555",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#1c1c1c",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#9c9c9c",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#4f8ff7",
"weight": "bold",
"size": 14
},
"active": {
"background": "#2b2b2b",
"text": {
"family": "Zed Sans",
"color": "#f1f1f1",
"size": 14
}
},
"hover": {
"background": "#232323"
}
},
"border": {
"color": "#070707",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#474747",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#000000",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#474747",
"size": 14
},
"selection": {
"cursor": "#2472f2",
"selection": "#2472f23d"
},
"text": {
"family": "Zed Mono",
"color": "#f1f1f1",
"size": 14
},
"border": {
"color": "#232323",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#00000052",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#2b2b2b",
"color": "#c6c6c6",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#323232"
}
},
"disabled_contact_button": {
"background": "#2b2b2b",
"color": "#555555",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#3f15a380",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#ffffff",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#2b2b2b",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#808080",
"size": 14
},
"selection": {
"cursor": "#2472f2",
"selection": "#2472f23d"
},
"border": {
"color": "#d5d5d5",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#393939",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#474747",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#e3e3e3",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#2b2b2b",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#eaeaea",
"color": "#393939",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#e3e3e3"
}
},
"disabled_contact_button": {
"background": "#eaeaea",
"color": "#9c9c9c",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#f8f8f8",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#474747",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#484bed",
"weight": "bold",
"size": 14
},
"active": {
"background": "#e3e3e3",
"text": {
"family": "Zed Sans",
"color": "#2b2b2b",
"size": 14
}
},
"hover": {
"background": "#eaeaea"
}
},
"border": {
"color": "#d5d5d5",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#808080",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#ffffff",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#808080",
"size": 14
},
"selection": {
"cursor": "#2472f2",
"selection": "#2472f23d"
},
"text": {
"family": "Zed Mono",
"color": "#2b2b2b",
"size": 14
},
"border": {
"color": "#d5d5d5",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000001f",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#eaeaea",
"color": "#393939",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#e3e3e3"
}
},
"disabled_contact_button": {
"background": "#eaeaea",
"color": "#9c9c9c",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#fce9b7",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#002b36",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#eee8d5",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#839496",
"size": 14
},
"selection": {
"cursor": "#268bd2",
"selection": "#268bd23d"
},
"border": {
"color": "#073642",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#eee8d5",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#93a1a1",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#657b83",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#eee8d5",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#073642",
"color": "#eee8d5",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#586e753d"
}
},
"disabled_contact_button": {
"background": "#073642",
"color": "#93a1a1",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#073642",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#93a1a1",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#268bd2",
"weight": "bold",
"size": 14
},
"active": {
"background": "#586e755c",
"text": {
"family": "Zed Sans",
"color": "#eee8d5",
"size": 14
}
},
"hover": {
"background": "#586e753d"
}
},
"border": {
"color": "#002b36",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#839496",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#002b36",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#839496",
"size": 14
},
"selection": {
"cursor": "#268bd2",
"selection": "#268bd23d"
},
"text": {
"family": "Zed Mono",
"color": "#eee8d5",
"size": 14
},
"border": {
"color": "#073642",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000003d",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#073642",
"color": "#eee8d5",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#586e753d"
}
},
"disabled_contact_button": {
"background": "#073642",
"color": "#93a1a1",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#6c71c47a",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#fdf6e3",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#073642",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#657b83",
"size": 14
},
"selection": {
"cursor": "#268bd2",
"selection": "#268bd23d"
},
"border": {
"color": "#eee8d5",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#073642",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#586e75",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#839496",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#073642",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#eee8d5",
"color": "#073642",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#93a1a11f"
}
},
"disabled_contact_button": {
"background": "#eee8d5",
"color": "#586e75",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#eee8d5",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#586e75",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#268bd2",
"weight": "bold",
"size": 14
},
"active": {
"background": "#93a1a12e",
"text": {
"family": "Zed Sans",
"color": "#073642",
"size": 14
}
},
"hover": {
"background": "#93a1a11f"
}
},
"border": {
"color": "#fdf6e3",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#657b83",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#fdf6e3",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#657b83",
"size": 14
},
"selection": {
"cursor": "#268bd2",
"selection": "#268bd23d"
},
"text": {
"family": "Zed Mono",
"color": "#073642",
"size": 14
},
"border": {
"color": "#eee8d5",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000001f",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#eee8d5",
"color": "#073642",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#93a1a11f"
}
},
"disabled_contact_button": {
"background": "#eee8d5",
"color": "#586e75",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#6c71c43d",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#202746",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#dfe2f1",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#898ea4",
"size": 14
},
"selection": {
"cursor": "#3d8fd1",
"selection": "#3d8fd13d"
},
"border": {
"color": "#293256",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#dfe2f1",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#979db4",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#6b7394",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#dfe2f1",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#293256",
"color": "#dfe2f1",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#5e66873d"
}
},
"disabled_contact_button": {
"background": "#293256",
"color": "#979db4",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#293256",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#979db4",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#3d8fd1",
"weight": "bold",
"size": 14
},
"active": {
"background": "#5e66875c",
"text": {
"family": "Zed Sans",
"color": "#dfe2f1",
"size": 14
}
},
"hover": {
"background": "#5e66873d"
}
},
"border": {
"color": "#202746",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#898ea4",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#202746",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#898ea4",
"size": 14
},
"selection": {
"cursor": "#3d8fd1",
"selection": "#3d8fd13d"
},
"text": {
"family": "Zed Mono",
"color": "#dfe2f1",
"size": 14
},
"border": {
"color": "#293256",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000003d",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#293256",
"color": "#dfe2f1",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#5e66873d"
}
},
"disabled_contact_button": {
"background": "#293256",
"color": "#979db4",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#6679cc7a",
"tab_icon_spacing": 8,

View File

@ -1212,14 +1212,64 @@
"bottom": 12,
"right": 12
},
"host_row_height": 28,
"user_query_editor": {
"background": "#f5f7ff",
"corner_radius": 6,
"text": {
"family": "Zed Mono",
"color": "#293256",
"size": 14
},
"placeholder_text": {
"family": "Zed Mono",
"color": "#6b7394",
"size": 14
},
"selection": {
"cursor": "#3d8fd1",
"selection": "#3d8fd13d"
},
"border": {
"color": "#dfe2f1",
"width": 1
},
"padding": {
"bottom": 4,
"left": 8,
"right": 8,
"top": 4
}
},
"user_query_editor_height": 32,
"add_contact_button": {
"margin": {
"left": 6
},
"color": "#293256",
"button_width": 8,
"icon_width": 8
},
"row": {
"padding": {
"left": 8
}
},
"row_height": 28,
"header": {
"family": "Zed Mono",
"color": "#5e6687",
"size": 14,
"margin": {
"top": 8
}
},
"tree_branch_color": "#898ea4",
"tree_branch_width": 1,
"host_avatar": {
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"host_username": {
"contact_username": {
"family": "Zed Mono",
"color": "#293256",
"size": 14,
@ -1227,6 +1277,23 @@
"left": 8
}
},
"contact_button": {
"background": "#dfe2f1",
"color": "#293256",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#979db41f"
}
},
"disabled_contact_button": {
"background": "#dfe2f1",
"color": "#5e6687",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
},
"project": {
"guest_avatar_spacing": 4,
"height": 24,
@ -1328,6 +1395,122 @@
"corner_radius": 6
}
},
"contact_finder": {
"background": "#dfe2f1",
"corner_radius": 8,
"padding": 8,
"item": {
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 4
},
"corner_radius": 8,
"text": {
"family": "Zed Sans",
"color": "#5e6687",
"size": 14
},
"highlight_text": {
"family": "Zed Sans",
"color": "#3d8fd1",
"weight": "bold",
"size": 14
},
"active": {
"background": "#979db42e",
"text": {
"family": "Zed Sans",
"color": "#293256",
"size": 14
}
},
"hover": {
"background": "#979db41f"
}
},
"border": {
"color": "#f5f7ff",
"width": 1
},
"empty": {
"text": {
"family": "Zed Sans",
"color": "#6b7394",
"size": 14
},
"padding": {
"bottom": 4,
"left": 12,
"right": 12,
"top": 8
}
},
"input_editor": {
"background": "#f5f7ff",
"corner_radius": 8,
"placeholder_text": {
"family": "Zed Sans",
"color": "#6b7394",
"size": 14
},
"selection": {
"cursor": "#3d8fd1",
"selection": "#3d8fd13d"
},
"text": {
"family": "Zed Mono",
"color": "#293256",
"size": 14
},
"border": {
"color": "#dfe2f1",
"width": 1
},
"padding": {
"bottom": 7,
"left": 16,
"right": 16,
"top": 7
}
},
"shadow": {
"blur": 16,
"color": "#0000001f",
"offset": [
0,
2
]
},
"row_height": 28,
"contact_avatar": {
"corner_radius": 10,
"width": 18
},
"contact_username": {
"padding": {
"left": 8
}
},
"contact_button": {
"background": "#dfe2f1",
"color": "#293256",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8,
"hover": {
"background": "#979db41f"
}
},
"disabled_contact_button": {
"background": "#dfe2f1",
"color": "#5e6687",
"icon_width": 8,
"button_width": 16,
"corner_radius": 8
}
},
"search": {
"match_background": "#6679cc3d",
"tab_icon_spacing": 8,

View File

@ -500,7 +500,7 @@ async fn messages_from_proto(
.collect();
user_store
.update(cx, |user_store, cx| {
user_store.load_users(unique_user_ids, cx)
user_store.get_users(unique_user_ids, cx)
})
.await?;
@ -639,7 +639,7 @@ mod tests {
server
.respond(
get_users.receipt(),
proto::GetUsersResponse {
proto::UsersResponse {
users: vec![proto::User {
id: 5,
github_login: "nathansobo".into(),
@ -690,7 +690,7 @@ mod tests {
server
.respond(
get_users.receipt(),
proto::GetUsersResponse {
proto::UsersResponse {
users: vec![proto::User {
id: 6,
github_login: "maxbrunsfeld".into(),
@ -738,7 +738,7 @@ mod tests {
server
.respond(
get_users.receipt(),
proto::GetUsersResponse {
proto::UsersResponse {
users: vec![proto::User {
id: 7,
github_login: "as-cii".into(),

View File

@ -117,7 +117,7 @@ impl EstablishConnectionError {
}
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Status {
SignedOut,
UpgradeRequired,
@ -293,6 +293,7 @@ impl Client {
}
fn set_status(self: &Arc<Self>, status: Status, cx: &AsyncAppContext) {
log::info!("set status on client {}: {:?}", self.id, status);
let mut state = self.state.write();
*state.status.0.borrow_mut() = status;
@ -629,10 +630,13 @@ impl Client {
async fn set_connection(self: &Arc<Self>, conn: Connection, cx: &AsyncAppContext) {
let executor = cx.background();
log::info!("add connection to peer");
let (connection_id, handle_io, mut incoming) = self
.peer
.add_connection(conn, move |duration| executor.timer(duration))
.await;
log::info!("set status to connected {}", connection_id);
self.set_status(Status::Connected { connection_id }, cx);
cx.foreground()
.spawn({
let cx = cx.clone();
@ -730,15 +734,17 @@ impl Client {
})
.detach();
self.set_status(Status::Connected { connection_id }, cx);
let handle_io = cx.background().spawn(handle_io);
let this = self.clone();
let cx = cx.clone();
cx.foreground()
.spawn(async move {
match handle_io.await {
Ok(()) => this.set_status(Status::SignedOut, &cx),
Ok(()) => {
if *this.status().borrow() == (Status::Connected { connection_id }) {
this.set_status(Status::SignedOut, &cx);
}
}
Err(err) => {
log::error!("connection error: {:?}", err);
this.set_status(Status::ConnectionLost, &cx);

View File

@ -1,10 +1,11 @@
use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
use anyhow::{anyhow, Result};
use futures::{future, AsyncReadExt};
use anyhow::{anyhow, Context, Result};
use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
use postage::{prelude::Stream, sink::Sink, watch};
use rpc::proto::{RequestMessage, UsersResponse};
use std::{
collections::{HashMap, HashSet},
collections::{hash_map::Entry, HashMap, HashSet},
sync::{Arc, Weak},
};
use util::TryFutureExt as _;
@ -19,6 +20,7 @@ pub struct User {
#[derive(Debug)]
pub struct Contact {
pub user: Arc<User>,
pub online: bool,
pub projects: Vec<ProjectMetadata>,
}
@ -30,11 +32,22 @@ pub struct ProjectMetadata {
pub guests: Vec<Arc<User>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContactRequestStatus {
None,
RequestSent,
RequestReceived,
RequestAccepted,
}
pub struct UserStore {
users: HashMap<u64, Arc<User>>,
update_contacts_tx: watch::Sender<Option<proto::UpdateContacts>>,
update_contacts_tx: mpsc::UnboundedSender<UpdateContacts>,
current_user: watch::Receiver<Option<Arc<User>>>,
contacts: Arc<[Contact]>,
contacts: Vec<Arc<Contact>>,
incoming_contact_requests: Vec<Arc<User>>,
outgoing_contact_requests: Vec<Arc<User>>,
pending_contact_requests: HashMap<u64, usize>,
client: Weak<Client>,
http: Arc<dyn HttpClient>,
_maintain_contacts: Task<()>,
@ -47,6 +60,11 @@ impl Entity for UserStore {
type Event = Event;
}
enum UpdateContacts {
Update(proto::UpdateContacts),
Clear(postage::barrier::Sender),
}
impl UserStore {
pub fn new(
client: Arc<Client>,
@ -54,21 +72,22 @@ impl UserStore {
cx: &mut ModelContext<Self>,
) -> Self {
let (mut current_user_tx, current_user_rx) = watch::channel();
let (update_contacts_tx, mut update_contacts_rx) =
watch::channel::<Option<proto::UpdateContacts>>();
let (update_contacts_tx, mut update_contacts_rx) = mpsc::unbounded();
let rpc_subscription =
client.add_message_handler(cx.handle(), Self::handle_update_contacts);
Self {
users: Default::default(),
current_user: current_user_rx,
contacts: Arc::from([]),
contacts: Default::default(),
incoming_contact_requests: Default::default(),
outgoing_contact_requests: Default::default(),
client: Arc::downgrade(&client),
update_contacts_tx,
http,
_maintain_contacts: cx.spawn_weak(|this, mut cx| async move {
let _subscription = rpc_subscription;
while let Some(message) = update_contacts_rx.recv().await {
if let Some((message, this)) = message.zip(this.upgrade(&cx)) {
while let Some(message) = update_contacts_rx.next().await {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| this.update_contacts(message, cx))
.log_err()
.await;
@ -90,11 +109,20 @@ impl UserStore {
}
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;
}
}
Status::ConnectionLost => {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, _| this.clear_contacts()).await;
}
}
_ => {}
}
}
}),
pending_contact_requests: Default::default(),
}
}
@ -105,76 +133,278 @@ impl UserStore {
mut cx: AsyncAppContext,
) -> Result<()> {
this.update(&mut cx, |this, _| {
*this.update_contacts_tx.borrow_mut() = Some(msg.payload);
this.update_contacts_tx
.unbounded_send(UpdateContacts::Update(msg.payload))
.unwrap();
});
Ok(())
}
fn update_contacts(
&mut self,
message: proto::UpdateContacts,
message: UpdateContacts,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
let mut user_ids = HashSet::new();
for contact in &message.contacts {
user_ids.insert(contact.user_id);
user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
}
let load_users = self.load_users(user_ids.into_iter().collect(), cx);
cx.spawn(|this, mut cx| async move {
load_users.await?;
let mut contacts = Vec::new();
for contact in message.contacts {
contacts.push(Contact::from_proto(contact, &this, &mut cx).await?);
match message {
UpdateContacts::Clear(barrier) => {
self.contacts.clear();
self.incoming_contact_requests.clear();
self.outgoing_contact_requests.clear();
drop(barrier);
Task::ready(Ok(()))
}
UpdateContacts::Update(message) => {
log::info!(
"update contacts on client {}: {:?}",
self.client.upgrade().unwrap().id,
message
);
let mut user_ids = HashSet::new();
for contact in &message.contacts {
user_ids.insert(contact.user_id);
user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
}
user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
user_ids.extend(message.outgoing_requests.iter());
let load_users = self.get_users(user_ids.into_iter().collect(), cx);
cx.spawn(|this, mut cx| async move {
load_users.await?;
// Users are fetched in parallel above and cached in call to get_users
// No need to paralellize here
let mut updated_contacts = Vec::new();
for contact in message.contacts {
updated_contacts.push(Arc::new(
Contact::from_proto(contact, &this, &mut cx).await?,
));
}
let mut incoming_requests = Vec::new();
for request in message.incoming_requests {
incoming_requests.push(
this.update(&mut cx, |this, cx| {
this.fetch_user(request.requester_id, cx)
})
.await?,
);
}
let mut outgoing_requests = Vec::new();
for requested_user_id in message.outgoing_requests {
outgoing_requests.push(
this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx))
.await?,
);
}
let removed_contacts =
HashSet::<u64>::from_iter(message.remove_contacts.iter().copied());
let removed_incoming_requests =
HashSet::<u64>::from_iter(message.remove_incoming_requests.iter().copied());
let removed_outgoing_requests =
HashSet::<u64>::from_iter(message.remove_outgoing_requests.iter().copied());
this.update(&mut cx, |this, cx| {
// Remove contacts
this.contacts
.retain(|contact| !removed_contacts.contains(&contact.user.id));
// Update existing contacts and insert new ones
for updated_contact in updated_contacts {
match this.contacts.binary_search_by_key(
&&updated_contact.user.github_login,
|contact| &contact.user.github_login,
) {
Ok(ix) => this.contacts[ix] = updated_contact,
Err(ix) => this.contacts.insert(ix, updated_contact),
}
}
// Remove incoming contact requests
this.incoming_contact_requests
.retain(|user| !removed_incoming_requests.contains(&user.id));
// Update existing incoming requests and insert new ones
for request in incoming_requests {
match this
.incoming_contact_requests
.binary_search_by_key(&&request.github_login, |contact| {
&contact.github_login
}) {
Ok(ix) => this.incoming_contact_requests[ix] = request,
Err(ix) => this.incoming_contact_requests.insert(ix, request),
}
}
// Remove outgoing contact requests
this.outgoing_contact_requests
.retain(|user| !removed_outgoing_requests.contains(&user.id));
// Update existing incoming requests and insert new ones
for request in outgoing_requests {
match this
.outgoing_contact_requests
.binary_search_by_key(&&request.github_login, |contact| {
&contact.github_login
}) {
Ok(ix) => this.outgoing_contact_requests[ix] = request,
Err(ix) => this.outgoing_contact_requests.insert(ix, request),
}
}
cx.notify();
});
Ok(())
})
}
}
}
pub fn contacts(&self) -> &[Arc<Contact>] {
&self.contacts
}
pub fn has_contact(&self, user: &Arc<User>) -> bool {
self.contacts
.binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login)
.is_ok()
}
pub fn incoming_contact_requests(&self) -> &[Arc<User>] {
&self.incoming_contact_requests
}
pub fn outgoing_contact_requests(&self) -> &[Arc<User>] {
&self.outgoing_contact_requests
}
pub fn is_contact_request_pending(&self, user: &User) -> bool {
self.pending_contact_requests.contains_key(&user.id)
}
pub fn contact_request_status(&self, user: &User) -> ContactRequestStatus {
if self
.contacts
.binary_search_by_key(&&user.github_login, |contact| &contact.user.github_login)
.is_ok()
{
ContactRequestStatus::RequestAccepted
} else if self
.outgoing_contact_requests
.binary_search_by_key(&&user.github_login, |user| &user.github_login)
.is_ok()
{
ContactRequestStatus::RequestSent
} else if self
.incoming_contact_requests
.binary_search_by_key(&&user.github_login, |user| &user.github_login)
.is_ok()
{
ContactRequestStatus::RequestReceived
} else {
ContactRequestStatus::None
}
}
pub fn request_contact(
&mut self,
responder_id: u64,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
self.perform_contact_request(responder_id, proto::RequestContact { responder_id }, cx)
}
pub fn remove_contact(
&mut self,
user_id: u64,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
self.perform_contact_request(user_id, proto::RemoveContact { user_id }, cx)
}
pub fn respond_to_contact_request(
&mut self,
requester_id: u64,
accept: bool,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
self.perform_contact_request(
requester_id,
proto::RespondToContactRequest {
requester_id,
response: if accept {
proto::ContactRequestResponse::Accept
} else {
proto::ContactRequestResponse::Reject
} as i32,
},
cx,
)
}
fn perform_contact_request<T: RequestMessage>(
&mut self,
user_id: u64,
request: T,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
let client = self.client.upgrade();
*self.pending_contact_requests.entry(user_id).or_insert(0) += 1;
cx.notify();
cx.spawn(|this, mut cx| async move {
let response = client
.ok_or_else(|| anyhow!("can't upgrade client reference"))?
.request(request)
.await;
this.update(&mut cx, |this, cx| {
contacts.sort_by(|a, b| a.user.github_login.cmp(&b.user.github_login));
this.contacts = contacts.into();
if let Entry::Occupied(mut request_count) =
this.pending_contact_requests.entry(user_id)
{
*request_count.get_mut() -= 1;
if *request_count.get() == 0 {
request_count.remove();
}
}
cx.notify();
});
response?;
Ok(())
})
}
pub fn contacts(&self) -> &Arc<[Contact]> {
&self.contacts
pub fn clear_contacts(&mut self) -> impl Future<Output = ()> {
let (tx, mut rx) = postage::barrier::channel();
self.update_contacts_tx
.unbounded_send(UpdateContacts::Clear(tx))
.unwrap();
async move {
rx.recv().await;
}
}
pub fn load_users(
pub fn get_users(
&mut self,
mut user_ids: Vec<u64>,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
let rpc = self.client.clone();
let http = self.http.clone();
user_ids.retain(|id| !self.users.contains_key(id));
cx.spawn_weak(|this, mut cx| async move {
if let Some(rpc) = rpc.upgrade() {
if !user_ids.is_empty() {
let response = rpc.request(proto::GetUsers { user_ids }).await?;
let new_users = future::join_all(
response
.users
.into_iter()
.map(|user| User::new(user, http.as_ref())),
)
.await;
if user_ids.is_empty() {
Task::ready(Ok(()))
} else {
let load = self.load_users(proto::GetUsers { user_ids }, cx);
cx.foreground().spawn(async move {
load.await?;
Ok(())
})
}
}
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, _| {
for user in new_users {
this.users.insert(user.id, Arc::new(user));
}
});
}
}
}
Ok(())
})
pub fn fuzzy_search_users(
&mut self,
query: String,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<Arc<User>>>> {
self.load_users(proto::FuzzySearchUsers { query }, cx)
}
pub fn fetch_user(
@ -186,7 +416,7 @@ impl UserStore {
return cx.foreground().spawn(async move { Ok(user) });
}
let load_users = self.load_users(vec![user_id], cx);
let load_users = self.get_users(vec![user_id], cx);
cx.spawn(|this, mut cx| async move {
load_users.await?;
this.update(&mut cx, |this, _| {
@ -205,15 +435,47 @@ impl UserStore {
pub fn watch_current_user(&self) -> watch::Receiver<Option<Arc<User>>> {
self.current_user.clone()
}
fn load_users(
&mut self,
request: impl RequestMessage<Response = UsersResponse>,
cx: &mut ModelContext<Self>,
) -> Task<Result<Vec<Arc<User>>>> {
let client = self.client.clone();
let http = self.http.clone();
cx.spawn_weak(|this, mut cx| async move {
if let Some(rpc) = client.upgrade() {
let response = rpc.request(request).await.context("error loading users")?;
let users = future::join_all(
response
.users
.into_iter()
.map(|user| User::new(user, http.as_ref())),
)
.await;
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, _| {
for user in &users {
this.users.insert(user.id, user.clone());
}
});
}
Ok(users)
} else {
Ok(Vec::new())
}
})
}
}
impl User {
async fn new(message: proto::User, http: &dyn HttpClient) -> Self {
User {
async fn new(message: proto::User, http: &dyn HttpClient) -> Arc<Self> {
Arc::new(User {
id: message.id,
github_login: message.github_login,
avatar: fetch_avatar(http, &message.avatar_url).warn_on_err().await,
}
})
}
}
@ -247,7 +509,17 @@ impl Contact {
guests,
});
}
Ok(Self { user, projects })
Ok(Self {
user,
online: contact.online,
projects,
})
}
pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
self.projects
.iter()
.filter(|project| !project.worktree_root_names.is_empty())
}
}

View File

@ -22,6 +22,7 @@ async-trait = "0.1.50"
async-tungstenite = "0.16"
axum = { version = "0.5", features = ["json", "headers", "ws"] }
base64 = "0.13"
clap = { version = "3.1", features = ["derive"], optional = true }
envy = "0.4.2"
env_logger = "0.8"
futures = "0.3"
@ -32,6 +33,7 @@ opentelemetry = { version = "0.17", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.10", features = ["tls-roots"] }
parking_lot = "0.11.1"
rand = "0.8"
reqwest = { version = "0.11", features = ["json"], optional = true }
scrypt = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@ -69,4 +71,4 @@ lazy_static = "1.4"
serde_json = { version = "1.0.64", features = ["preserve_order"] }
[features]
seed-support = ["lipsum"]
seed-support = ["clap", "lipsum", "reqwest"]

View File

@ -0,0 +1,2 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX trigram_index_users_on_github_login ON users USING GIN(github_login gin_trgm_ops);

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS "contacts" (
"id" SERIAL PRIMARY KEY,
"user_id_a" INTEGER REFERENCES users (id) NOT NULL,
"user_id_b" INTEGER REFERENCES users (id) NOT NULL,
"a_to_b" BOOLEAN NOT NULL,
"should_notify" BOOLEAN NOT NULL,
"accepted" BOOLEAN NOT NULL
);
CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_id_b");
CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");

View File

@ -1,31 +1,87 @@
use clap::Parser;
use db::{Db, PostgresDb, UserId};
use rand::prelude::*;
use serde::Deserialize;
use std::fmt::Write;
use time::{Duration, OffsetDateTime};
#[allow(unused)]
#[path = "../db.rs"]
mod db;
#[derive(Parser)]
struct Args {
/// Seed users from GitHub.
#[clap(short, long)]
github_users: bool,
}
#[derive(Debug, Deserialize)]
struct GitHubUser {
id: usize,
login: String,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let mut rng = StdRng::from_entropy();
let database_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL env var");
let db = PostgresDb::new(&database_url, 5)
.await
.expect("failed to connect to postgres database");
let zed_users = ["nathansobo", "maxbrunsfeld", "as-cii", "iamnbutler"];
let mut zed_users = vec![
"nathansobo".to_string(),
"maxbrunsfeld".to_string(),
"as-cii".to_string(),
"iamnbutler".to_string(),
"gibusu".to_string(),
"Kethku".to_string(),
];
if args.github_users {
let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
let client = reqwest::Client::new();
let mut last_user_id = None;
for page in 0..20 {
println!("Downloading users from GitHub, page {}", page);
let mut uri = "https://api.github.com/users?per_page=100".to_string();
if let Some(last_user_id) = last_user_id {
write!(&mut uri, "&since={}", last_user_id).unwrap();
}
let response = client
.get(uri)
.bearer_auth(&github_token)
.header("user-agent", "zed")
.send()
.await
.expect("failed to fetch github users");
let users = response
.json::<Vec<GitHubUser>>()
.await
.expect("failed to deserialize github user");
zed_users.extend(users.iter().map(|user| user.login.clone()));
if let Some(last_user) = users.last() {
last_user_id = Some(last_user.id);
} else {
break;
}
}
}
let mut zed_user_ids = Vec::<UserId>::new();
for zed_user in zed_users {
if let Some(user) = db
.get_user_by_github_login(zed_user)
.get_user_by_github_login(&zed_user)
.await
.expect("failed to fetch user")
{
zed_user_ids.push(user.id);
} else {
zed_user_ids.push(
db.create_user(zed_user, true)
db.create_user(&zed_user, true)
.await
.expect("failed to insert user"),
);

View File

@ -1,6 +1,6 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use futures::StreamExt;
use serde::Serialize;
pub use sqlx::postgres::PgPoolOptions as DbOptions;
use sqlx::{types::Uuid, FromRow};
@ -10,11 +10,28 @@ use time::OffsetDateTime;
pub trait Db: Send + Sync {
async fn create_user(&self, github_login: &str, admin: bool) -> Result<UserId>;
async fn get_all_users(&self) -> Result<Vec<User>>;
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>>;
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()>;
async fn destroy_user(&self, id: UserId) -> Result<()>;
async fn get_contacts(&self, id: UserId) -> Result<Contacts>;
async fn send_contact_request(&self, requester_id: UserId, responder_id: UserId) -> Result<()>;
async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()>;
async fn dismiss_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
) -> Result<()>;
async fn respond_to_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
accept: bool,
) -> Result<()>;
async fn create_access_token_hash(
&self,
user_id: UserId,
@ -23,6 +40,7 @@ pub trait Db: Send + Sync {
) -> Result<()>;
async fn get_access_token_hashes(&self, user_id: UserId) -> Result<Vec<String>>;
#[cfg(any(test, feature = "seed-support"))]
async fn find_org_by_slug(&self, slug: &str) -> Result<Option<Org>>;
#[cfg(any(test, feature = "seed-support"))]
async fn create_org(&self, name: &str, slug: &str) -> Result<OrgId>;
@ -31,6 +49,7 @@ pub trait Db: Send + Sync {
#[cfg(any(test, feature = "seed-support"))]
async fn create_org_channel(&self, org_id: OrgId, name: &str) -> Result<ChannelId>;
#[cfg(any(test, feature = "seed-support"))]
async fn get_org_channels(&self, org_id: OrgId) -> Result<Vec<Channel>>;
async fn get_accessible_channels(&self, user_id: UserId) -> Result<Vec<Channel>>;
async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId)
@ -58,6 +77,8 @@ pub trait Db: Send + Sync {
) -> Result<Vec<ChannelMessage>>;
#[cfg(test)]
async fn teardown(&self, url: &str);
#[cfg(test)]
fn as_fake<'a>(&'a self) -> Option<&'a tests::FakeDb>;
}
pub struct PostgresDb {
@ -99,6 +120,23 @@ impl Db for PostgresDb {
Ok(sqlx::query_as(query).fetch_all(&self.pool).await?)
}
async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
let like_string = fuzzy_like_string(name_query);
let query = "
SELECT users.*
FROM users
WHERE github_login ILIKE $1
ORDER BY github_login <-> $2
LIMIT $3
";
Ok(sqlx::query_as(query)
.bind(like_string)
.bind(name_query)
.bind(limit)
.fetch_all(&self.pool)
.await?)
}
async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
let users = self.get_users_by_ids(vec![id]).await?;
Ok(users.into_iter().next())
@ -150,6 +188,188 @@ impl Db for PostgresDb {
.map(drop)?)
}
// contacts
async fn get_contacts(&self, user_id: UserId) -> Result<Contacts> {
let query = "
SELECT user_id_a, user_id_b, a_to_b, accepted, should_notify
FROM contacts
WHERE user_id_a = $1 OR user_id_b = $1;
";
let mut rows = sqlx::query_as::<_, (UserId, UserId, bool, bool, bool)>(query)
.bind(user_id)
.fetch(&self.pool);
let mut current = vec![user_id];
let mut outgoing_requests = Vec::new();
let mut incoming_requests = Vec::new();
while let Some(row) = rows.next().await {
let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
if user_id_a == user_id {
if accepted {
current.push(user_id_b);
} else if a_to_b {
outgoing_requests.push(user_id_b);
} else {
incoming_requests.push(IncomingContactRequest {
requester_id: user_id_b,
should_notify,
});
}
} else {
if accepted {
current.push(user_id_a);
} else if a_to_b {
incoming_requests.push(IncomingContactRequest {
requester_id: user_id_a,
should_notify,
});
} else {
outgoing_requests.push(user_id_a);
}
}
}
current.sort_unstable();
outgoing_requests.sort_unstable();
incoming_requests.sort_unstable();
Ok(Contacts {
current,
outgoing_requests,
incoming_requests,
})
}
async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
(sender_id, receiver_id, true)
} else {
(receiver_id, sender_id, false)
};
let query = "
INSERT into contacts (user_id_a, user_id_b, a_to_b, accepted, should_notify)
VALUES ($1, $2, $3, 'f', 't')
ON CONFLICT (user_id_a, user_id_b) DO UPDATE
SET
accepted = 't'
WHERE
NOT contacts.accepted AND
((contacts.a_to_b = excluded.a_to_b AND contacts.user_id_a = excluded.user_id_b) OR
(contacts.a_to_b != excluded.a_to_b AND contacts.user_id_a = excluded.user_id_a));
";
let result = sqlx::query(query)
.bind(id_a.0)
.bind(id_b.0)
.bind(a_to_b)
.execute(&self.pool)
.await?;
if result.rows_affected() == 1 {
Ok(())
} else {
Err(anyhow!("contact already requested"))
}
}
async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()> {
let (id_a, id_b) = if responder_id < requester_id {
(responder_id, requester_id)
} else {
(requester_id, responder_id)
};
let query = "
DELETE FROM contacts
WHERE user_id_a = $1 AND user_id_b = $2;
";
let result = sqlx::query(query)
.bind(id_a.0)
.bind(id_b.0)
.execute(&self.pool)
.await?;
if result.rows_affected() == 1 {
Ok(())
} else {
Err(anyhow!("no such contact"))
}
}
async fn dismiss_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
) -> Result<()> {
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
(responder_id, requester_id, false)
} else {
(requester_id, responder_id, true)
};
let query = "
UPDATE contacts
SET should_notify = 'f'
WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
";
let result = sqlx::query(query)
.bind(id_a.0)
.bind(id_b.0)
.bind(a_to_b)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
Err(anyhow!("no such contact request"))?;
}
Ok(())
}
async fn respond_to_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
accept: bool,
) -> Result<()> {
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
(responder_id, requester_id, false)
} else {
(requester_id, responder_id, true)
};
let result = if accept {
let query = "
UPDATE contacts
SET accepted = 't', should_notify = 'f'
WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3;
";
sqlx::query(query)
.bind(id_a.0)
.bind(id_b.0)
.bind(a_to_b)
.execute(&self.pool)
.await?
} else {
let query = "
DELETE FROM contacts
WHERE user_id_a = $1 AND user_id_b = $2 AND a_to_b = $3 AND NOT accepted;
";
sqlx::query(query)
.bind(id_a.0)
.bind(id_b.0)
.bind(a_to_b)
.execute(&self.pool)
.await?
};
if result.rows_affected() == 1 {
Ok(())
} else {
Err(anyhow!("no such contact request"))
}
}
// access tokens
async fn create_access_token_hash(
@ -406,12 +626,17 @@ impl Db for PostgresDb {
.await
.log_err();
}
#[cfg(test)]
fn as_fake(&self) -> Option<&tests::FakeDb> {
None
}
}
macro_rules! id_type {
($name:ident) => {
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type, Serialize,
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type, Serialize,
)]
#[sqlx(transparent)]
#[serde(transparent)]
@ -476,6 +701,31 @@ pub struct ChannelMessage {
pub nonce: Uuid,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Contacts {
pub current: Vec<UserId>,
pub incoming_requests: Vec<IncomingContactRequest>,
pub outgoing_requests: Vec<UserId>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct IncomingContactRequest {
pub requester_id: UserId,
pub should_notify: bool,
}
fn fuzzy_like_string(string: &str) -> String {
let mut result = String::with_capacity(string.len() * 2 + 1);
for c in string.chars() {
if c.is_alphanumeric() {
result.push('%');
result.push(c);
}
}
result.push('%');
result
}
#[cfg(test)]
pub mod tests {
use super::*;
@ -640,6 +890,185 @@ pub mod tests {
);
}
#[test]
fn test_fuzzy_like_string() {
assert_eq!(fuzzy_like_string("abcd"), "%a%b%c%d%");
assert_eq!(fuzzy_like_string("x y"), "%x%y%");
assert_eq!(fuzzy_like_string(" z "), "%z%");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_fuzzy_search_users() {
let test_db = TestDb::postgres().await;
let db = test_db.db();
for github_login in [
"California",
"colorado",
"oregon",
"washington",
"florida",
"delaware",
"rhode-island",
] {
db.create_user(github_login, false).await.unwrap();
}
assert_eq!(
fuzzy_search_user_names(db, "clr").await,
&["colorado", "California"]
);
assert_eq!(
fuzzy_search_user_names(db, "ro").await,
&["rhode-island", "colorado", "oregon"],
);
async fn fuzzy_search_user_names(db: &Arc<dyn Db>, query: &str) -> Vec<String> {
db.fuzzy_search_users(query, 10)
.await
.unwrap()
.into_iter()
.map(|user| user.github_login)
.collect::<Vec<_>>()
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_add_contacts() {
for test_db in [
TestDb::postgres().await,
TestDb::fake(Arc::new(gpui::executor::Background::new())),
] {
let db = test_db.db();
let user_1 = db.create_user("user1", false).await.unwrap();
let user_2 = db.create_user("user2", false).await.unwrap();
let user_3 = db.create_user("user3", false).await.unwrap();
// User starts with no contacts
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
Contacts {
current: vec![user_1],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
// User requests a contact. Both users see the pending request.
db.send_contact_request(user_1, user_2).await.unwrap();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
Contacts {
current: vec![user_1],
outgoing_requests: vec![user_2],
incoming_requests: vec![],
},
);
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
Contacts {
current: vec![user_2],
outgoing_requests: vec![],
incoming_requests: vec![IncomingContactRequest {
requester_id: user_1,
should_notify: true
}],
},
);
// User 2 dismisses the contact request notification without accepting or rejecting.
// We shouldn't notify them again.
db.dismiss_contact_request(user_1, user_2)
.await
.unwrap_err();
db.dismiss_contact_request(user_2, user_1).await.unwrap();
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
Contacts {
current: vec![user_2],
outgoing_requests: vec![],
incoming_requests: vec![IncomingContactRequest {
requester_id: user_1,
should_notify: false
}],
},
);
// User can't accept their own contact request
db.respond_to_contact_request(user_1, user_2, true)
.await
.unwrap_err();
// User accepts a contact request. Both users see the contact.
db.respond_to_contact_request(user_2, user_1, true)
.await
.unwrap();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
Contacts {
current: vec![user_1, user_2],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
Contacts {
current: vec![user_1, user_2],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
// Users cannot re-request existing contacts.
db.send_contact_request(user_1, user_2).await.unwrap_err();
db.send_contact_request(user_2, user_1).await.unwrap_err();
// Users send each other concurrent contact requests and
// see that they are immediately accepted.
db.send_contact_request(user_1, user_3).await.unwrap();
db.send_contact_request(user_3, user_1).await.unwrap();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
Contacts {
current: vec![user_1, user_2, user_3],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
assert_eq!(
db.get_contacts(user_3).await.unwrap(),
Contacts {
current: vec![user_1, user_3],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
// User declines a contact request. Both users see that it is gone.
db.send_contact_request(user_2, user_3).await.unwrap();
db.respond_to_contact_request(user_3, user_2, false)
.await
.unwrap();
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
Contacts {
current: vec![user_1, user_2],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
assert_eq!(
db.get_contacts(user_3).await.unwrap(),
Contacts {
current: vec![user_1, user_3],
outgoing_requests: vec![],
incoming_requests: vec![],
},
);
}
}
pub struct TestDb {
pub db: Option<Arc<dyn Db>>,
pub url: String,
@ -690,16 +1119,25 @@ pub mod tests {
pub struct FakeDb {
background: Arc<Background>,
users: Mutex<BTreeMap<UserId, User>>,
next_user_id: Mutex<i32>,
orgs: Mutex<BTreeMap<OrgId, Org>>,
next_org_id: Mutex<i32>,
org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
channels: Mutex<BTreeMap<ChannelId, Channel>>,
next_channel_id: Mutex<i32>,
channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
pub users: Mutex<BTreeMap<UserId, User>>,
pub orgs: Mutex<BTreeMap<OrgId, Org>>,
pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
pub contacts: Mutex<Vec<FakeContact>>,
next_channel_message_id: Mutex<i32>,
next_user_id: Mutex<i32>,
next_org_id: Mutex<i32>,
next_channel_id: Mutex<i32>,
}
#[derive(Debug)]
pub struct FakeContact {
pub requester_id: UserId,
pub responder_id: UserId,
pub accepted: bool,
pub should_notify: bool,
}
impl FakeDb {
@ -716,6 +1154,7 @@ pub mod tests {
channel_memberships: Default::default(),
channel_messages: Default::default(),
next_channel_message_id: Mutex::new(1),
contacts: Default::default(),
}
}
}
@ -749,6 +1188,10 @@ pub mod tests {
unimplemented!()
}
async fn fuzzy_search_users(&self, _: &str, _: u32) -> Result<Vec<User>> {
unimplemented!()
}
async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
Ok(self.get_users_by_ids(vec![id]).await?.into_iter().next())
}
@ -759,8 +1202,13 @@ pub mod tests {
Ok(ids.iter().filter_map(|id| users.get(id).cloned()).collect())
}
async fn get_user_by_github_login(&self, _github_login: &str) -> Result<Option<User>> {
unimplemented!()
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
Ok(self
.users
.lock()
.values()
.find(|user| user.github_login == github_login)
.cloned())
}
async fn set_user_is_admin(&self, _id: UserId, _is_admin: bool) -> Result<()> {
@ -771,6 +1219,122 @@ pub mod tests {
unimplemented!()
}
async fn get_contacts(&self, id: UserId) -> Result<Contacts> {
self.background.simulate_random_delay().await;
let mut current = vec![id];
let mut outgoing_requests = Vec::new();
let mut incoming_requests = Vec::new();
for contact in self.contacts.lock().iter() {
if contact.requester_id == id {
if contact.accepted {
current.push(contact.responder_id);
} else {
outgoing_requests.push(contact.responder_id);
}
} else if contact.responder_id == id {
if contact.accepted {
current.push(contact.requester_id);
} else {
incoming_requests.push(IncomingContactRequest {
requester_id: contact.requester_id,
should_notify: contact.should_notify,
});
}
}
}
current.sort_unstable();
outgoing_requests.sort_unstable();
incoming_requests.sort_unstable();
Ok(Contacts {
current,
outgoing_requests,
incoming_requests,
})
}
async fn send_contact_request(
&self,
requester_id: UserId,
responder_id: UserId,
) -> Result<()> {
let mut contacts = self.contacts.lock();
for contact in contacts.iter_mut() {
if contact.requester_id == requester_id && contact.responder_id == responder_id {
if contact.accepted {
Err(anyhow!("contact already exists"))?;
} else {
Err(anyhow!("contact already requested"))?;
}
}
if contact.responder_id == requester_id && contact.requester_id == responder_id {
if contact.accepted {
Err(anyhow!("contact already exists"))?;
} else {
contact.accepted = true;
return Ok(());
}
}
}
contacts.push(FakeContact {
requester_id,
responder_id,
accepted: false,
should_notify: true,
});
Ok(())
}
async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<()> {
self.contacts.lock().retain(|contact| {
!(contact.requester_id == requester_id && contact.responder_id == responder_id)
});
Ok(())
}
async fn dismiss_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
) -> Result<()> {
let mut contacts = self.contacts.lock();
for contact in contacts.iter_mut() {
if contact.requester_id == requester_id && contact.responder_id == responder_id {
if contact.accepted {
return Err(anyhow!("contact already confirmed"));
}
contact.should_notify = false;
return Ok(());
}
}
Err(anyhow!("no such contact request"))
}
async fn respond_to_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
accept: bool,
) -> Result<()> {
let mut contacts = self.contacts.lock();
for (ix, contact) in contacts.iter_mut().enumerate() {
if contact.requester_id == requester_id && contact.responder_id == responder_id {
if contact.accepted {
return Err(anyhow!("contact already confirmed"));
}
if accept {
contact.accepted = true;
} else {
contacts.remove(ix);
}
return Ok(());
}
}
Err(anyhow!("no such contact request"))
}
async fn create_access_token_hash(
&self,
_user_id: UserId,
@ -965,5 +1529,10 @@ pub mod tests {
}
async fn teardown(&self, _: &str) {}
#[cfg(test)]
fn as_fake(&self) -> Option<&FakeDb> {
Some(self)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
use crate::db::{ChannelId, UserId};
use crate::db::{self, ChannelId, UserId};
use anyhow::{anyhow, Result};
use collections::{BTreeMap, HashMap, HashSet};
use rpc::{proto, ConnectionId};
@ -10,7 +10,6 @@ pub struct Store {
connections: HashMap<ConnectionId, ConnectionState>,
connections_by_user_id: HashMap<UserId, HashSet<ConnectionId>>,
projects: HashMap<u64, Project>,
visible_projects_by_user_id: HashMap<UserId, HashSet<u64>>,
channels: HashMap<ChannelId, Channel>,
next_project_id: u64,
}
@ -30,7 +29,6 @@ pub struct Project {
}
pub struct Worktree {
pub authorized_user_ids: Vec<UserId>,
pub root_name: String,
pub visible: bool,
}
@ -58,6 +56,7 @@ pub type ReplicaId = u16;
#[derive(Default)]
pub struct RemovedConnectionState {
pub user_id: UserId,
pub hosted_projects: HashMap<u64, Project>,
pub guest_project_ids: HashMap<u64, Vec<ConnectionId>>,
pub contact_ids: HashSet<UserId>,
@ -68,18 +67,16 @@ pub struct JoinedProject<'a> {
pub project: &'a Project,
}
pub struct SharedProject {
pub authorized_user_ids: Vec<UserId>,
}
pub struct SharedProject {}
pub struct UnsharedProject {
pub connection_ids: Vec<ConnectionId>,
pub authorized_user_ids: Vec<UserId>,
pub host_user_id: UserId,
}
pub struct LeftProject {
pub connection_ids: Vec<ConnectionId>,
pub authorized_user_ids: Vec<UserId>,
pub host_user_id: UserId,
}
#[derive(Copy, Clone)]
@ -151,15 +148,14 @@ impl Store {
}
let mut result = RemovedConnectionState::default();
result.user_id = connection.user_id;
for project_id in connection.projects.clone() {
if let Ok(project) = self.unregister_project(project_id, connection_id) {
result.contact_ids.extend(project.authorized_user_ids());
result.hosted_projects.insert(project_id, project);
} else if let Ok(project) = self.leave_project(connection_id, project_id) {
result
.guest_project_ids
.insert(project_id, project.connection_ids);
result.contact_ids.extend(project.authorized_user_ids);
}
}
@ -213,51 +209,123 @@ impl Store {
.copied()
}
pub fn contacts_for_user(&self, user_id: UserId) -> Vec<proto::Contact> {
let mut contacts = HashMap::default();
for project_id in self
.visible_projects_by_user_id
pub fn is_user_online(&self, user_id: UserId) -> bool {
!self
.connections_by_user_id
.get(&user_id)
.unwrap_or(&HashSet::default())
{
let project = &self.projects[project_id];
.unwrap_or(&Default::default())
.is_empty()
}
let mut guests = HashSet::default();
if let Ok(share) = project.share() {
for guest_connection_id in share.guests.keys() {
if let Ok(user_id) = self.user_id_for_connection(*guest_connection_id) {
guests.insert(user_id.to_proto());
}
}
}
pub fn build_initial_contacts_update(&self, contacts: db::Contacts) -> proto::UpdateContacts {
let mut update = proto::UpdateContacts::default();
for user_id in contacts.current {
update.contacts.push(self.contact_for_user(user_id));
}
if let Ok(host_user_id) = self.user_id_for_connection(project.host_connection_id) {
let mut worktree_root_names = project
.worktrees
.values()
.filter(|worktree| worktree.visible)
.map(|worktree| worktree.root_name.clone())
.collect::<Vec<_>>();
worktree_root_names.sort_unstable();
contacts
.entry(host_user_id)
.or_insert_with(|| proto::Contact {
user_id: host_user_id.to_proto(),
projects: Vec::new(),
})
.projects
.push(proto::ProjectMetadata {
id: *project_id,
worktree_root_names,
is_shared: project.share.is_some(),
guests: guests.into_iter().collect(),
});
for request in contacts.incoming_requests {
update
.incoming_requests
.push(proto::IncomingContactRequest {
requester_id: request.requester_id.to_proto(),
should_notify: request.should_notify,
})
}
for requested_user_id in contacts.outgoing_requests {
update.outgoing_requests.push(requested_user_id.to_proto())
}
update
}
pub fn contact_for_user(&self, user_id: UserId) -> proto::Contact {
proto::Contact {
user_id: user_id.to_proto(),
projects: self.project_metadata_for_user(user_id),
online: self.is_user_online(user_id),
}
}
pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec<proto::ProjectMetadata> {
let connection_ids = self.connections_by_user_id.get(&user_id);
let project_ids = connection_ids.iter().flat_map(|connection_ids| {
connection_ids
.iter()
.filter_map(|connection_id| self.connections.get(connection_id))
.flat_map(|connection| connection.projects.iter().copied())
});
let mut metadata = Vec::new();
for project_id in project_ids {
if let Some(project) = self.projects.get(&project_id) {
metadata.push(proto::ProjectMetadata {
id: project_id,
is_shared: project.share.is_some(),
worktree_root_names: project
.worktrees
.values()
.map(|worktree| worktree.root_name.clone())
.collect(),
guests: project
.share
.iter()
.flat_map(|share| {
share.guests.values().map(|(_, user_id)| user_id.to_proto())
})
.collect(),
});
}
}
contacts.into_values().collect()
metadata
}
// pub fn contacts_for_user(&self, user_id: UserId) -> Vec<proto::Contact> {
// let mut contacts = HashMap::default();
// for project_id in self
// .visible_projects_by_user_id
// .get(&user_id)
// .unwrap_or(&HashSet::default())
// {
// let project = &self.projects[project_id];
// let mut guests = HashSet::default();
// if let Ok(share) = project.share() {
// for guest_connection_id in share.guests.keys() {
// if let Ok(user_id) = self.user_id_for_connection(*guest_connection_id) {
// guests.insert(user_id.to_proto());
// }
// }
// }
// if let Ok(host_user_id) = self.user_id_for_connection(project.host_connection_id) {
// let mut worktree_root_names = project
// .worktrees
// .values()
// .filter(|worktree| worktree.visible)
// .map(|worktree| worktree.root_name.clone())
// .collect::<Vec<_>>();
// worktree_root_names.sort_unstable();
// contacts
// .entry(host_user_id)
// .or_insert_with(|| proto::Contact {
// user_id: host_user_id.to_proto(),
// projects: Vec::new(),
// })
// .projects
// .push(proto::ProjectMetadata {
// id: *project_id,
// worktree_root_names,
// is_shared: project.share.is_some(),
// guests: guests.into_iter().collect(),
// });
// }
// }
// contacts.into_values().collect()
// }
pub fn register_project(
&mut self,
host_connection_id: ConnectionId,
@ -293,13 +361,6 @@ impl Store {
.get_mut(&project_id)
.ok_or_else(|| anyhow!("no such project"))?;
if project.host_connection_id == connection_id {
for authorized_user_id in &worktree.authorized_user_ids {
self.visible_projects_by_user_id
.entry(*authorized_user_id)
.or_default()
.insert(project_id);
}
project.worktrees.insert(worktree_id, worktree);
if let Ok(share) = project.share_mut() {
share.worktrees.insert(worktree_id, Default::default());
@ -319,14 +380,6 @@ impl Store {
match self.projects.entry(project_id) {
hash_map::Entry::Occupied(e) => {
if e.get().host_connection_id == connection_id {
for user_id in e.get().authorized_user_ids() {
if let hash_map::Entry::Occupied(mut projects) =
self.visible_projects_by_user_id.entry(user_id)
{
projects.get_mut().remove(&project_id);
}
}
let project = e.remove();
if let Some(host_connection) = self.connections.get_mut(&connection_id) {
@ -375,16 +428,6 @@ impl Store {
share.worktrees.remove(&worktree_id);
}
for authorized_user_id in &worktree.authorized_user_ids {
if let Some(visible_projects) =
self.visible_projects_by_user_id.get_mut(authorized_user_id)
{
if !project.has_authorized_user_id(*authorized_user_id) {
visible_projects.remove(&project_id);
}
}
}
Ok((worktree, guest_connection_ids))
}
@ -400,9 +443,7 @@ impl Store {
share.worktrees.insert(*worktree_id, Default::default());
}
project.share = Some(share);
return Ok(SharedProject {
authorized_user_ids: project.authorized_user_ids(),
});
return Ok(SharedProject {});
}
}
Err(anyhow!("no such project"))?
@ -424,7 +465,6 @@ impl Store {
}
let connection_ids = project.connection_ids();
let authorized_user_ids = project.authorized_user_ids();
if let Some(share) = project.share.take() {
for connection_id in share.guests.into_keys() {
if let Some(connection) = self.connections.get_mut(&connection_id) {
@ -434,7 +474,7 @@ impl Store {
Ok(UnsharedProject {
connection_ids,
authorized_user_ids,
host_user_id: project.host_user_id,
})
} else {
Err(anyhow!("project is not shared"))?
@ -498,13 +538,6 @@ impl Store {
let project = self
.projects
.get_mut(&project_id)
.and_then(|project| {
if project.has_authorized_user_id(user_id) {
Some(project)
} else {
None
}
})
.ok_or_else(|| anyhow!("no such project"))?;
let share = project.share_mut()?;
@ -546,12 +579,9 @@ impl Store {
connection.projects.remove(&project_id);
}
let connection_ids = project.connection_ids();
let authorized_user_ids = project.authorized_user_ids();
Ok(LeftProject {
connection_ids,
authorized_user_ids,
connection_ids: project.connection_ids(),
host_user_id: project.host_user_id,
})
}
@ -599,9 +629,10 @@ impl Store {
.connection_ids())
}
#[cfg(test)]
pub fn project(&self, project_id: u64) -> Option<&Project> {
self.projects.get(&project_id)
pub fn project(&self, project_id: u64) -> Result<&Project> {
self.projects
.get(&project_id)
.ok_or_else(|| anyhow!("no such project"))
}
pub fn read_project(&self, project_id: u64, connection_id: ConnectionId) -> Result<&Project> {
@ -701,14 +732,6 @@ impl Store {
let host_connection = self.connections.get(&project.host_connection_id).unwrap();
assert!(host_connection.projects.contains(project_id));
for authorized_user_ids in project.authorized_user_ids() {
let visible_project_ids = self
.visible_projects_by_user_id
.get(&authorized_user_ids)
.unwrap();
assert!(visible_project_ids.contains(project_id));
}
if let Some(share) = &project.share {
for guest_connection_id in share.guests.keys() {
let guest_connection = self.connections.get(guest_connection_id).unwrap();
@ -726,13 +749,6 @@ impl Store {
}
}
for (user_id, visible_project_ids) in &self.visible_projects_by_user_id {
for project_id in visible_project_ids {
let project = self.projects.get(project_id).unwrap();
assert!(project.authorized_user_ids().contains(user_id));
}
}
for (channel_id, channel) in &self.channels {
for connection_id in &channel.connection_ids {
let connection = self.connections.get(connection_id).unwrap();
@ -743,24 +759,6 @@ impl Store {
}
impl Project {
pub fn has_authorized_user_id(&self, user_id: UserId) -> bool {
self.worktrees
.values()
.any(|worktree| worktree.authorized_user_ids.contains(&user_id))
}
pub fn authorized_user_ids(&self) -> Vec<UserId> {
let mut ids = self
.worktrees
.values()
.flat_map(|worktree| worktree.authorized_user_ids.iter())
.copied()
.collect::<Vec<_>>();
ids.sort_unstable();
ids.dedup();
ids
}
pub fn guest_connection_ids(&self) -> Vec<ConnectionId> {
if let Some(share) = &self.share {
share.guests.keys().copied().collect()

View File

@ -9,8 +9,15 @@ doctest = false
[dependencies]
client = { path = "../client" }
editor = { path = "../editor" }
fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" }
picker = { path = "../picker" }
settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
futures = "0.3"
log = "0.4"
postage = { version = "0.4.1", features = ["futures-traits"] }
serde = { version = "1", features = ["derive"] }

View File

@ -0,0 +1,191 @@
use client::{ContactRequestStatus, User, UserStore};
use gpui::{
actions, elements::*, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View,
ViewContext, ViewHandle,
};
use picker::{Picker, PickerDelegate};
use settings::Settings;
use std::sync::Arc;
use util::TryFutureExt;
use workspace::Workspace;
use crate::render_icon_button;
actions!(contact_finder, [Toggle]);
pub fn init(cx: &mut MutableAppContext) {
Picker::<ContactFinder>::init(cx);
cx.add_action(ContactFinder::toggle);
}
pub struct ContactFinder {
picker: ViewHandle<Picker<Self>>,
potential_contacts: Arc<[Arc<User>]>,
user_store: ModelHandle<UserStore>,
selected_index: usize,
}
pub enum Event {
Dismissed,
}
impl Entity for ContactFinder {
type Event = Event;
}
impl View for ContactFinder {
fn ui_name() -> &'static str {
"ContactFinder"
}
fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
ChildView::new(self.picker.clone()).boxed()
}
fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
cx.focus(&self.picker);
}
}
impl PickerDelegate for ContactFinder {
fn match_count(&self) -> usize {
self.potential_contacts.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
self.selected_index = ix;
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
let search_users = self
.user_store
.update(cx, |store, cx| store.fuzzy_search_users(query, cx));
cx.spawn(|this, mut cx| async move {
async {
let potential_contacts = search_users.await?;
this.update(&mut cx, |this, cx| {
this.potential_contacts = potential_contacts.into();
cx.notify();
});
Ok(())
}
.log_err()
.await;
})
}
fn confirm(&mut self, cx: &mut ViewContext<Self>) {
if let Some(user) = self.potential_contacts.get(self.selected_index) {
let user_store = self.user_store.read(cx);
match user_store.contact_request_status(user) {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
self.user_store
.update(cx, |store, cx| store.request_contact(user.id, cx))
.detach();
}
ContactRequestStatus::RequestSent => {
self.user_store
.update(cx, |store, cx| store.remove_contact(user.id, cx))
.detach();
}
_ => {}
}
}
}
fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
cx.emit(Event::Dismissed);
}
fn render_match(
&self,
ix: usize,
mouse_state: &MouseState,
selected: bool,
cx: &gpui::AppContext,
) -> ElementBox {
let theme = &cx.global::<Settings>().theme;
let user = &self.potential_contacts[ix];
let request_status = self.user_store.read(cx).contact_request_status(&user);
let icon_path = match request_status {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
"icons/accept.svg"
}
ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
"icons/reject.svg"
}
};
let button_style = if self.user_store.read(cx).is_contact_request_pending(&user) {
&theme.contact_finder.disabled_contact_button
} else {
&theme.contact_finder.contact_button
};
let style = theme.picker.item.style_for(mouse_state, selected);
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
.with_style(theme.contact_finder.contact_avatar)
.aligned()
.left()
.boxed()
}))
.with_child(
Label::new(user.github_login.clone(), style.label.clone())
.contained()
.with_style(theme.contact_finder.contact_username)
.aligned()
.left()
.boxed(),
)
.with_child(
render_icon_button(button_style, icon_path)
.aligned()
.flex_float()
.boxed(),
)
.contained()
.with_style(style.container)
.constrained()
.with_height(theme.contact_finder.row_height)
.boxed()
}
}
impl ContactFinder {
fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
workspace.toggle_modal(cx, |cx, workspace| {
let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
cx.subscribe(&finder, Self::on_event).detach();
finder
});
}
pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
let this = cx.weak_handle();
Self {
picker: cx.add_view(|cx| Picker::new(this, cx)),
potential_contacts: Arc::from([]),
user_store,
selected_index: 0,
}
}
fn on_event(
workspace: &mut Workspace,
_: ViewHandle<Self>,
event: &Event,
cx: &mut ViewContext<Workspace>,
) {
match event {
Event::Dismissed => {
workspace.dismiss_modal(cx);
}
}
}
}

View File

@ -1,63 +1,149 @@
use client::{Contact, UserStore};
mod contact_finder;
use client::{Contact, User, UserStore};
use editor::{Cancel, Editor};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
elements::*,
geometry::{rect::RectF, vector::vec2f},
impl_actions,
platform::CursorStyle,
Element, ElementBox, Entity, LayoutContext, ModelHandle, RenderContext, Subscription, View,
ViewContext,
Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext,
Subscription, View, ViewContext, ViewHandle,
};
use serde::Deserialize;
use settings::Settings;
use std::sync::Arc;
use theme::IconButton;
use workspace::{AppState, JoinProject};
impl_actions!(
contacts_panel,
[RequestContact, RemoveContact, RespondToContactRequest]
);
#[derive(Debug)]
enum ContactEntry {
Header(&'static str),
IncomingRequest(Arc<User>),
OutgoingRequest(Arc<User>),
Contact(Arc<Contact>),
}
pub struct ContactsPanel {
contacts: ListState,
entries: Vec<ContactEntry>,
match_candidates: Vec<StringMatchCandidate>,
list_state: ListState,
user_store: ModelHandle<UserStore>,
filter_editor: ViewHandle<Editor>,
_maintain_contacts: Subscription,
}
#[derive(Clone, Deserialize)]
pub struct RequestContact(pub u64);
#[derive(Clone, Deserialize)]
pub struct RemoveContact(pub u64);
#[derive(Clone, Deserialize)]
pub struct RespondToContactRequest {
pub user_id: u64,
pub accept: bool,
}
pub fn init(cx: &mut MutableAppContext) {
contact_finder::init(cx);
cx.add_action(ContactsPanel::request_contact);
cx.add_action(ContactsPanel::remove_contact);
cx.add_action(ContactsPanel::respond_to_contact_request);
cx.add_action(ContactsPanel::clear_filter);
}
impl ContactsPanel {
pub fn new(app_state: Arc<AppState>, cx: &mut ViewContext<Self>) -> Self {
Self {
contacts: ListState::new(
app_state.user_store.read(cx).contacts().len(),
Orientation::Top,
1000.,
{
let app_state = app_state.clone();
move |ix, cx| {
let user_store = app_state.user_store.read(cx);
let contacts = user_store.contacts().clone();
let current_user_id = user_store.current_user().map(|user| user.id);
Self::render_collaborator(
&contacts[ix],
let user_query_editor = cx.add_view(|cx| {
let mut editor = Editor::single_line(
Some(|theme| theme.contacts_panel.user_query_editor.clone()),
cx,
);
editor.set_placeholder_text("Filter contacts", cx);
editor
});
cx.subscribe(&user_query_editor, |this, _, event, cx| {
if let editor::Event::BufferEdited = event {
this.update_entries(cx)
}
})
.detach();
let mut this = Self {
list_state: ListState::new(0, Orientation::Top, 1000., {
let this = cx.weak_handle();
let app_state = app_state.clone();
move |ix, cx| {
let this = this.upgrade(cx).unwrap();
let this = this.read(cx);
let theme = cx.global::<Settings>().theme.clone();
let theme = &theme.contacts_panel;
let current_user_id =
this.user_store.read(cx).current_user().map(|user| user.id);
match &this.entries[ix] {
ContactEntry::Header(text) => {
Label::new(text.to_string(), theme.header.text.clone())
.contained()
.aligned()
.left()
.constrained()
.with_height(theme.row_height)
.contained()
.with_style(theme.header.container)
.boxed()
}
ContactEntry::IncomingRequest(user) => Self::render_contact_request(
user.clone(),
this.user_store.clone(),
theme,
true,
cx,
),
ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
user.clone(),
this.user_store.clone(),
theme,
false,
cx,
),
ContactEntry::Contact(contact) => Self::render_contact(
contact.clone(),
current_user_id,
app_state.clone(),
theme,
cx,
)
),
}
},
),
_maintain_contacts: cx.observe(&app_state.user_store, Self::update_contacts),
}
}),
entries: Default::default(),
match_candidates: Default::default(),
filter_editor: user_query_editor,
_maintain_contacts: cx
.observe(&app_state.user_store, |this, _, cx| this.update_entries(cx)),
user_store: app_state.user_store.clone(),
}
};
this.update_entries(cx);
this
}
fn update_contacts(&mut self, _: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) {
self.contacts
.reset(self.user_store.read(cx).contacts().len());
cx.notify();
}
fn render_collaborator(
collaborator: &Contact,
fn render_contact(
contact: Arc<Contact>,
current_user_id: Option<u64>,
app_state: Arc<AppState>,
theme: &theme::ContactsPanel,
cx: &mut LayoutContext,
) -> ElementBox {
let theme = cx.global::<Settings>().theme.clone();
let theme = &theme.contacts_panel;
let project_count = collaborator.projects.len();
let project_count = contact.non_empty_projects().count();
let font_cache = cx.font_cache();
let line_height = theme.unshared_project.name.text.line_height(font_cache);
let cap_height = theme.unshared_project.name.text.cap_height(font_cache);
@ -66,44 +152,42 @@ impl ContactsPanel {
let tree_branch_width = theme.tree_branch_width;
let tree_branch_color = theme.tree_branch_color;
let host_avatar_height = theme
.host_avatar
.contact_avatar
.width
.or(theme.host_avatar.height)
.or(theme.contact_avatar.height)
.unwrap_or(0.);
Flex::column()
.with_child(
Flex::row()
.with_children(collaborator.user.avatar.clone().map(|avatar| {
.with_children(contact.user.avatar.clone().map(|avatar| {
Image::new(avatar)
.with_style(theme.host_avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
.boxed()
}))
.with_child(
Label::new(
collaborator.user.github_login.clone(),
theme.host_username.text.clone(),
contact.user.github_login.clone(),
theme.contact_username.text.clone(),
)
.contained()
.with_style(theme.host_username.container)
.with_style(theme.contact_username.container)
.aligned()
.left()
.boxed(),
)
.constrained()
.with_height(theme.host_row_height)
.with_height(theme.row_height)
.boxed(),
)
.with_children(
collaborator
.projects
.iter()
contact
.non_empty_projects()
.enumerate()
.map(|(ix, project)| {
let project_id = project.id;
Flex::row()
.with_child(
Canvas::new(move |bounds, _, cx| {
@ -145,7 +229,7 @@ impl ContactsPanel {
.boxed(),
)
.with_child({
let is_host = Some(collaborator.user.id) == current_user_id;
let is_host = Some(contact.user.id) == current_user_id;
let is_guest = !is_host
&& project
.guests
@ -199,7 +283,7 @@ impl ContactsPanel {
.boxed()
},
)
.with_cursor_style(if is_host || is_shared {
.with_cursor_style(if !is_host && is_shared {
CursorStyle::PointingHand
} else {
CursorStyle::Arrow
@ -220,8 +304,281 @@ impl ContactsPanel {
.boxed()
}),
)
.contained()
.with_style(theme.row.clone())
.boxed()
}
fn render_contact_request(
user: Arc<User>,
user_store: ModelHandle<UserStore>,
theme: &theme::ContactsPanel,
is_incoming: bool,
cx: &mut LayoutContext,
) -> ElementBox {
enum Reject {}
enum Accept {}
enum Cancel {}
let mut row = Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::new(avatar)
.with_style(theme.contact_avatar)
.aligned()
.left()
.boxed()
}))
.with_child(
Label::new(
user.github_login.clone(),
theme.contact_username.text.clone(),
)
.contained()
.with_style(theme.contact_username.container)
.aligned()
.left()
.boxed(),
);
let user_id = user.id;
let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
if is_incoming {
row.add_children([
MouseEventHandler::new::<Reject, _, _>(user.id as usize, cx, |mouse_state, _| {
let button_style = if is_contact_request_pending {
&theme.disabled_contact_button
} else {
&theme.contact_button.style_for(mouse_state, false)
};
render_icon_button(button_style, "icons/reject.svg")
.aligned()
.flex_float()
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(move |_, cx| {
cx.dispatch_action(RespondToContactRequest {
user_id,
accept: false,
})
})
.flex_float()
.boxed(),
MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |mouse_state, _| {
let button_style = if is_contact_request_pending {
&theme.disabled_contact_button
} else {
&theme.contact_button.style_for(mouse_state, false)
};
render_icon_button(button_style, "icons/accept.svg")
.aligned()
.flex_float()
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(move |_, cx| {
cx.dispatch_action(RespondToContactRequest {
user_id,
accept: true,
})
})
.boxed(),
]);
} else {
row.add_child(
MouseEventHandler::new::<Cancel, _, _>(user.id as usize, cx, |mouse_state, _| {
let button_style = if is_contact_request_pending {
&theme.disabled_contact_button
} else {
&theme.contact_button.style_for(mouse_state, false)
};
render_icon_button(button_style, "icons/reject.svg")
.aligned()
.flex_float()
.boxed()
})
.with_padding(Padding::uniform(2.))
.with_cursor_style(CursorStyle::PointingHand)
.on_click(move |_, cx| cx.dispatch_action(RemoveContact(user_id)))
.flex_float()
.boxed(),
);
}
row.constrained()
.with_height(theme.row_height)
.contained()
.with_style(theme.row)
.boxed()
}
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();
self.entries.clear();
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(),
));
if !matches.is_empty() {
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(),
));
if !matches.is_empty() {
request_entries.extend(
matches.iter().map(|mat| {
ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())
}),
);
}
}
if !request_entries.is_empty() {
self.entries.push(ContactEntry::Header("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 (online_contacts, offline_contacts) = matches
.iter()
.partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
if !online_contacts.is_empty() {
self.entries.push(ContactEntry::Header("Online"));
self.entries.extend(
online_contacts
.into_iter()
.map(|mat| ContactEntry::Contact(contacts[mat.candidate_id].clone())),
);
}
if !offline_contacts.is_empty() {
self.entries.push(ContactEntry::Header("Offline"));
self.entries.extend(
offline_contacts
.into_iter()
.map(|mat| ContactEntry::Contact(contacts[mat.candidate_id].clone())),
);
}
}
self.list_state.reset(self.entries.len());
cx.notify();
}
fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
self.user_store
.update(cx, |store, cx| store.request_contact(request.0, cx))
.detach();
}
fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
self.user_store
.update(cx, |store, cx| store.remove_contact(request.0, 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 clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
self.filter_editor
.update(cx, |editor, cx| editor.set_text("", cx));
}
}
fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
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)
}
pub enum Event {}
@ -236,9 +593,44 @@ impl View for ContactsPanel {
}
fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
let theme = &cx.global::<Settings>().theme.contacts_panel;
Container::new(List::new(self.contacts.clone()).boxed())
.with_style(theme.container)
.boxed()
enum AddContact {}
let theme = cx.global::<Settings>().theme.clone();
let theme = &theme.contacts_panel;
Container::new(
Flex::column()
.with_child(
Flex::row()
.with_child(
ChildView::new(self.filter_editor.clone())
.contained()
.with_style(theme.user_query_editor.container)
.flex(1., true)
.boxed(),
)
.with_child(
MouseEventHandler::new::<AddContact, _, _>(0, cx, |_, _| {
Svg::new("icons/add-contact.svg")
.with_color(theme.add_contact_button.color)
.constrained()
.with_height(12.)
.contained()
.with_style(theme.add_contact_button.container)
.aligned()
.boxed()
})
.with_cursor_style(CursorStyle::PointingHand)
.on_click(|_, cx| cx.dispatch_action(contact_finder::Toggle))
.boxed(),
)
.constrained()
.with_height(theme.user_query_editor_height)
.boxed(),
)
.with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
.boxed(),
)
.with_style(theme.container)
.boxed()
}
}

View File

@ -185,6 +185,18 @@ pub async fn match_strings(
return Default::default();
}
if query.is_empty() {
return candidates
.iter()
.map(|candidate| StringMatch {
candidate_id: candidate.id,
score: 0.,
positions: Default::default(),
string: candidate.string.clone(),
})
.collect();
}
let lowercase_query = query.to_lowercase().chars().collect::<Vec<_>>();
let query = query.chars().collect::<Vec<_>>();
@ -195,7 +207,7 @@ pub async fn match_strings(
let num_cpus = background.num_cpus().min(candidates.len());
let segment_size = (candidates.len() + num_cpus - 1) / num_cpus;
let mut segment_results = (0..num_cpus)
.map(|_| Vec::with_capacity(max_results))
.map(|_| Vec::with_capacity(max_results.min(candidates.len())))
.collect::<Vec<_>>();
background

View File

@ -117,14 +117,15 @@ impl Element for Flex {
) -> (Vector2F, Self::LayoutState) {
let mut total_flex = None;
let mut fixed_space = 0.0;
let mut contains_float = false;
let cross_axis = self.axis.invert();
let mut cross_axis_max: f32 = 0.0;
for child in &mut self.children {
if let Some(flex) = child
.metadata::<FlexParentData>()
.and_then(|metadata| metadata.flex.map(|(flex, _)| flex))
{
let metadata = child.metadata::<FlexParentData>();
contains_float |= metadata.map_or(false, |metadata| metadata.float);
if let Some(flex) = metadata.and_then(|metadata| metadata.flex.map(|(flex, _)| flex)) {
*total_flex.get_or_insert(0.) += flex;
} else {
let child_constraint = match self.axis {
@ -177,6 +178,13 @@ impl Element for Flex {
}
};
if contains_float {
match self.axis {
Axis::Horizontal => size.set_x(size.x().max(constraint.max.x())),
Axis::Vertical => size.set_y(size.y().max(constraint.max.y())),
}
}
if constraint.min.x().is_finite() {
size.set_x(size.x().max(constraint.min.x()));
}
@ -225,7 +233,9 @@ impl Element for Flex {
remaining_space: &mut Self::LayoutState,
cx: &mut PaintContext,
) -> Self::PaintState {
let overflowing = *remaining_space < 0.;
let mut remaining_space = *remaining_space;
let overflowing = remaining_space < 0.;
if overflowing {
cx.scene.push_layer(Some(bounds));
}
@ -240,14 +250,14 @@ impl Element for Flex {
}
for child in &mut self.children {
if *remaining_space > 0. {
if remaining_space > 0. {
if let Some(metadata) = child.metadata::<FlexParentData>() {
if metadata.float {
match self.axis {
Axis::Horizontal => child_origin += vec2f(*remaining_space, 0.0),
Axis::Vertical => child_origin += vec2f(0.0, *remaining_space),
Axis::Horizontal => child_origin += vec2f(remaining_space, 0.0),
Axis::Vertical => child_origin += vec2f(0.0, remaining_space),
}
*remaining_space = 0.;
remaining_space = 0.;
}
}
}
@ -257,6 +267,7 @@ impl Element for Flex {
Axis::Vertical => child_origin += vec2f(0.0, child.size().y()),
}
}
if overflowing {
cx.scene.pop_layer();
}

View File

@ -12,10 +12,10 @@ pub struct AtlasAllocator {
device: Device,
texture_descriptor: TextureDescriptor,
atlases: Vec<Atlas>,
free_atlases: Vec<Atlas>,
last_used_atlas_id: usize,
}
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Debug)]
pub struct AllocId {
pub atlas_id: usize,
alloc_id: etagere::AllocId,
@ -23,15 +23,15 @@ pub struct AllocId {
impl AtlasAllocator {
pub fn new(device: Device, texture_descriptor: TextureDescriptor) -> Self {
let mut me = Self {
let mut this = Self {
device,
texture_descriptor,
atlases: Vec::new(),
free_atlases: Vec::new(),
atlases: vec![],
last_used_atlas_id: 0,
};
let atlas = me.new_atlas(Vector2I::zero());
me.atlases.push(atlas);
me
let atlas = this.new_atlas(Vector2I::zero());
this.atlases.push(atlas);
this
}
pub fn default_atlas_size(&self) -> Vector2I {
@ -42,17 +42,27 @@ impl AtlasAllocator {
}
pub fn allocate(&mut self, requested_size: Vector2I) -> Option<(AllocId, Vector2I)> {
let allocation = self
.atlases
.last_mut()
.unwrap()
let atlas_id = self.last_used_atlas_id;
if let Some((alloc_id, origin)) = self.atlases[atlas_id].allocate(requested_size) {
return Some((AllocId { atlas_id, alloc_id }, origin));
}
for (atlas_id, atlas) in self.atlases.iter_mut().enumerate() {
if atlas_id == self.last_used_atlas_id {
continue;
}
if let Some((alloc_id, origin)) = atlas.allocate(requested_size) {
self.last_used_atlas_id = atlas_id;
return Some((AllocId { atlas_id, alloc_id }, origin));
}
}
let atlas_id = self.atlases.len();
let mut atlas = self.new_atlas(requested_size);
let allocation = atlas
.allocate(requested_size)
.or_else(|| {
let mut atlas = self.new_atlas(requested_size);
let (id, origin) = atlas.allocate(requested_size)?;
self.atlases.push(atlas);
Some((id, origin))
});
.map(|(alloc_id, origin)| (AllocId { atlas_id, alloc_id }, origin));
self.atlases.push(atlas);
if allocation.is_none() {
warn!(
@ -61,13 +71,7 @@ impl AtlasAllocator {
);
}
let (alloc_id, origin) = allocation?;
let id = AllocId {
atlas_id: self.atlases.len() - 1,
alloc_id,
};
Some((id, origin))
allocation
}
pub fn upload(&mut self, size: Vector2I, bytes: &[u8]) -> Option<(AllocId, RectI)> {
@ -80,9 +84,6 @@ impl AtlasAllocator {
pub fn deallocate(&mut self, id: AllocId) {
if let Some(atlas) = self.atlases.get_mut(id.atlas_id) {
atlas.deallocate(id.alloc_id);
if atlas.is_empty() {
self.free_atlases.push(self.atlases.remove(id.atlas_id));
}
}
}
@ -90,7 +91,6 @@ impl AtlasAllocator {
for atlas in &mut self.atlases {
atlas.clear();
}
self.free_atlases.extend(self.atlases.drain(1..));
}
pub fn texture(&self, atlas_id: usize) -> Option<&metal::TextureRef> {
@ -98,28 +98,22 @@ impl AtlasAllocator {
}
fn new_atlas(&mut self, required_size: Vector2I) -> Atlas {
if let Some(i) = self.free_atlases.iter().rposition(|atlas| {
atlas.size().x() >= required_size.x() && atlas.size().y() >= required_size.y()
}) {
self.free_atlases.remove(i)
} else {
let size = self.default_atlas_size().max(required_size);
let texture = if size.x() as u64 > self.texture_descriptor.width()
|| size.y() as u64 > self.texture_descriptor.height()
{
let descriptor = unsafe {
let descriptor_ptr: *mut metal::MTLTextureDescriptor =
msg_send![self.texture_descriptor, copy];
metal::TextureDescriptor::from_ptr(descriptor_ptr)
};
descriptor.set_width(size.x() as u64);
descriptor.set_height(size.y() as u64);
self.device.new_texture(&descriptor)
} else {
self.device.new_texture(&self.texture_descriptor)
let size = self.default_atlas_size().max(required_size);
let texture = if size.x() as u64 > self.texture_descriptor.width()
|| size.y() as u64 > self.texture_descriptor.height()
{
let descriptor = unsafe {
let descriptor_ptr: *mut metal::MTLTextureDescriptor =
msg_send![self.texture_descriptor, copy];
metal::TextureDescriptor::from_ptr(descriptor_ptr)
};
Atlas::new(size, texture)
}
descriptor.set_width(size.x() as u64);
descriptor.set_height(size.y() as u64);
self.device.new_texture(&descriptor)
} else {
self.device.new_texture(&self.texture_descriptor)
};
Atlas::new(size, texture)
}
}
@ -136,11 +130,6 @@ impl Atlas {
}
}
fn size(&self) -> Vector2I {
let size = self.allocator.size();
vec2i(size.width, size.height)
}
fn allocate(&mut self, size: Vector2I) -> Option<(etagere::AllocId, Vector2I)> {
let alloc = self
.allocator
@ -177,10 +166,6 @@ impl Atlas {
self.allocator.deallocate(id);
}
fn is_empty(&self) -> bool {
self.allocator.is_empty()
}
fn clear(&mut self) {
self.allocator.clear();
}

View File

@ -289,10 +289,13 @@ impl LanguageRegistry {
let servers_tx = servers_tx.clone();
cx.background()
.spawn(async move {
fake_server
.receive_notification::<lsp::notification::Initialized>()
.await;
servers_tx.unbounded_send(fake_server).ok();
if fake_server
.try_receive_notification::<lsp::notification::Initialized>()
.await
.is_some()
{
servers_tx.unbounded_send(fake_server).ok();
}
})
.detach();
Ok(server)

View File

@ -647,12 +647,18 @@ impl FakeLanguageServer {
}
pub async fn receive_notification<T: notification::Notification>(&mut self) -> T::Params {
self.try_receive_notification::<T>().await.unwrap()
}
pub async fn try_receive_notification<T: notification::Notification>(
&mut self,
) -> Option<T::Params> {
use futures::StreamExt as _;
loop {
let (method, params) = self.notifications_rx.next().await.unwrap();
let (method, params) = self.notifications_rx.next().await?;
if &method == T::METHOD {
return serde_json::from_str::<T::Params>(&params).unwrap();
return Some(serde_json::from_str::<T::Params>(&params).unwrap());
} else {
log::info!("skipping message in fake language server {:?}", params);
}

View File

@ -376,6 +376,16 @@ impl FakeFs {
.boxed()
}
pub async fn files(&self) -> Vec<PathBuf> {
self.state
.lock()
.await
.entries
.iter()
.filter_map(|(path, entry)| entry.content.as_ref().map(|_| path.clone()))
.collect()
}
async fn simulate_random_delay(&self) {
self.executor
.upgrade()

View File

@ -443,7 +443,7 @@ impl Project {
.map(|peer| peer.user_id)
.collect();
user_store
.update(cx, |user_store, cx| user_store.load_users(user_ids, cx))
.update(cx, |user_store, cx| user_store.get_users(user_ids, cx))
.await?;
let mut collaborators = HashMap::default();
for message in response.collaborators {
@ -6550,7 +6550,7 @@ mod tests {
assert!(results.is_empty());
}
#[gpui::test]
#[gpui::test(iterations = 10)]
async fn test_definition(cx: &mut gpui::TestAppContext) {
let mut language = Language::new(
LanguageConfig {

View File

@ -32,7 +32,6 @@ use postage::{
prelude::{Sink as _, Stream as _},
watch,
};
use serde::Deserialize;
use smol::channel::{self, Sender};
use std::{
any::Any,
@ -64,7 +63,6 @@ pub enum Worktree {
pub struct LocalWorktree {
snapshot: LocalSnapshot,
config: WorktreeConfig,
background_snapshot: Arc<Mutex<LocalSnapshot>>,
last_scan_state_rx: watch::Receiver<ScanState>,
_background_scanner_task: Option<Task<()>>,
@ -143,11 +141,6 @@ struct ShareState {
_maintain_remote_snapshot: Option<Task<Option<()>>>,
}
#[derive(Default, Deserialize)]
struct WorktreeConfig {
collaborators: Vec<String>,
}
pub enum Event {
UpdatedEntries,
}
@ -460,13 +453,6 @@ impl LocalWorktree {
.await
.context("failed to stat worktree path")?;
let mut config = WorktreeConfig::default();
if let Ok(zed_toml) = fs.load(&abs_path.join(".zed.toml")).await {
if let Ok(parsed) = toml::from_str(&zed_toml) {
config = parsed;
}
}
let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded();
let (mut last_scan_state_tx, last_scan_state_rx) = watch::channel_with(ScanState::Scanning);
let tree = cx.add_model(move |cx: &mut ModelContext<Worktree>| {
@ -496,7 +482,6 @@ impl LocalWorktree {
let tree = Self {
snapshot: snapshot.clone(),
config,
background_snapshot: Arc::new(Mutex::new(snapshot)),
last_scan_state_rx,
_background_scanner_task: None,
@ -544,10 +529,6 @@ impl LocalWorktree {
}
}
pub fn authorized_logins(&self) -> Vec<String> {
self.config.collaborators.clone()
}
pub(crate) fn load_buffer(
&mut self,
path: &Path,
@ -879,7 +860,6 @@ impl LocalWorktree {
project_id,
worktree_id: self.id().to_proto(),
root_name: self.root_name().to_string(),
authorized_logins: self.authorized_logins(),
visible: self.visible,
};
let request = client.request(register_message);

View File

@ -319,15 +319,20 @@ mod tests {
.into_iter()
.map(|name| StringMatchCandidate::new(0, name.into()))
.collect::<Vec<_>>();
let matches = fuzzy::match_strings(
&candidates,
&params.query,
true,
100,
&Default::default(),
executor.clone(),
)
.await;
let matches = if params.query.is_empty() {
Vec::new()
} else {
fuzzy::match_strings(
&candidates,
&params.query,
true,
100,
&Default::default(),
executor.clone(),
)
.await
};
Ok(Some(
matches.into_iter().map(|mat| symbol(&mat.string)).collect(),
))

View File

@ -87,12 +87,16 @@ message Envelope {
UpdateContacts update_contacts = 75;
GetUsers get_users = 76;
GetUsersResponse get_users_response = 77;
FuzzySearchUsers fuzzy_search_users = 77;
UsersResponse users_response = 78;
RequestContact request_contact = 79;
RespondToContactRequest respond_to_contact_request = 80;
RemoveContact remove_contact = 81;
Follow follow = 78;
FollowResponse follow_response = 79;
UpdateFollowers update_followers = 80;
Unfollow unfollow = 81;
Follow follow = 82;
FollowResponse follow_response = 83;
UpdateFollowers update_followers = 84;
Unfollow unfollow = 85;
}
}
@ -147,8 +151,7 @@ message RegisterWorktree {
uint64 project_id = 1;
uint64 worktree_id = 2;
string root_name = 3;
repeated string authorized_logins = 4;
bool visible = 5;
bool visible = 4;
}
message UnregisterWorktree {
@ -538,10 +541,33 @@ message GetUsers {
repeated uint64 user_ids = 1;
}
message GetUsersResponse {
message FuzzySearchUsers {
string query = 1;
}
message UsersResponse {
repeated User users = 1;
}
message RequestContact {
uint64 responder_id = 1;
}
message RemoveContact {
uint64 user_id = 1;
}
message RespondToContactRequest {
uint64 requester_id = 1;
ContactRequestResponse response = 2;
}
enum ContactRequestResponse {
Accept = 0;
Reject = 1;
Block = 2;
}
message SendChannelMessage {
uint64 channel_id = 1;
string body = 2;
@ -569,6 +595,16 @@ message GetChannelMessagesResponse {
message UpdateContacts {
repeated Contact contacts = 1;
repeated uint64 remove_contacts = 2;
repeated IncomingContactRequest incoming_requests = 3;
repeated uint64 remove_incoming_requests = 4;
repeated uint64 outgoing_requests = 5;
repeated uint64 remove_outgoing_requests = 6;
}
message IncomingContactRequest {
uint64 requester_id = 1;
bool should_notify = 2;
}
message UpdateDiagnostics {
@ -839,6 +875,7 @@ message ChannelMessage {
message Contact {
uint64 user_id = 1;
repeated ProjectMetadata projects = 2;
bool online = 3;
}
message ProjectMetadata {

67
crates/rpc/src/macros.rs Normal file
View File

@ -0,0 +1,67 @@
#[macro_export]
macro_rules! messages {
($(($name:ident, $priority:ident)),* $(,)?) => {
pub fn build_typed_envelope(sender_id: ConnectionId, envelope: Envelope) -> Option<Box<dyn AnyTypedEnvelope>> {
match envelope.payload {
$(Some(envelope::Payload::$name(payload)) => {
Some(Box::new(TypedEnvelope {
sender_id,
original_sender_id: envelope.original_sender_id.map(PeerId),
message_id: envelope.id,
payload,
}))
}, )*
_ => None
}
}
$(
impl EnvelopedMessage for $name {
const NAME: &'static str = std::stringify!($name);
const PRIORITY: MessagePriority = MessagePriority::$priority;
fn into_envelope(
self,
id: u32,
responding_to: Option<u32>,
original_sender_id: Option<u32>,
) -> Envelope {
Envelope {
id,
responding_to,
original_sender_id,
payload: Some(envelope::Payload::$name(self)),
}
}
fn from_envelope(envelope: Envelope) -> Option<Self> {
if let Some(envelope::Payload::$name(msg)) = envelope.payload {
Some(msg)
} else {
None
}
}
}
)*
};
}
#[macro_export]
macro_rules! request_messages {
($(($request_name:ident, $response_name:ident)),* $(,)?) => {
$(impl RequestMessage for $request_name {
type Response = $response_name;
})*
};
}
#[macro_export]
macro_rules! entity_messages {
($id_field:ident, $($name:ident),* $(,)?) => {
$(impl EntityMessage for $name {
fn remote_entity_id(&self) -> u64 {
self.$id_field
}
})*
};
}

View File

@ -173,7 +173,10 @@ impl Peer {
Err(anyhow!("timed out writing message"))?;
}
}
None => return Ok(()),
None => {
log::info!("outgoing channel closed");
return Ok(())
},
},
incoming = read_message => {
let incoming = incoming.context("received invalid RPC message")?;
@ -181,7 +184,10 @@ impl Peer {
if let proto::Message::Envelope(incoming) = incoming {
match incoming_tx.send(incoming).timeout(RECEIVE_TIMEOUT).await {
Some(Ok(_)) => {},
Some(Err(_)) => return Ok(()),
Some(Err(_)) => {
log::info!("incoming channel closed");
return Ok(())
},
None => Err(anyhow!("timed out processing incoming message"))?,
}
}

View File

@ -1,4 +1,4 @@
use super::{ConnectionId, PeerId, TypedEnvelope};
use super::{entity_messages, messages, request_messages, ConnectionId, PeerId, TypedEnvelope};
use anyhow::{anyhow, Result};
use async_tungstenite::tungstenite::Message as WebSocketMessage;
use futures::{SinkExt as _, StreamExt as _};
@ -73,71 +73,6 @@ impl<T: EnvelopedMessage> AnyTypedEnvelope for TypedEnvelope<T> {
}
}
macro_rules! messages {
($(($name:ident, $priority:ident)),* $(,)?) => {
pub fn build_typed_envelope(sender_id: ConnectionId, envelope: Envelope) -> Option<Box<dyn AnyTypedEnvelope>> {
match envelope.payload {
$(Some(envelope::Payload::$name(payload)) => {
Some(Box::new(TypedEnvelope {
sender_id,
original_sender_id: envelope.original_sender_id.map(PeerId),
message_id: envelope.id,
payload,
}))
}, )*
_ => None
}
}
$(
impl EnvelopedMessage for $name {
const NAME: &'static str = std::stringify!($name);
const PRIORITY: MessagePriority = MessagePriority::$priority;
fn into_envelope(
self,
id: u32,
responding_to: Option<u32>,
original_sender_id: Option<u32>,
) -> Envelope {
Envelope {
id,
responding_to,
original_sender_id,
payload: Some(envelope::Payload::$name(self)),
}
}
fn from_envelope(envelope: Envelope) -> Option<Self> {
if let Some(envelope::Payload::$name(msg)) = envelope.payload {
Some(msg)
} else {
None
}
}
}
)*
};
}
macro_rules! request_messages {
($(($request_name:ident, $response_name:ident)),* $(,)?) => {
$(impl RequestMessage for $request_name {
type Response = $response_name;
})*
};
}
macro_rules! entity_messages {
($id_field:ident, $($name:ident),* $(,)?) => {
$(impl EntityMessage for $name {
fn remote_entity_id(&self) -> u64 {
self.$id_field
}
})*
};
}
messages!(
(Ack, Foreground),
(AddProjectCollaborator, Foreground),
@ -147,6 +82,7 @@ messages!(
(ApplyCompletionAdditionalEditsResponse, Background),
(BufferReloaded, Foreground),
(BufferSaved, Foreground),
(RemoveContact, Foreground),
(ChannelMessageSent, Foreground),
(CreateProjectEntry, Foreground),
(DeleteProjectEntry, Foreground),
@ -155,6 +91,7 @@ messages!(
(FollowResponse, Foreground),
(FormatBuffers, Foreground),
(FormatBuffersResponse, Foreground),
(FuzzySearchUsers, Foreground),
(GetChannelMessages, Foreground),
(GetChannelMessagesResponse, Foreground),
(GetChannels, Foreground),
@ -172,7 +109,7 @@ messages!(
(GetProjectSymbols, Background),
(GetProjectSymbolsResponse, Background),
(GetUsers, Foreground),
(GetUsersResponse, Foreground),
(UsersResponse, Foreground),
(JoinChannel, Foreground),
(JoinChannelResponse, Foreground),
(JoinProject, Foreground),
@ -197,6 +134,8 @@ messages!(
(ReloadBuffersResponse, Foreground),
(RemoveProjectCollaborator, Foreground),
(RenameProjectEntry, Foreground),
(RequestContact, Foreground),
(RespondToContactRequest, Foreground),
(SaveBuffer, Foreground),
(SearchProject, Background),
(SearchProjectResponse, Background),
@ -236,7 +175,8 @@ request_messages!(
(GetDocumentHighlights, GetDocumentHighlightsResponse),
(GetReferences, GetReferencesResponse),
(GetProjectSymbols, GetProjectSymbolsResponse),
(GetUsers, GetUsersResponse),
(FuzzySearchUsers, UsersResponse),
(GetUsers, UsersResponse),
(JoinChannel, JoinChannelResponse),
(JoinProject, JoinProjectResponse),
(OpenBufferById, OpenBufferResponse),
@ -248,6 +188,9 @@ request_messages!(
(RegisterProject, RegisterProjectResponse),
(RegisterWorktree, Ack),
(ReloadBuffers, ReloadBuffersResponse),
(RequestContact, Ack),
(RemoveContact, Ack),
(RespondToContactRequest, Ack),
(RenameProjectEntry, ProjectEntryResponse),
(SaveBuffer, BufferSaved),
(SearchProject, SearchProjectResponse),

View File

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

View File

@ -21,6 +21,7 @@ pub struct Theme {
pub workspace: Workspace,
pub chat_panel: ChatPanel,
pub contacts_panel: ContactsPanel,
pub contact_finder: ContactFinder,
pub project_panel: ProjectPanel,
pub command_palette: CommandPalette,
pub picker: Picker,
@ -234,19 +235,44 @@ pub struct CommandPalette {
pub struct ContactsPanel {
#[serde(flatten)]
pub container: ContainerStyle,
pub host_row_height: f32,
pub host_avatar: ImageStyle,
pub host_username: ContainedText,
pub header: ContainedText,
pub user_query_editor: FieldEditor,
pub user_query_editor_height: f32,
pub add_contact_button: IconButton,
pub row: ContainerStyle,
pub row_height: f32,
pub contact_avatar: ImageStyle,
pub contact_username: ContainedText,
pub contact_button: Interactive<IconButton>,
pub disabled_contact_button: IconButton,
pub tree_branch_width: f32,
pub tree_branch_color: Color,
pub shared_project: WorktreeRow,
pub hovered_shared_project: WorktreeRow,
pub unshared_project: WorktreeRow,
pub hovered_unshared_project: WorktreeRow,
pub shared_project: ProjectRow,
pub hovered_shared_project: ProjectRow,
pub unshared_project: ProjectRow,
pub hovered_unshared_project: ProjectRow,
}
#[derive(Deserialize, Default)]
pub struct WorktreeRow {
pub struct ContactFinder {
pub row_height: f32,
pub contact_avatar: ImageStyle,
pub contact_username: ContainerStyle,
pub contact_button: IconButton,
pub disabled_contact_button: IconButton,
}
#[derive(Deserialize, Default)]
pub struct IconButton {
#[serde(flatten)]
pub container: ContainerStyle,
pub color: Color,
pub icon_width: f32,
pub button_width: f32,
}
#[derive(Deserialize, Default)]
pub struct ProjectRow {
#[serde(flatten)]
pub container: ContainerStyle,
pub height: f32,

View File

@ -106,10 +106,12 @@ impl Sidebar {
.with_cursor_style(CursorStyle::ResizeLeftRight)
.on_drag(move |delta, cx| {
let prev_width = *actual_width.borrow();
match side {
Side::Left => *custom_width.borrow_mut() = 0f32.max(prev_width + delta.x()),
Side::Right => *custom_width.borrow_mut() = 0f32.max(prev_width - delta.x()),
}
*custom_width.borrow_mut() = 0f32
.max(match side {
Side::Left => prev_width + delta.x(),
Side::Right => prev_width - delta.x(),
})
.round();
cx.notify();
})

View File

@ -824,6 +824,10 @@ impl Workspace {
&self.status_bar
}
pub fn user_store(&self) -> &ModelHandle<UserStore> {
&self.user_store
}
pub fn project(&self) -> &ModelHandle<Project> {
&self.project
}
@ -931,7 +935,7 @@ impl Workspace {
})
}
// Returns the model that was toggled closed if it was open
/// Returns the modal that was toggled closed if it was open.
pub fn toggle_modal<V, F>(
&mut self,
cx: &mut ViewContext<Self>,

View File

@ -146,6 +146,7 @@ fn main() {
go_to_line::init(cx);
file_finder::init(cx);
chat_panel::init(cx);
contacts_panel::init(cx);
outline::init(cx);
project_symbols::init(cx);
project_panel::init(cx);

View File

@ -6,4 +6,4 @@ cd crates/collab
# Export contents of .env.toml
eval "$(cargo run --bin dotenv)"
cargo run --package=collab --features seed-support --bin seed
cargo run --package=collab --features seed-support --bin seed -- $@

View File

@ -1,6 +1,7 @@
import Theme from "../themes/theme";
import chatPanel from "./chatPanel";
import { text } from "./components";
import contactFinder from "./contactFinder";
import contactsPanel from "./contactsPanel";
import commandPalette from "./commandPalette";
import editor from "./editor";
@ -24,6 +25,7 @@ export default function app(theme: Theme): Object {
projectPanel: projectPanel(theme),
chatPanel: chatPanel(theme),
contactsPanel: contactsPanel(theme),
contactFinder: contactFinder(theme),
search: search(theme),
breadcrumbs: {
...text(theme, "sans", "secondary"),

View File

@ -0,0 +1,38 @@
import Theme from "../themes/theme";
import picker from "./picker";
import { backgroundColor, iconColor } from "./components";
export default function contactFinder(theme: Theme) {
const contactButton = {
background: backgroundColor(theme, 100),
color: iconColor(theme, "primary"),
iconWidth: 8,
buttonWidth: 16,
cornerRadius: 8,
};
return {
...picker(theme),
rowHeight: 28,
contactAvatar: {
cornerRadius: 10,
width: 18,
},
contactUsername: {
padding: {
left: 8,
},
},
contactButton: {
...contactButton,
hover: {
background: backgroundColor(theme, 100, "hovered")
}
},
disabledContactButton: {
...contactButton,
background: backgroundColor(theme, 100),
color: iconColor(theme, "muted"),
},
}
}

View File

@ -1,8 +1,8 @@
import Theme from "../themes/theme";
import { panel } from "./app";
import { backgroundColor, borderColor, text } from "./components";
import { backgroundColor, border, borderColor, iconColor, player, text } from "./components";
export default function(theme: Theme) {
export default function contactsPanel(theme: Theme) {
const project = {
guestAvatarSpacing: 4,
height: 24,
@ -31,21 +31,68 @@ export default function(theme: Theme) {
},
};
const contactButton = {
background: backgroundColor(theme, 100),
color: iconColor(theme, "primary"),
iconWidth: 8,
buttonWidth: 16,
cornerRadius: 8,
};
return {
...panel,
hostRowHeight: 28,
userQueryEditor: {
background: backgroundColor(theme, 500),
cornerRadius: 6,
text: text(theme, "mono", "primary"),
placeholderText: text(theme, "mono", "placeholder", { size: "sm" }),
selection: player(theme, 1).selection,
border: border(theme, "secondary"),
padding: {
bottom: 4,
left: 8,
right: 8,
top: 4,
},
},
userQueryEditorHeight: 32,
addContactButton: {
margin: { left: 6 },
color: iconColor(theme, "primary"),
buttonWidth: 8,
iconWidth: 8,
},
row: {
padding: { left: 8 },
},
rowHeight: 28,
header: {
...text(theme, "mono", "secondary", { size: "sm" }),
margin: { top: 8 },
},
treeBranchColor: borderColor(theme, "muted"),
treeBranchWidth: 1,
hostAvatar: {
contactAvatar: {
cornerRadius: 10,
width: 18,
},
hostUsername: {
contactUsername: {
...text(theme, "mono", "primary", { size: "sm" }),
padding: {
left: 8,
},
},
contactButton: {
...contactButton,
hover: {
background: backgroundColor(theme, 100, "hovered"),
},
},
disabledContactButton: {
...contactButton,
background: backgroundColor(theme, 100),
color: iconColor(theme, "muted"),
},
project,
sharedProject,
hoveredSharedProject: {