diff --git a/Cargo.lock b/Cargo.lock index 57dcade5b9..60b35ef3c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,9 +936,11 @@ dependencies = [ "futures", "fuzzy", "gpui", + "language", "log", "picker", "postage", + "project", "serde", "settings", "theme", diff --git a/assets/themes/cave-dark.json b/assets/themes/cave-dark.json index cb1208a1db..826206477d 100644 --- a/assets/themes/cave-dark.json +++ b/assets/themes/cave-dark.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#8b8792", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#e2dfe7", + "size": 14, + "background": "#5852605c" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#5852605c" + } + }, + "row_height": 28, "tree_branch_color": "#655f6d", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#7e7887", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#26232a", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#5852603d" + }, + "active": { + "background": "#5852605c" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#5852603d", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#26232a", + "corner_radius": 6, + "hover": { + "background": "#5852603d" }, - "name": { - "family": "Zed Mono", - "color": "#7e7887", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#5852605c" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#7e7887", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/cave-light.json b/assets/themes/cave-light.json index f0b3f5bd43..19158e53b1 100644 --- a/assets/themes/cave-light.json +++ b/assets/themes/cave-light.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#585260", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#26232a", + "size": 14, + "background": "#8b87922e" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#8b87922e" + } + }, + "row_height": 28, "tree_branch_color": "#7e7887", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#655f6d", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#e2dfe7", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#8b87921f" + }, + "active": { + "background": "#8b87922e" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#8b87921f", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#e2dfe7", + "corner_radius": 6, + "hover": { + "background": "#8b87921f" }, - "name": { - "family": "Zed Mono", - "color": "#655f6d", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#8b87922e" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#655f6d", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/dark.json b/assets/themes/dark.json index 9cc3badc81..3adc9ec29e 100644 --- a/assets/themes/dark.json +++ b/assets/themes/dark.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#9c9c9c", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#f1f1f1", + "size": 14, + "background": "#1c1c1c" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#1c1c1c" + } + }, + "row_height": 28, "tree_branch_color": "#404040", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#474747", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#1c1c1c", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#232323" + }, + "active": { + "background": "#2b2b2b" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#232323", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#1c1c1c", + "corner_radius": 6, + "hover": { + "background": "#232323" }, - "name": { - "family": "Zed Mono", - "color": "#474747", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#2b2b2b" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#474747", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/light.json b/assets/themes/light.json index e2563fadad..69bca672f7 100644 --- a/assets/themes/light.json +++ b/assets/themes/light.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#474747", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#2b2b2b", + "size": 14, + "background": "#d5d5d5" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#d5d5d5" + } + }, + "row_height": 28, "tree_branch_color": "#e3e3e3", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#808080", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#f8f8f8", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#eaeaea" + }, + "active": { + "background": "#e3e3e3" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#eaeaea", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#f8f8f8", + "corner_radius": 6, + "hover": { + "background": "#eaeaea" }, - "name": { - "family": "Zed Mono", - "color": "#808080", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#e3e3e3" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#808080", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/solarized-dark.json b/assets/themes/solarized-dark.json index 6e8c405b6c..75e0eb9b7b 100644 --- a/assets/themes/solarized-dark.json +++ b/assets/themes/solarized-dark.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#93a1a1", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#eee8d5", + "size": 14, + "background": "#586e755c" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#586e755c" + } + }, + "row_height": 28, "tree_branch_color": "#657b83", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#839496", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#073642", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#586e753d" + }, + "active": { + "background": "#586e755c" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#586e753d", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#073642", + "corner_radius": 6, + "hover": { + "background": "#586e753d" }, - "name": { - "family": "Zed Mono", - "color": "#839496", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#586e755c" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#839496", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/solarized-light.json b/assets/themes/solarized-light.json index 3f5b26ee56..e88872bdfb 100644 --- a/assets/themes/solarized-light.json +++ b/assets/themes/solarized-light.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#586e75", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#073642", + "size": 14, + "background": "#93a1a12e" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#93a1a12e" + } + }, + "row_height": 28, "tree_branch_color": "#839496", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#657b83", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#eee8d5", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#93a1a11f" + }, + "active": { + "background": "#93a1a12e" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#93a1a11f", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#eee8d5", + "corner_radius": 6, + "hover": { + "background": "#93a1a11f" }, - "name": { - "family": "Zed Mono", - "color": "#657b83", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#93a1a12e" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#657b83", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/sulphurpool-dark.json b/assets/themes/sulphurpool-dark.json index 0f2a868f24..ef136a0e44 100644 --- a/assets/themes/sulphurpool-dark.json +++ b/assets/themes/sulphurpool-dark.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#979db4", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#dfe2f1", + "size": 14, + "background": "#5e66875c" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#5e66875c" + } + }, + "row_height": 28, "tree_branch_color": "#6b7394", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#898ea4", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#293256", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#5e66873d" + }, + "active": { + "background": "#5e66875c" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#5e66873d", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#293256", + "corner_radius": 6, + "hover": { + "background": "#5e66873d" }, - "name": { - "family": "Zed Mono", - "color": "#898ea4", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#5e66875c" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#898ea4", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/assets/themes/sulphurpool-light.json b/assets/themes/sulphurpool-light.json index b9106c62f3..998a540f1b 100644 --- a/assets/themes/sulphurpool-light.json +++ b/assets/themes/sulphurpool-light.json @@ -1249,20 +1249,29 @@ "button_width": 8, "icon_width": 8 }, - "row": { - "padding": { - "left": 8 - } - }, - "row_height": 28, - "header": { + "header_row": { "family": "Zed Mono", "color": "#5e6687", "size": 14, "margin": { "top": 8 + }, + "active": { + "family": "Zed Mono", + "color": "#293256", + "size": 14, + "background": "#979db42e" } }, + "contact_row": { + "padding": { + "left": 8 + }, + "active": { + "background": "#979db42e" + } + }, + "row_height": 28, "tree_branch_color": "#898ea4", "tree_branch_width": 1, "contact_avatar": { @@ -1294,26 +1303,7 @@ "button_width": 16, "corner_radius": 8 }, - "project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#6b7394", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - } - }, - "shared_project": { + "shared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1332,9 +1322,15 @@ "left": 8 }, "background": "#dfe2f1", - "corner_radius": 6 + "corner_radius": 6, + "hover": { + "background": "#979db41f" + }, + "active": { + "background": "#979db42e" + } }, - "hovered_shared_project": { + "unshared_project_row": { "guest_avatar_spacing": 4, "height": 24, "guest_avatar": { @@ -1352,47 +1348,14 @@ "padding": { "left": 8 }, - "background": "#979db41f", - "corner_radius": 6 - }, - "unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 + "background": "#dfe2f1", + "corner_radius": 6, + "hover": { + "background": "#979db41f" }, - "name": { - "family": "Zed Mono", - "color": "#6b7394", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 + "active": { + "background": "#979db42e" } - }, - "hovered_unshared_project": { - "guest_avatar_spacing": 4, - "height": 24, - "guest_avatar": { - "corner_radius": 8, - "width": 14 - }, - "name": { - "family": "Zed Mono", - "color": "#6b7394", - "size": 14, - "margin": { - "right": 6 - } - }, - "padding": { - "left": 8 - }, - "corner_radius": 6 } }, "contact_finder": { diff --git a/crates/contacts_panel/Cargo.toml b/crates/contacts_panel/Cargo.toml index 619bcad338..de49f070b9 100644 --- a/crates/contacts_panel/Cargo.toml +++ b/crates/contacts_panel/Cargo.toml @@ -21,3 +21,8 @@ futures = "0.3" log = "0.4" postage = { version = "0.4.1", features = ["futures-traits"] } serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +language = { path = "../language", features = ["test-support"] } +project = { path = "../project", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs index 5d96a1b0c2..4cc8091a77 100644 --- a/crates/contacts_panel/src/contacts_panel.rs +++ b/crates/contacts_panel/src/contacts_panel.rs @@ -15,6 +15,7 @@ use serde::Deserialize; use settings::Settings; use std::sync::Arc; use theme::IconButton; +use workspace::menu::{SelectNext, SelectPrev}; use workspace::{AppState, JoinProject}; impl_actions!( @@ -22,12 +23,13 @@ impl_actions!( [RequestContact, RemoveContact, RespondToContactRequest] ); -#[derive(Debug)] +#[derive(Clone, Debug)] enum ContactEntry { Header(&'static str), IncomingRequest(Arc), OutgoingRequest(Arc), Contact(Arc), + ContactProject(Arc, usize), } pub struct ContactsPanel { @@ -36,6 +38,7 @@ pub struct ContactsPanel { list_state: ListState, user_store: ModelHandle, filter_editor: ViewHandle, + selection: Option, _maintain_contacts: Subscription, } @@ -57,6 +60,8 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(ContactsPanel::remove_contact); cx.add_action(ContactsPanel::respond_to_contact_request); cx.add_action(ContactsPanel::clear_filter); + cx.add_action(ContactsPanel::select_next); + cx.add_action(ContactsPanel::select_prev); } impl ContactsPanel { @@ -72,6 +77,7 @@ impl ContactsPanel { cx.subscribe(&user_query_editor, |this, _, event, cx| { if let editor::Event::BufferEdited = event { + this.selection.take(); this.update_entries(cx) } }) @@ -88,17 +94,20 @@ impl ContactsPanel { let theme = &theme.contacts_panel; let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id); + let is_selected = this.selection == Some(ix); match &this.entries[ix] { ContactEntry::Header(text) => { - Label::new(text.to_string(), theme.header.text.clone()) + let header_style = + theme.header_row.style_for(&Default::default(), is_selected); + Label::new(text.to_string(), header_style.text.clone()) .contained() .aligned() .left() .constrained() .with_height(theme.row_height) .contained() - .with_style(theme.header.container) + .with_style(header_style.container) .boxed() } ContactEntry::IncomingRequest(user) => Self::render_contact_request( @@ -106,6 +115,7 @@ impl ContactsPanel { this.user_store.clone(), theme, true, + is_selected, cx, ), ContactEntry::OutgoingRequest(user) => Self::render_contact_request( @@ -113,18 +123,36 @@ impl ContactsPanel { this.user_store.clone(), theme, false, + is_selected, cx, ), - ContactEntry::Contact(contact) => Self::render_contact( - contact.clone(), - current_user_id, - app_state.clone(), - theme, - cx, - ), + ContactEntry::Contact(contact) => { + Self::render_contact(contact.clone(), theme, is_selected) + } + ContactEntry::ContactProject(contact, project_ix) => { + let is_last_project_for_contact = + this.entries.get(ix + 1).map_or(true, |next| { + if let ContactEntry::ContactProject(next_contact, _) = next { + next_contact.user.id != contact.user.id + } else { + true + } + }); + Self::render_contact_project( + contact.clone(), + current_user_id, + *project_ix, + app_state.clone(), + theme, + is_last_project_for_contact, + is_selected, + cx, + ) + } } } }), + selection: None, entries: Default::default(), match_candidates: Default::default(), filter_editor: user_query_editor, @@ -137,175 +165,173 @@ impl ContactsPanel { } fn render_contact( + contact: Arc, + theme: &theme::ContactsPanel, + is_selected: bool, + ) -> ElementBox { + Flex::row() + .with_children(contact.user.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left() + .boxed() + })) + .with_child( + Label::new( + contact.user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .boxed(), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .style_for(&Default::default(), is_selected), + ) + .boxed() + } + + fn render_contact_project( contact: Arc, current_user_id: Option, + project_ix: usize, app_state: Arc, theme: &theme::ContactsPanel, + is_last_project: bool, + is_selected: bool, cx: &mut LayoutContext, ) -> ElementBox { - let project_count = contact.non_empty_projects().count(); + let project = &contact.projects[project_ix]; + let project_id = project.id; + 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); - let baseline_offset = theme.unshared_project.name.text.baseline_offset(font_cache) - + (theme.unshared_project.height - line_height) / 2.; - let tree_branch_width = theme.tree_branch_width; - let tree_branch_color = theme.tree_branch_color; let host_avatar_height = theme .contact_avatar .width .or(theme.contact_avatar.height) .unwrap_or(0.); + let row = &theme.unshared_project_row.default; + let line_height = row.name.text.line_height(font_cache); + let cap_height = row.name.text.cap_height(font_cache); + let baseline_offset = + row.name.text.baseline_offset(font_cache) + (row.height - line_height) / 2.; + let tree_branch_width = theme.tree_branch_width; + let tree_branch_color = theme.tree_branch_color; - Flex::column() + Flex::row() .with_child( - Flex::row() - .with_children(contact.user.avatar.clone().map(|avatar| { - Image::new(avatar) - .with_style(theme.contact_avatar) - .aligned() - .left() - .boxed() - })) - .with_child( - Label::new( - contact.user.github_login.clone(), - theme.contact_username.text.clone(), - ) - .contained() - .with_style(theme.contact_username.container) - .aligned() - .left() - .boxed(), - ) - .constrained() - .with_height(theme.row_height) - .boxed(), + Canvas::new(move |bounds, _, cx| { + let start_x = bounds.min_x() + (bounds.width() / 2.) - (tree_branch_width / 2.); + let end_x = bounds.max_x(); + let start_y = bounds.min_y(); + let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); + + cx.scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, start_y), + vec2f( + start_x + tree_branch_width, + if is_last_project { + end_y + } else { + bounds.max_y() + }, + ), + ), + background: Some(tree_branch_color), + border: gpui::Border::default(), + corner_radius: 0., + }); + cx.scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, end_y), + vec2f(end_x, end_y + tree_branch_width), + ), + background: Some(tree_branch_color), + border: gpui::Border::default(), + corner_radius: 0., + }); + }) + .constrained() + .with_width(host_avatar_height) + .boxed(), ) - .with_children( - contact - .non_empty_projects() - .enumerate() - .map(|(ix, project)| { - let project_id = project.id; + .with_child({ + let is_host = Some(contact.user.id) == current_user_id; + let is_guest = !is_host + && project + .guests + .iter() + .any(|guest| Some(guest.id) == current_user_id); + let is_shared = project.is_shared; + let app_state = app_state.clone(); + + MouseEventHandler::new::( + project_id as usize, + cx, + |mouse_state, _| { + let style = if project.is_shared { + &theme.shared_project_row + } else { + &theme.unshared_project_row + } + .style_for(mouse_state, is_selected); Flex::row() .with_child( - Canvas::new(move |bounds, _, cx| { - let start_x = bounds.min_x() + (bounds.width() / 2.) - - (tree_branch_width / 2.); - let end_x = bounds.max_x(); - let start_y = bounds.min_y(); - let end_y = - bounds.min_y() + baseline_offset - (cap_height / 2.); - - cx.scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, start_y), - vec2f( - start_x + tree_branch_width, - if ix + 1 == project_count { - end_y - } else { - bounds.max_y() - }, - ), - ), - background: Some(tree_branch_color), - border: gpui::Border::default(), - corner_radius: 0., - }); - cx.scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, end_y), - vec2f(end_x, end_y + tree_branch_width), - ), - background: Some(tree_branch_color), - border: gpui::Border::default(), - corner_radius: 0., - }); - }) - .constrained() - .with_width(host_avatar_height) + Label::new( + project.worktree_root_names.join(", "), + style.name.text.clone(), + ) + .aligned() + .left() + .contained() + .with_style(style.name.container) .boxed(), ) - .with_child({ - let is_host = Some(contact.user.id) == current_user_id; - let is_guest = !is_host - && project - .guests - .iter() - .any(|guest| Some(guest.id) == current_user_id); - let is_shared = project.is_shared; - let app_state = app_state.clone(); - - MouseEventHandler::new::( - project_id as usize, - cx, - |mouse_state, _| { - let style = match (project.is_shared, mouse_state.hovered) { - (false, false) => &theme.unshared_project, - (false, true) => &theme.hovered_unshared_project, - (true, false) => &theme.shared_project, - (true, true) => &theme.hovered_shared_project, - }; - - Flex::row() - .with_child( - Label::new( - project.worktree_root_names.join(", "), - style.name.text.clone(), - ) - .aligned() - .left() - .contained() - .with_style(style.name.container) - .boxed(), - ) - .with_children(project.guests.iter().filter_map( - |participant| { - participant.avatar.clone().map(|avatar| { - Image::new(avatar) - .with_style(style.guest_avatar) - .aligned() - .left() - .contained() - .with_margin_right( - style.guest_avatar_spacing, - ) - .boxed() - }) - }, - )) - .contained() - .with_style(style.container) - .constrained() - .with_height(style.height) - .boxed() - }, - ) - .with_cursor_style(if !is_host && is_shared { - CursorStyle::PointingHand - } else { - CursorStyle::Arrow + .with_children(project.guests.iter().filter_map(|participant| { + participant.avatar.clone().map(|avatar| { + Image::new(avatar) + .with_style(style.guest_avatar) + .aligned() + .left() + .contained() + .with_margin_right(style.guest_avatar_spacing) + .boxed() }) - .on_click(move |_, cx| { - if !is_host && !is_guest { - cx.dispatch_global_action(JoinProject { - project_id, - app_state: app_state.clone(), - }); - } - }) - .flex(1., true) - .boxed() - }) + })) + .contained() + .with_style(style.container) .constrained() - .with_height(theme.unshared_project.height) + .with_height(style.height) .boxed() - }), - ) - .contained() - .with_style(theme.row.clone()) + }, + ) + .with_cursor_style(if !is_host && is_shared { + CursorStyle::PointingHand + } else { + CursorStyle::Arrow + }) + .on_click(move |_, cx| { + if !is_host && !is_guest { + cx.dispatch_global_action(JoinProject { + project_id, + app_state: app_state.clone(), + }); + } + }) + .flex(1., true) + .boxed() + }) + .constrained() + .with_height(row.height) .boxed() } @@ -314,6 +340,7 @@ impl ContactsPanel { user_store: ModelHandle, theme: &theme::ContactsPanel, is_incoming: bool, + is_selected: bool, cx: &mut LayoutContext, ) -> ElementBox { enum Reject {} @@ -409,7 +436,11 @@ impl ContactsPanel { row.constrained() .with_height(theme.row_height) .contained() - .with_style(theme.row) + .with_style( + *theme + .contact_row + .style_for(&Default::default(), is_selected), + ) .boxed() } @@ -418,6 +449,7 @@ impl ContactsPanel { let query = self.filter_editor.read(cx).text(cx); let executor = cx.background().clone(); + let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); self.entries.clear(); let mut request_entries = Vec::new(); @@ -443,13 +475,11 @@ impl ContactsPanel { &Default::default(), executor.clone(), )); - if !matches.is_empty() { - request_entries.extend( - matches.iter().map(|mat| { - ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone()) - }), - ); - } + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), + ); } let outgoing = user_store.outgoing_contact_requests(); @@ -474,13 +504,11 @@ impl ContactsPanel { &Default::default(), executor.clone(), )); - if !matches.is_empty() { - request_entries.extend( - matches.iter().map(|mat| { - ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone()) - }), - ); - } + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), + ); } if !request_entries.is_empty() { @@ -515,22 +543,33 @@ impl ContactsPanel { .iter() .partition::, _>(|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())), - ); + for (matches, name) in [(online_contacts, "Online"), (offline_contacts, "Offline")] { + if !matches.is_empty() { + self.entries.push(ContactEntry::Header(name)); + for mat in matches { + let contact = &contacts[mat.candidate_id]; + self.entries.push(ContactEntry::Contact(contact.clone())); + self.entries + .extend(contact.projects.iter().enumerate().filter_map( + |(ix, project)| { + if project.worktree_root_names.is_empty() { + None + } else { + Some(ContactEntry::ContactProject(contact.clone(), ix)) + } + }, + )); + } + } } + } - 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())), - ); + if let Some(selection) = &mut self.selection { + for (ix, entry) in self.entries.iter().enumerate() { + if Some(entry) == prev_selected_entry.as_ref() { + *selection = ix; + break; + } } } @@ -566,6 +605,30 @@ impl ContactsPanel { self.filter_editor .update(cx, |editor, cx| editor.set_text("", cx)); } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(ix) = self.selection { + if self.entries.len() > ix + 1 { + self.selection = Some(ix + 1); + } + } else if !self.entries.is_empty() { + self.selection = Some(0); + } + cx.notify(); + self.list_state.reset(self.entries.len()); + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(ix) = self.selection { + if ix > 0 { + self.selection = Some(ix - 1); + } else { + self.selection = None; + } + } + cx.notify(); + self.list_state.reset(self.entries.len()); + } } fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { @@ -633,4 +696,249 @@ impl View for ContactsPanel { .with_style(theme.container) .boxed() } + + fn on_focus(&mut self, cx: &mut ViewContext) { + cx.focus(&self.filter_editor); + } + + fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context { + let mut cx = Self::default_keymap_context(); + cx.set.insert("menu".into()); + cx + } +} + +impl PartialEq for ContactEntry { + fn eq(&self, other: &Self) -> bool { + match self { + ContactEntry::Header(name_1) => { + if let ContactEntry::Header(name_2) = other { + return name_1 == name_2; + } + } + ContactEntry::IncomingRequest(user_1) => { + if let ContactEntry::IncomingRequest(user_2) = other { + return user_1.id == user_2.id; + } + } + ContactEntry::OutgoingRequest(user_1) => { + if let ContactEntry::OutgoingRequest(user_2) = other { + return user_1.id == user_2.id; + } + } + ContactEntry::Contact(contact_1) => { + if let ContactEntry::Contact(contact_2) = other { + return contact_1.user.id == contact_2.user.id; + } + } + ContactEntry::ContactProject(contact_1, ix_1) => { + if let ContactEntry::ContactProject(contact_2, ix_2) = other { + return contact_1.user.id == contact_2.user.id && ix_1 == ix_2; + } + } + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use client::{proto, test::FakeServer, ChannelList, Client}; + use gpui::TestAppContext; + use language::LanguageRegistry; + use theme::ThemeRegistry; + + #[gpui::test] + async fn test_contact_panel(cx: &mut TestAppContext) { + let (app_state, server) = init(cx).await; + let panel = cx.add_view(0, |cx| ContactsPanel::new(app_state.clone(), cx)); + + let get_users_request = server.receive::().await.unwrap(); + server + .respond( + get_users_request.receipt(), + proto::UsersResponse { + users: [ + "user_zero", + "user_one", + "user_two", + "user_three", + "user_four", + "user_five", + ] + .into_iter() + .enumerate() + .map(|(id, name)| proto::User { + id: id as u64, + github_login: name.to_string(), + ..Default::default() + }) + .collect(), + }, + ) + .await; + + server.send(proto::UpdateContacts { + incoming_requests: vec![proto::IncomingContactRequest { + requester_id: 1, + should_notify: false, + }], + outgoing_requests: vec![2], + contacts: vec![ + proto::Contact { + user_id: 3, + online: true, + projects: vec![proto::ProjectMetadata { + id: 101, + worktree_root_names: vec!["dir1".to_string()], + is_shared: true, + guests: vec![2], + }], + }, + proto::Contact { + user_id: 4, + online: true, + projects: vec![proto::ProjectMetadata { + id: 102, + worktree_root_names: vec!["dir2".to_string()], + is_shared: true, + guests: vec![2], + }], + }, + proto::Contact { + user_id: 5, + online: false, + projects: vec![], + }, + ], + ..Default::default() + }); + + cx.foreground().run_until_parked(); + assert_eq!( + render_to_strings(&panel, cx), + &[ + "+", + "v Requests", + " incoming user_one <=== selected", + " outgoing user_two", + "v Online", + " user_four", + " dir2", + " user_three", + " dir1", + "v Offline", + " user_five", + ] + ); + + panel.update(cx, |panel, cx| { + panel + .filter_editor + .update(cx, |editor, cx| editor.set_text("f", cx)) + }); + cx.foreground().run_until_parked(); + assert_eq!( + render_to_strings(&panel, cx), + &[ + "+", + "Online", + " user_four <=== selected", + " dir2", + "Offline", + " user_five", + ] + ); + + panel.update(cx, |panel, cx| { + panel.select_next(&Default::default(), cx); + }); + assert_eq!( + render_to_strings(&panel, cx), + &[ + "+", + "Online", + " user_four", + " dir2 <=== selected", + "Offline", + " user_five", + ] + ); + + panel.update(cx, |panel, cx| { + panel.select_next(&Default::default(), cx); + }); + assert_eq!( + render_to_strings(&panel, cx), + &[ + "+", + "Online", + " user_four", + " dir2", + "Offline", + " user_five <=== selected", + ] + ); + } + + fn render_to_strings(panel: &ViewHandle, cx: &TestAppContext) -> Vec { + panel.read_with(cx, |panel, _| { + let mut entries = Vec::new(); + entries.push("+".to_string()); + entries.extend(panel.entries.iter().map(|entry| match entry { + ContactEntry::Header(name) => { + format!("{}", name) + } + ContactEntry::IncomingRequest(user) => { + format!(" incoming {}", user.github_login) + } + ContactEntry::OutgoingRequest(user) => { + format!(" outgoing {}", user.github_login) + } + ContactEntry::Contact(contact) => { + format!(" {}", contact.user.github_login) + } + ContactEntry::ContactProject(contact, project_ix) => { + format!( + " {}", + contact.projects[*project_ix].worktree_root_names.join(", ") + ) + } + })); + entries + }) + } + + async fn init(cx: &mut TestAppContext) -> (Arc, FakeServer) { + cx.update(|cx| cx.set_global(Settings::test(cx))); + let themes = ThemeRegistry::new((), cx.font_cache()); + let fs = project::FakeFs::new(cx.background().clone()); + let languages = Arc::new(LanguageRegistry::test()); + let http_client = client::test::FakeHttpClient::with_404_response(); + let mut client = Client::new(http_client.clone()); + let server = FakeServer::for_client(100, &mut client, &cx).await; + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); + let channel_list = + cx.add_model(|cx| ChannelList::new(user_store.clone(), client.clone(), cx)); + + let get_channels = server.receive::().await.unwrap(); + server + .respond(get_channels.receipt(), Default::default()) + .await; + + ( + Arc::new(AppState { + languages, + themes, + client, + user_store: user_store.clone(), + fs, + channel_list, + build_window_options: || unimplemented!(), + build_workspace: |_, _, _| unimplemented!(), + }), + server, + ) + } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 72db11c493..c55fe0ae5f 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -235,11 +235,13 @@ pub struct CommandPalette { pub struct ContactsPanel { #[serde(flatten)] pub container: ContainerStyle, - pub header: ContainedText, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, pub add_contact_button: IconButton, - pub row: ContainerStyle, + pub header_row: Interactive, + pub contact_row: Interactive, + pub shared_project_row: Interactive, + pub unshared_project_row: Interactive, pub row_height: f32, pub contact_avatar: ImageStyle, pub contact_username: ContainedText, @@ -247,10 +249,6 @@ pub struct ContactsPanel { pub disabled_contact_button: IconButton, pub tree_branch_width: f32, pub tree_branch_color: Color, - pub shared_project: ProjectRow, - pub hovered_shared_project: ProjectRow, - pub unshared_project: ProjectRow, - pub hovered_unshared_project: ProjectRow, } #[derive(Deserialize, Default)] diff --git a/styles/src/styleTree/contactsPanel.ts b/styles/src/styleTree/contactsPanel.ts index 3cc0f35c3e..69c62b5c02 100644 --- a/styles/src/styleTree/contactsPanel.ts +++ b/styles/src/styleTree/contactsPanel.ts @@ -21,16 +21,6 @@ export default function contactsPanel(theme: Theme) { }, }; - const sharedProject = { - ...project, - background: backgroundColor(theme, 300), - cornerRadius: 6, - name: { - ...project.name, - ...text(theme, "mono", "secondary", { size: "sm" }), - }, - }; - const contactButton = { background: backgroundColor(theme, 100), color: iconColor(theme, "primary"), @@ -62,14 +52,21 @@ export default function contactsPanel(theme: Theme) { buttonWidth: 8, iconWidth: 8, }, - row: { - padding: { left: 8 }, - }, - rowHeight: 28, - header: { + headerRow: { ...text(theme, "mono", "secondary", { size: "sm" }), margin: { top: 8 }, + active: { + ...text(theme, "mono", "primary", { size: "sm" }), + background: backgroundColor(theme, 100, "active"), + } }, + contactRow: { + padding: { left: 8 }, + active: { + background: backgroundColor(theme, 100, "active"), + } + }, + rowHeight: 28, treeBranchColor: borderColor(theme, "muted"), treeBranchWidth: 1, contactAvatar: { @@ -93,17 +90,35 @@ export default function contactsPanel(theme: Theme) { background: backgroundColor(theme, 100), color: iconColor(theme, "muted"), }, - project, - sharedProject, - hoveredSharedProject: { - ...sharedProject, - background: backgroundColor(theme, 300, "hovered"), - cornerRadius: 6, - }, - unsharedProject: project, - hoveredUnsharedProject: { + sharedProjectRow: { ...project, + background: backgroundColor(theme, 300), cornerRadius: 6, + name: { + ...project.name, + ...text(theme, "mono", "secondary", { size: "sm" }), + }, + hover: { + background: backgroundColor(theme, 300, "hovered"), + }, + active: { + background: backgroundColor(theme, 300, "active"), + } }, + unsharedProjectRow: { + ...project, + background: backgroundColor(theme, 300), + cornerRadius: 6, + name: { + ...project.name, + ...text(theme, "mono", "secondary", { size: "sm" }), + }, + hover: { + background: backgroundColor(theme, 300, "hovered"), + }, + active: { + background: backgroundColor(theme, 300, "active"), + } + } } }