Compare commits
5 Commits
v0.204.1
...
run-comman
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfab93eb15 | ||
|
|
a1a6031c6a | ||
|
|
2d20b5d850 | ||
|
|
11ad0b5793 | ||
|
|
2755cd8ec7 |
21
Cargo.lock
generated
21
Cargo.lock
generated
@@ -196,9 +196,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.2.0-alpha.8"
|
||||
version = "0.2.0-alpha.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08539e8d6b2ccca6cd00afdd42211698f7677adef09108a09414c11f1f45fdaf"
|
||||
checksum = "6d02292efd75080932b6466471d428c70e2ac06908ae24792fc7c36ecbaf67ca"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-broadcast",
|
||||
@@ -301,7 +301,6 @@ dependencies = [
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"gpui_tokio",
|
||||
"http_client",
|
||||
"indoc",
|
||||
"language",
|
||||
"language_model",
|
||||
@@ -356,7 +355,6 @@ dependencies = [
|
||||
"agent_settings",
|
||||
"ai_onboarding",
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"assistant_context",
|
||||
"assistant_slash_command",
|
||||
"assistant_slash_commands",
|
||||
@@ -484,7 +482,6 @@ dependencies = [
|
||||
"client",
|
||||
"cloud_llm_client",
|
||||
"component",
|
||||
"feature_flags",
|
||||
"gpui",
|
||||
"language_model",
|
||||
"serde",
|
||||
@@ -2885,9 +2882,11 @@ dependencies = [
|
||||
"language",
|
||||
"log",
|
||||
"postage",
|
||||
"rand 0.9.1",
|
||||
"release_channel",
|
||||
"rpc",
|
||||
"settings",
|
||||
"sum_tree",
|
||||
"text",
|
||||
"time",
|
||||
"util",
|
||||
@@ -3375,10 +3374,12 @@ dependencies = [
|
||||
"collections",
|
||||
"db",
|
||||
"editor",
|
||||
"emojis",
|
||||
"futures 0.3.31",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"language",
|
||||
"log",
|
||||
"menu",
|
||||
"notifications",
|
||||
@@ -3386,6 +3387,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"release_channel",
|
||||
"rich_text",
|
||||
"rpc",
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -5042,6 +5044,7 @@ dependencies = [
|
||||
"multi_buffer",
|
||||
"ordered-float 2.10.1",
|
||||
"parking_lot",
|
||||
"postage",
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"rand 0.9.1",
|
||||
@@ -14350,9 +14353,9 @@ name = "scheduler"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"backtrace",
|
||||
"chrono",
|
||||
"futures 0.3.31",
|
||||
"parking",
|
||||
"parking_lot",
|
||||
"rand 0.9.1",
|
||||
"workspace-hack",
|
||||
@@ -14873,7 +14876,6 @@ dependencies = [
|
||||
"editor",
|
||||
"feature_flags",
|
||||
"gpui",
|
||||
"menu",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
@@ -15872,11 +15874,9 @@ dependencies = [
|
||||
"editor",
|
||||
"file_icons",
|
||||
"gpui",
|
||||
"project",
|
||||
"ui",
|
||||
"workspace",
|
||||
"workspace-hack",
|
||||
"worktree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -20373,7 +20373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.204.1"
|
||||
version = "0.204.0"
|
||||
dependencies = [
|
||||
"acp_tools",
|
||||
"activity_indicator",
|
||||
@@ -20464,6 +20464,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"paths",
|
||||
"picker",
|
||||
"postage",
|
||||
"pretty_assertions",
|
||||
"profiling",
|
||||
"project",
|
||||
|
||||
@@ -433,7 +433,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
||||
# External crates
|
||||
#
|
||||
|
||||
agent-client-protocol = { version = "0.2.0-alpha.8", features = ["unstable"] }
|
||||
agent-client-protocol = { version = "0.2.0-alpha.6", features = ["unstable"]}
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
@@ -460,7 +460,6 @@ aws-sdk-bedrockruntime = { version = "1.80.0", features = [
|
||||
] }
|
||||
aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
|
||||
aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
|
||||
backtrace = "0.3"
|
||||
base64 = "0.22"
|
||||
bincode = "1.2.1"
|
||||
bitflags = "2.6.0"
|
||||
@@ -568,6 +567,7 @@ objc2-foundation = { version = "0.3", default-features = false, features = [
|
||||
open = "5.0.0"
|
||||
ordered-float = "2.1.1"
|
||||
palette = { version = "0.7.5", default-features = false, features = ["std"] }
|
||||
parking = "2.0"
|
||||
parking_lot = "0.12.1"
|
||||
partial-json-fixer = "0.5.3"
|
||||
parse_int = "0.9"
|
||||
|
||||
@@ -247,10 +247,7 @@
|
||||
"ctrl-shift-e": "project_panel::ToggleFocus",
|
||||
"ctrl-shift-enter": "agent::ContinueThread",
|
||||
"super-ctrl-b": "agent::ToggleBurnMode",
|
||||
"alt-enter": "agent::ContinueWithBurnMode",
|
||||
"ctrl-y": "agent::AllowOnce",
|
||||
"ctrl-alt-y": "agent::AllowAlways",
|
||||
"ctrl-d": "agent::RejectOnce"
|
||||
"alt-enter": "agent::ContinueWithBurnMode"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -331,12 +328,6 @@
|
||||
"enter": "agent::AcceptSuggestedContext"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AcpThread > ModeSelector",
|
||||
"bindings": {
|
||||
"ctrl-enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AcpThread > Editor && !use_modifier_to_send",
|
||||
"use_key_equivalents": true,
|
||||
@@ -354,8 +345,7 @@
|
||||
"ctrl-enter": "agent::Chat",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector"
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -496,8 +486,8 @@
|
||||
"alt-down": "editor::MoveLineDown",
|
||||
"ctrl-alt-shift-up": "editor::DuplicateLineUp",
|
||||
"ctrl-alt-shift-down": "editor::DuplicateLineDown",
|
||||
"alt-shift-right": "editor::SelectLargerSyntaxNode", // Expand selection
|
||||
"alt-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink selection
|
||||
"alt-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection
|
||||
"alt-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection
|
||||
"ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection
|
||||
"ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word
|
||||
"ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && !agent_diff && !AgentPanel",
|
||||
"context": "Editor && !agent_diff",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-alt-z": "git::Restore",
|
||||
@@ -286,10 +286,7 @@
|
||||
"cmd-shift-e": "project_panel::ToggleFocus",
|
||||
"cmd-ctrl-b": "agent::ToggleBurnMode",
|
||||
"cmd-shift-enter": "agent::ContinueThread",
|
||||
"alt-enter": "agent::ContinueWithBurnMode",
|
||||
"cmd-y": "agent::AllowOnce",
|
||||
"cmd-alt-y": "agent::AllowAlways",
|
||||
"cmd-d": "agent::RejectOnce"
|
||||
"alt-enter": "agent::ContinueWithBurnMode"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -381,12 +378,6 @@
|
||||
"ctrl--": "pane::GoBack"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AcpThread > ModeSelector",
|
||||
"bindings": {
|
||||
"cmd-enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AcpThread > Editor && !use_modifier_to_send",
|
||||
"use_key_equivalents": true,
|
||||
@@ -394,8 +385,7 @@
|
||||
"enter": "agent::Chat",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector"
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -405,8 +395,7 @@
|
||||
"cmd-enter": "agent::Chat",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector"
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -547,10 +536,8 @@
|
||||
"alt-down": "editor::MoveLineDown",
|
||||
"alt-shift-up": "editor::DuplicateLineUp",
|
||||
"alt-shift-down": "editor::DuplicateLineDown",
|
||||
"cmd-ctrl-left": "editor::SelectSmallerSyntaxNode", // Shrink selection
|
||||
"cmd-ctrl-right": "editor::SelectLargerSyntaxNode", // Expand selection
|
||||
"cmd-ctrl-up": "editor::SelectPreviousSyntaxNode", // Move selection up
|
||||
"cmd-ctrl-down": "editor::SelectNextSyntaxNode", // Move selection down
|
||||
"ctrl-shift-right": "editor::SelectLargerSyntaxNode", // Expand Selection
|
||||
"ctrl-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection
|
||||
"cmd-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand
|
||||
"cmd-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection
|
||||
"cmd-f2": "editor::SelectAllMatches", // Select all occurrences of current word
|
||||
|
||||
@@ -249,10 +249,7 @@
|
||||
"ctrl-shift-e": "project_panel::ToggleFocus",
|
||||
"ctrl-shift-enter": "agent::ContinueThread",
|
||||
"super-ctrl-b": "agent::ToggleBurnMode",
|
||||
"alt-enter": "agent::ContinueWithBurnMode",
|
||||
"ctrl-y": "agent::AllowOnce",
|
||||
"ctrl-alt-y": "agent::AllowAlways",
|
||||
"ctrl-d": "agent::RejectOnce"
|
||||
"alt-enter": "agent::ContinueWithBurnMode"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -339,12 +336,6 @@
|
||||
"enter": "agent::AcceptSuggestedContext"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AcpThread > ModeSelector",
|
||||
"bindings": {
|
||||
"ctrl-enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AcpThread > Editor",
|
||||
"use_key_equivalents": true,
|
||||
@@ -352,8 +343,7 @@
|
||||
"enter": "agent::Chat",
|
||||
"ctrl-shift-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll",
|
||||
"shift-tab": "agent::CycleModeSelector"
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -495,10 +485,8 @@
|
||||
"alt-down": "editor::MoveLineDown",
|
||||
"shift-alt-up": "editor::DuplicateLineUp",
|
||||
"shift-alt-down": "editor::DuplicateLineDown",
|
||||
"shift-alt-right": "editor::SelectLargerSyntaxNode", // Expand selection
|
||||
"shift-alt-left": "editor::SelectSmallerSyntaxNode", // Shrink selection
|
||||
"ctrl-shift-right": "editor::SelectLargerSyntaxNode", // Expand selection (VSCode version)
|
||||
"ctrl-shift-left": "editor::SelectSmallerSyntaxNode", // Shrink selection (VSCode version)
|
||||
"shift-alt-right": "editor::SelectLargerSyntaxNode", // Expand Selection
|
||||
"shift-alt-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection
|
||||
"ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection
|
||||
"ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word
|
||||
"ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand
|
||||
|
||||
@@ -860,11 +860,11 @@
|
||||
"j": "menu::SelectNext",
|
||||
"k": "menu::SelectPrevious",
|
||||
"l": "project_panel::ExpandSelectedEntry",
|
||||
"o": "project_panel::OpenPermanent",
|
||||
"shift-d": "project_panel::Delete",
|
||||
"shift-r": "project_panel::Rename",
|
||||
"t": "project_panel::OpenPermanent",
|
||||
"v": "project_panel::OpenSplitVertical",
|
||||
"o": "project_panel::OpenSplitHorizontal",
|
||||
"v": "project_panel::OpenPermanent",
|
||||
"p": "project_panel::Open",
|
||||
"x": "project_panel::RevealInFileManager",
|
||||
"s": "workspace::OpenWithSystem",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"project_name": null,
|
||||
// The name of the Zed theme to use for the UI.
|
||||
//
|
||||
// `mode` is one of:
|
||||
@@ -741,6 +740,16 @@
|
||||
// Default width of the collaboration panel.
|
||||
"default_width": 240
|
||||
},
|
||||
"chat_panel": {
|
||||
// When to show the chat panel button in the status bar.
|
||||
// Can be 'never', 'always', or 'when_in_call',
|
||||
// or a boolean (interpreted as 'never'/'always').
|
||||
"button": "when_in_call",
|
||||
// Where to dock the chat panel. Can be 'left' or 'right'.
|
||||
"dock": "right",
|
||||
// Default width of the chat panel.
|
||||
"default_width": 240
|
||||
},
|
||||
"git_panel": {
|
||||
// Whether to show the git panel button in the status bar.
|
||||
"button": true,
|
||||
@@ -829,9 +838,6 @@
|
||||
// }
|
||||
],
|
||||
// When enabled, the agent can run potentially destructive actions without asking for your confirmation.
|
||||
//
|
||||
// Note: This setting has no effect on external agents that support permission modes, such as Claude Code.
|
||||
// You can set `agent_servers.claude.default_mode` to `bypassPermissions` to skip all permission requests.
|
||||
"always_allow_tool_actions": false,
|
||||
// When enabled, the agent will stream edits.
|
||||
"stream_edits": false,
|
||||
@@ -1199,10 +1205,6 @@
|
||||
// The minimum column number to show the inline blame information at
|
||||
"min_column": 0
|
||||
},
|
||||
// Control which information is shown in the branch picker.
|
||||
"branch_picker": {
|
||||
"show_author_name": true
|
||||
},
|
||||
// How git hunks are displayed visually in the editor.
|
||||
// This setting can take two values:
|
||||
//
|
||||
|
||||
@@ -316,11 +316,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#a6a5a0ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#d2a6ffff",
|
||||
"font_style": null,
|
||||
@@ -707,11 +702,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#73777bff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#a37accff",
|
||||
"font_style": null,
|
||||
@@ -1098,11 +1088,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#b4b3aeff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#dfbfffff",
|
||||
"font_style": null,
|
||||
|
||||
@@ -325,11 +325,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#83a598ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#e5d5adff",
|
||||
"font_style": null,
|
||||
@@ -730,11 +725,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#83a598ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#e5d5adff",
|
||||
"font_style": null,
|
||||
@@ -1135,11 +1125,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#83a598ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#e5d5adff",
|
||||
"font_style": null,
|
||||
@@ -1540,11 +1525,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#066578ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#413d3aff",
|
||||
"font_style": null,
|
||||
@@ -1945,11 +1925,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#066578ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#413d3aff",
|
||||
"font_style": null,
|
||||
@@ -2350,11 +2325,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#066578ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#413d3aff",
|
||||
"font_style": null,
|
||||
|
||||
@@ -321,11 +321,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#d07277ff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#b1574bff",
|
||||
"font_style": null,
|
||||
@@ -720,11 +715,6 @@
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.markup": {
|
||||
"color": "#d3604fff",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#b92b46ff",
|
||||
"font_style": null,
|
||||
|
||||
@@ -18,8 +18,8 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
|
||||
[dependencies]
|
||||
action_log.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
agent_settings.workspace = true
|
||||
buffer_diff.workspace = true
|
||||
collections.workspace = true
|
||||
editor.workspace = true
|
||||
|
||||
@@ -805,7 +805,6 @@ pub enum AcpThreadEvent {
|
||||
PromptCapabilitiesUpdated,
|
||||
Refusal,
|
||||
AvailableCommandsUpdated(Vec<acp::AvailableCommand>),
|
||||
ModeUpdated(acp::SessionModeId),
|
||||
}
|
||||
|
||||
impl EventEmitter<AcpThreadEvent> for AcpThread {}
|
||||
@@ -813,6 +812,7 @@ impl EventEmitter<AcpThreadEvent> for AcpThread {}
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum ThreadStatus {
|
||||
Idle,
|
||||
WaitingForToolConfirmation,
|
||||
Generating,
|
||||
}
|
||||
|
||||
@@ -935,7 +935,11 @@ impl AcpThread {
|
||||
|
||||
pub fn status(&self) -> ThreadStatus {
|
||||
if self.send_task.is_some() {
|
||||
ThreadStatus::Generating
|
||||
if self.waiting_for_tool_confirmation() {
|
||||
ThreadStatus::WaitingForToolConfirmation
|
||||
} else {
|
||||
ThreadStatus::Generating
|
||||
}
|
||||
} else {
|
||||
ThreadStatus::Idle
|
||||
}
|
||||
@@ -1003,9 +1007,6 @@ impl AcpThread {
|
||||
acp::SessionUpdate::AvailableCommandsUpdate { available_commands } => {
|
||||
cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands))
|
||||
}
|
||||
acp::SessionUpdate::CurrentModeUpdate { current_mode_id } => {
|
||||
cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id))
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1302,12 +1303,11 @@ impl AcpThread {
|
||||
&mut self,
|
||||
tool_call: acp::ToolCallUpdate,
|
||||
options: Vec<acp::PermissionOption>,
|
||||
respect_always_allow_setting: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<BoxFuture<'static, acp::RequestPermissionOutcome>> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
if respect_always_allow_setting && AgentSettings::get_global(cx).always_allow_tool_actions {
|
||||
if AgentSettings::get_global(cx).always_allow_tool_actions {
|
||||
// Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions,
|
||||
// some tools would (incorrectly) continue to auto-accept.
|
||||
if let Some(allow_once_option) = options.iter().find_map(|option| {
|
||||
@@ -1377,27 +1377,26 @@ impl AcpThread {
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(ix));
|
||||
}
|
||||
|
||||
pub fn first_tool_awaiting_confirmation(&self) -> Option<&ToolCall> {
|
||||
let mut first_tool_call = None;
|
||||
|
||||
/// Returns true if the last turn is awaiting tool authorization
|
||||
pub fn waiting_for_tool_confirmation(&self) -> bool {
|
||||
for entry in self.entries.iter().rev() {
|
||||
match &entry {
|
||||
AgentThreadEntry::ToolCall(call) => {
|
||||
if let ToolCallStatus::WaitingForConfirmation { .. } = call.status {
|
||||
first_tool_call = Some(call);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
AgentThreadEntry::ToolCall(call) => match call.status {
|
||||
ToolCallStatus::WaitingForConfirmation { .. } => return true,
|
||||
ToolCallStatus::Pending
|
||||
| ToolCallStatus::InProgress
|
||||
| ToolCallStatus::Completed
|
||||
| ToolCallStatus::Failed
|
||||
| ToolCallStatus::Rejected
|
||||
| ToolCallStatus::Canceled => continue,
|
||||
},
|
||||
AgentThreadEntry::UserMessage(_) | AgentThreadEntry::AssistantMessage(_) => {
|
||||
// Reached the beginning of the turn.
|
||||
// If we had pending permission requests in the previous turn, they have been cancelled.
|
||||
break;
|
||||
// Reached the beginning of the turn
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
first_tool_call
|
||||
false
|
||||
}
|
||||
|
||||
pub fn plan(&self) -> &Plan {
|
||||
@@ -1641,13 +1640,13 @@ impl AcpThread {
|
||||
cx.foreground_executor().spawn(send_task)
|
||||
}
|
||||
|
||||
/// Restores the git working tree to the state at the given checkpoint (if one exists)
|
||||
pub fn restore_checkpoint(
|
||||
&mut self,
|
||||
id: UserMessageId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let Some((_, message)) = self.user_message_mut(&id) else {
|
||||
/// Rewinds this thread to before the entry at `index`, removing it and all
|
||||
/// subsequent entries while reverting any changes made from that point.
|
||||
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("not supported")));
|
||||
};
|
||||
let Some(message) = self.user_message(&id) else {
|
||||
return Task::ready(Err(anyhow!("message not found")));
|
||||
};
|
||||
|
||||
@@ -1655,30 +1654,15 @@ impl AcpThread {
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|c| c.git_checkpoint.clone());
|
||||
let rewind = self.rewind(id.clone(), cx);
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
rewind.await?;
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Some(checkpoint) = checkpoint {
|
||||
git_store
|
||||
.update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))?
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewinds this thread to before the entry at `index`, removing it and all
|
||||
/// subsequent entries while rejecting any action_log changes made from that point.
|
||||
/// Unlike `restore_checkpoint`, this method does not restore from git.
|
||||
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("not supported")));
|
||||
};
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.update(|cx| truncate.run(id.clone(), cx))?.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some((ix, _)) = this.user_message_mut(&id) {
|
||||
@@ -1686,11 +1670,7 @@ impl AcpThread {
|
||||
this.entries.truncate(ix);
|
||||
cx.emit(AcpThreadEvent::EntriesRemoved(range));
|
||||
}
|
||||
this.action_log()
|
||||
.update(cx, |action_log, cx| action_log.reject_all_edits(cx))
|
||||
})?
|
||||
.await;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1747,6 +1727,20 @@ impl AcpThread {
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> {
|
||||
self.entries.iter().find_map(|entry| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
if message.id.as_ref() == Some(id) {
|
||||
Some(message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> {
|
||||
self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
@@ -2690,7 +2684,7 @@ mod tests {
|
||||
let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
|
||||
panic!("unexpected entries {:?}", thread.entries)
|
||||
};
|
||||
thread.restore_checkpoint(message.id.clone().unwrap(), cx)
|
||||
thread.rewind(message.id.clone().unwrap(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -75,15 +75,6 @@ pub trait AgentConnection {
|
||||
fn telemetry(&self) -> Option<Rc<dyn AgentTelemetry>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn session_modes(
|
||||
&self,
|
||||
_session_id: &acp::SessionId,
|
||||
_cx: &App,
|
||||
) -> Option<Rc<dyn AgentSessionModes>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
|
||||
}
|
||||
|
||||
@@ -118,14 +109,6 @@ pub trait AgentTelemetry {
|
||||
) -> Task<Result<serde_json::Value>>;
|
||||
}
|
||||
|
||||
pub trait AgentSessionModes {
|
||||
fn current_mode(&self) -> acp::SessionModeId;
|
||||
|
||||
fn all_modes(&self) -> Vec<acp::SessionMode>;
|
||||
|
||||
fn set_mode(&self, mode: acp::SessionModeId, cx: &mut App) -> Task<Result<()>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthRequired {
|
||||
pub description: Option<String>,
|
||||
@@ -414,7 +397,6 @@ mod test_support {
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call.clone().into(),
|
||||
options.clone(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
})??
|
||||
|
||||
@@ -771,9 +771,7 @@ impl NativeAgentConnection {
|
||||
response,
|
||||
}) => {
|
||||
let outcome_task = acp_thread.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call, options, true, cx,
|
||||
)
|
||||
thread.request_tool_call_authorization(tool_call, options, cx)
|
||||
})??;
|
||||
cx.background_spawn(async move {
|
||||
if let acp::RequestPermissionOutcome::Selected { option_id } =
|
||||
|
||||
@@ -23,14 +23,13 @@ action_log.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
client.workspace = true
|
||||
client = { workspace = true, optional = true }
|
||||
collections.workspace = true
|
||||
env_logger = { workspace = true, optional = true }
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_tokio = { workspace = true, optional = true }
|
||||
http_client.workspace = true
|
||||
indoc.workspace = true
|
||||
language.workspace = true
|
||||
language_model.workspace = true
|
||||
|
||||
@@ -9,7 +9,6 @@ use futures::io::BufReader;
|
||||
use project::Project;
|
||||
use project::agent_server_store::AgentServerCommand;
|
||||
use serde::Deserialize;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::{any::Any, cell::RefCell};
|
||||
@@ -31,11 +30,7 @@ pub struct AcpConnection {
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
auth_methods: Vec<acp::AuthMethod>,
|
||||
agent_capabilities: acp::AgentCapabilities,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
root_dir: PathBuf,
|
||||
// NB: Don't move this into the wait_task, since we need to ensure the process is
|
||||
// killed on drop (setting kill_on_drop on the command seems to not always work).
|
||||
child: smol::process::Child,
|
||||
_io_task: Task<Result<()>>,
|
||||
_wait_task: Task<Result<()>>,
|
||||
_stderr_task: Task<Result<()>>,
|
||||
@@ -44,26 +39,16 @@ pub struct AcpConnection {
|
||||
pub struct AcpSession {
|
||||
thread: WeakEntity<AcpThread>,
|
||||
suppress_abort_err: bool,
|
||||
session_modes: Option<Rc<RefCell<acp::SessionModeState>>>,
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
server_name: SharedString,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
is_remote: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Rc<dyn AgentConnection>> {
|
||||
let conn = AcpConnection::stdio(
|
||||
server_name,
|
||||
command.clone(),
|
||||
root_dir,
|
||||
default_mode,
|
||||
is_remote,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
let conn = AcpConnection::stdio(server_name, command.clone(), root_dir, is_remote, cx).await?;
|
||||
Ok(Rc::new(conn) as _)
|
||||
}
|
||||
|
||||
@@ -74,7 +59,6 @@ impl AcpConnection {
|
||||
server_name: SharedString,
|
||||
command: AgentServerCommand,
|
||||
root_dir: &Path,
|
||||
default_mode: Option<acp::SessionModeId>,
|
||||
is_remote: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Self> {
|
||||
@@ -84,7 +68,8 @@ impl AcpConnection {
|
||||
.envs(command.env.iter().flatten())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if !is_remote {
|
||||
child.current_dir(root_dir);
|
||||
}
|
||||
@@ -124,9 +109,8 @@ impl AcpConnection {
|
||||
|
||||
let wait_task = cx.spawn({
|
||||
let sessions = sessions.clone();
|
||||
let status_fut = child.status();
|
||||
async move |cx| {
|
||||
let status = status_fut.await?;
|
||||
let status = child.status().await?;
|
||||
|
||||
for session in sessions.borrow().values() {
|
||||
session
|
||||
@@ -173,11 +157,9 @@ impl AcpConnection {
|
||||
server_name,
|
||||
sessions,
|
||||
agent_capabilities: response.agent_capabilities,
|
||||
default_mode,
|
||||
_io_task: io_task,
|
||||
_wait_task: wait_task,
|
||||
_stderr_task: stderr_task,
|
||||
child,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -190,13 +172,6 @@ impl AcpConnection {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpConnection {
|
||||
fn drop(&mut self) {
|
||||
// See the comment on the child field.
|
||||
self.child.kill().log_err();
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConnection for AcpConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
@@ -204,10 +179,8 @@ impl AgentConnection for AcpConnection {
|
||||
cwd: &Path,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let name = self.server_name.clone();
|
||||
let conn = self.connection.clone();
|
||||
let sessions = self.sessions.clone();
|
||||
let default_mode = self.default_mode.clone();
|
||||
let cwd = cwd.to_path_buf();
|
||||
let context_server_store = project.read(cx).context_server_store().read(cx);
|
||||
let mcp_servers = if project.read(cx).is_local() {
|
||||
@@ -217,7 +190,7 @@ impl AgentConnection for AcpConnection {
|
||||
.filter_map(|id| {
|
||||
let configuration = context_server_store.configuration_for_server(id)?;
|
||||
let command = configuration.command();
|
||||
Some(acp::McpServer::Stdio {
|
||||
Some(acp::McpServer {
|
||||
name: id.0.to_string(),
|
||||
command: command.path.clone(),
|
||||
args: command.args.clone(),
|
||||
@@ -259,53 +232,6 @@ impl AgentConnection for AcpConnection {
|
||||
}
|
||||
})?;
|
||||
|
||||
let modes = response.modes.map(|modes| Rc::new(RefCell::new(modes)));
|
||||
|
||||
if let Some(default_mode) = default_mode {
|
||||
if let Some(modes) = modes.as_ref() {
|
||||
let mut modes_ref = modes.borrow_mut();
|
||||
let has_mode = modes_ref.available_modes.iter().any(|mode| mode.id == default_mode);
|
||||
|
||||
if has_mode {
|
||||
let initial_mode_id = modes_ref.current_mode_id.clone();
|
||||
|
||||
cx.spawn({
|
||||
let default_mode = default_mode.clone();
|
||||
let session_id = response.session_id.clone();
|
||||
let modes = modes.clone();
|
||||
async move |_| {
|
||||
let result = conn.set_session_mode(acp::SetSessionModeRequest {
|
||||
session_id,
|
||||
mode_id: default_mode,
|
||||
})
|
||||
.await.log_err();
|
||||
|
||||
if result.is_none() {
|
||||
modes.borrow_mut().current_mode_id = initial_mode_id;
|
||||
}
|
||||
}
|
||||
}).detach();
|
||||
|
||||
modes_ref.current_mode_id = default_mode;
|
||||
} else {
|
||||
let available_modes = modes_ref
|
||||
.available_modes
|
||||
.iter()
|
||||
.map(|mode| format!("- `{}`: {}", mode.id, mode.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
log::warn!(
|
||||
"`{default_mode}` is not valid {name} mode. Available options:\n{available_modes}",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"`{name}` does not support modes, but `default_mode` was set in settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let session_id = response.session_id;
|
||||
let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
|
||||
let thread = cx.new(|cx| {
|
||||
@@ -324,7 +250,6 @@ impl AgentConnection for AcpConnection {
|
||||
let session = AcpSession {
|
||||
thread: thread.downgrade(),
|
||||
suppress_abort_err: false,
|
||||
session_modes: modes
|
||||
};
|
||||
sessions.borrow_mut().insert(session_id, session);
|
||||
|
||||
@@ -421,77 +346,11 @@ impl AgentConnection for AcpConnection {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn session_modes(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
_cx: &App,
|
||||
) -> Option<Rc<dyn acp_thread::AgentSessionModes>> {
|
||||
let sessions = self.sessions.clone();
|
||||
let sessions_ref = sessions.borrow();
|
||||
let Some(session) = sessions_ref.get(session_id) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(modes) = session.session_modes.as_ref() {
|
||||
Some(Rc::new(AcpSessionModes {
|
||||
connection: self.connection.clone(),
|
||||
session_id: session_id.clone(),
|
||||
state: modes.clone(),
|
||||
}) as _)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct AcpSessionModes {
|
||||
session_id: acp::SessionId,
|
||||
connection: Rc<acp::ClientSideConnection>,
|
||||
state: Rc<RefCell<acp::SessionModeState>>,
|
||||
}
|
||||
|
||||
impl acp_thread::AgentSessionModes for AcpSessionModes {
|
||||
fn current_mode(&self) -> acp::SessionModeId {
|
||||
self.state.borrow().current_mode_id.clone()
|
||||
}
|
||||
|
||||
fn all_modes(&self) -> Vec<acp::SessionMode> {
|
||||
self.state.borrow().available_modes.clone()
|
||||
}
|
||||
|
||||
fn set_mode(&self, mode_id: acp::SessionModeId, cx: &mut App) -> Task<Result<()>> {
|
||||
let connection = self.connection.clone();
|
||||
let session_id = self.session_id.clone();
|
||||
let old_mode_id;
|
||||
{
|
||||
let mut state = self.state.borrow_mut();
|
||||
old_mode_id = state.current_mode_id.clone();
|
||||
state.current_mode_id = mode_id.clone();
|
||||
};
|
||||
let state = self.state.clone();
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let result = connection
|
||||
.set_session_mode(acp::SetSessionModeRequest {
|
||||
session_id,
|
||||
mode_id,
|
||||
})
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
state.borrow_mut().current_mode_id = old_mode_id;
|
||||
}
|
||||
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientDelegate {
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
cx: AsyncApp,
|
||||
@@ -502,27 +361,13 @@ impl acp::Client for ClientDelegate {
|
||||
&self,
|
||||
arguments: acp::RequestPermissionRequest,
|
||||
) -> Result<acp::RequestPermissionResponse, acp::Error> {
|
||||
let respect_always_allow_setting;
|
||||
let thread;
|
||||
{
|
||||
let sessions_ref = self.sessions.borrow();
|
||||
let session = sessions_ref
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?;
|
||||
respect_always_allow_setting = session.session_modes.is_none();
|
||||
thread = session.thread.clone();
|
||||
}
|
||||
|
||||
let cx = &mut self.cx.clone();
|
||||
|
||||
let task = thread.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(
|
||||
arguments.tool_call,
|
||||
arguments.options,
|
||||
respect_always_allow_setting,
|
||||
cx,
|
||||
)
|
||||
})??;
|
||||
let task = self
|
||||
.session_thread(&arguments.session_id)?
|
||||
.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx)
|
||||
})??;
|
||||
|
||||
let outcome = task.await;
|
||||
|
||||
@@ -565,24 +410,10 @@ impl acp::Client for ClientDelegate {
|
||||
&self,
|
||||
notification: acp::SessionNotification,
|
||||
) -> Result<(), acp::Error> {
|
||||
let sessions = self.sessions.borrow();
|
||||
let session = sessions
|
||||
.get(¬ification.session_id)
|
||||
.context("Failed to get session")?;
|
||||
|
||||
if let acp::SessionUpdate::CurrentModeUpdate { current_mode_id } = ¬ification.update {
|
||||
if let Some(session_modes) = &session.session_modes {
|
||||
session_modes.borrow_mut().current_mode_id = current_mode_id.clone();
|
||||
} else {
|
||||
log::error!(
|
||||
"Got a `CurrentModeUpdate` notification, but they agent didn't specify `modes` during setting setup."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
session.thread.update(&mut self.cx.clone(), |thread, cx| {
|
||||
thread.handle_session_update(notification.update, cx)
|
||||
})??;
|
||||
self.session_thread(¬ification.session_id)?
|
||||
.update(&mut self.cx.clone(), |thread, cx| {
|
||||
thread.handle_session_update(notification.update, cx)
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,20 +7,15 @@ mod gemini;
|
||||
pub mod e2e_tests;
|
||||
|
||||
pub use claude::*;
|
||||
use client::ProxySettings;
|
||||
use collections::HashMap;
|
||||
pub use custom::*;
|
||||
use fs::Fs;
|
||||
pub use gemini::*;
|
||||
use http_client::read_no_proxy_from_env;
|
||||
use project::agent_server_store::AgentServerStore;
|
||||
|
||||
use acp_thread::AgentConnection;
|
||||
use anyhow::Result;
|
||||
use gpui::{App, AppContext, Entity, SharedString, Task};
|
||||
use gpui::{App, Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use std::{any::Any, path::Path, rc::Rc, sync::Arc};
|
||||
use std::{any::Any, path::Path, rc::Rc};
|
||||
|
||||
pub use acp::AcpConnection;
|
||||
|
||||
@@ -55,16 +50,6 @@ pub trait AgentServer: Send {
|
||||
fn logo(&self) -> ui::IconName;
|
||||
fn name(&self) -> SharedString;
|
||||
fn telemetry_id(&self) -> &'static str;
|
||||
fn default_mode(&self, _cx: &mut App) -> Option<agent_client_protocol::SessionModeId> {
|
||||
None
|
||||
}
|
||||
fn set_default_mode(
|
||||
&self,
|
||||
_mode_id: Option<agent_client_protocol::SessionModeId>,
|
||||
_fs: Arc<dyn Fs>,
|
||||
_cx: &mut App,
|
||||
) {
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
@@ -81,25 +66,3 @@ impl dyn AgentServer {
|
||||
self.into_any().downcast().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the default proxy environment variables to pass through to the agent
|
||||
pub fn load_proxy_env(cx: &mut App) -> HashMap<String, String> {
|
||||
let proxy_url = cx
|
||||
.read_global(|settings: &SettingsStore, _| settings.get::<ProxySettings>(None).proxy_url());
|
||||
let mut env = HashMap::default();
|
||||
|
||||
if let Some(proxy_url) = &proxy_url {
|
||||
let env_var = if proxy_url.scheme() == "https" {
|
||||
"HTTPS_PROXY"
|
||||
} else {
|
||||
"HTTP_PROXY"
|
||||
};
|
||||
env.insert(env_var.to_owned(), proxy_url.to_string());
|
||||
}
|
||||
|
||||
if let Some(no_proxy) = read_no_proxy_from_env() {
|
||||
env.insert("NO_PROXY".to_owned(), no_proxy);
|
||||
}
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use agent_client_protocol as acp;
|
||||
use fs::Fs;
|
||||
use settings::{SettingsStore, update_settings_file};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::{any::Any, path::PathBuf};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{App, AppContext as _, SharedString, Task};
|
||||
use project::agent_server_store::{AllAgentServersSettings, CLAUDE_CODE_NAME};
|
||||
use gpui::{App, SharedString, Task};
|
||||
use project::agent_server_store::CLAUDE_CODE_NAME;
|
||||
|
||||
use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
|
||||
use crate::{AgentServer, AgentServerDelegate};
|
||||
use acp_thread::AgentConnection;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -34,22 +30,6 @@ impl AgentServer for ClaudeCode {
|
||||
ui::IconName::AiClaude
|
||||
}
|
||||
|
||||
fn default_mode(&self, cx: &mut App) -> Option<acp::SessionModeId> {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings.get::<AllAgentServersSettings>(None).claude.clone()
|
||||
});
|
||||
|
||||
settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into())))
|
||||
}
|
||||
|
||||
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
update_settings_file::<AllAgentServersSettings>(fs, cx, |settings, _| {
|
||||
settings.claude.get_or_insert_default().default_mode = mode_id.map(|m| m.to_string())
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
root_dir: Option<&Path>,
|
||||
@@ -60,8 +40,6 @@ impl AgentServer for ClaudeCode {
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
let extra_env = load_proxy_env(cx);
|
||||
let default_mode = self.default_mode(cx);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (command, root_dir, login) = store
|
||||
@@ -71,22 +49,15 @@ impl AgentServer for ClaudeCode {
|
||||
.context("Claude Code is not registered")?;
|
||||
anyhow::Ok(agent.get_command(
|
||||
root_dir.as_deref(),
|
||||
extra_env,
|
||||
Default::default(),
|
||||
delegate.status_tx,
|
||||
delegate.new_version_available,
|
||||
&mut cx.to_async(),
|
||||
))
|
||||
})??
|
||||
.await?;
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
is_remote,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
let connection =
|
||||
crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
|
||||
Ok((connection, login))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use crate::{AgentServerDelegate, load_proxy_env};
|
||||
use crate::AgentServerDelegate;
|
||||
use acp_thread::AgentConnection;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result};
|
||||
use fs::Fs;
|
||||
use gpui::{App, AppContext as _, SharedString, Task};
|
||||
use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName};
|
||||
use settings::{SettingsStore, update_settings_file};
|
||||
use std::{path::Path, rc::Rc, sync::Arc};
|
||||
use gpui::{App, SharedString, Task};
|
||||
use project::agent_server_store::ExternalAgentServerName;
|
||||
use std::{path::Path, rc::Rc};
|
||||
use ui::IconName;
|
||||
|
||||
/// A generic agent server implementation for custom user-defined agents
|
||||
@@ -33,27 +30,6 @@ impl crate::AgentServer for CustomAgentServer {
|
||||
IconName::Terminal
|
||||
}
|
||||
|
||||
fn default_mode(&self, cx: &mut App) -> Option<acp::SessionModeId> {
|
||||
let settings = cx.read_global(|settings: &SettingsStore, _| {
|
||||
settings
|
||||
.get::<AllAgentServersSettings>(None)
|
||||
.custom
|
||||
.get(&self.name())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into())))
|
||||
}
|
||||
|
||||
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
let name = self.name();
|
||||
update_settings_file::<AllAgentServersSettings>(fs, cx, move |settings, _| {
|
||||
settings.custom.get_mut(&name).unwrap().default_mode = mode_id.map(|m| m.to_string())
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
root_dir: Option<&Path>,
|
||||
@@ -63,9 +39,7 @@ impl crate::AgentServer for CustomAgentServer {
|
||||
let name = self.name();
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let default_mode = self.default_mode(cx);
|
||||
let store = delegate.store.downgrade();
|
||||
let extra_env = load_proxy_env(cx);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let (command, root_dir, login) = store
|
||||
@@ -77,22 +51,15 @@ impl crate::AgentServer for CustomAgentServer {
|
||||
})?;
|
||||
anyhow::Ok(agent.get_command(
|
||||
root_dir.as_deref(),
|
||||
extra_env,
|
||||
Default::default(),
|
||||
delegate.status_tx,
|
||||
delegate.new_version_available,
|
||||
&mut cx.to_async(),
|
||||
))
|
||||
})??
|
||||
.await?;
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
is_remote,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
let connection =
|
||||
crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
|
||||
Ok((connection, login))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, channel::mpsc, select};
|
||||
use gpui::{AppContext, Entity, TestAppContext};
|
||||
use indoc::indoc;
|
||||
#[cfg(test)]
|
||||
use project::agent_server_store::BuiltinAgentServerSettings;
|
||||
use project::agent_server_store::{AgentServerCommand, CustomAgentServerSettings};
|
||||
use project::{FakeFs, Project, agent_server_store::AllAgentServersSettings};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@@ -472,12 +472,12 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
|
||||
#[cfg(test)]
|
||||
AllAgentServersSettings::override_global(
|
||||
AllAgentServersSettings {
|
||||
claude: Some(BuiltinAgentServerSettings {
|
||||
path: Some("claude-code-acp".into()),
|
||||
args: None,
|
||||
env: None,
|
||||
ignore_system_version: None,
|
||||
default_mode: None,
|
||||
claude: Some(CustomAgentServerSettings {
|
||||
command: AgentServerCommand {
|
||||
path: "claude-code-acp".into(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
},
|
||||
}),
|
||||
gemini: Some(crate::gemini::tests::local_command().into()),
|
||||
custom: collections::HashMap::default(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::rc::Rc;
|
||||
use std::{any::Any, path::Path};
|
||||
|
||||
use crate::{AgentServer, AgentServerDelegate, load_proxy_env};
|
||||
use crate::{AgentServer, AgentServerDelegate};
|
||||
use acp_thread::AgentConnection;
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use gpui::{App, SharedString, Task};
|
||||
use language_models::provider::google::GoogleLanguageModelProvider;
|
||||
use project::agent_server_store::GEMINI_NAME;
|
||||
@@ -34,11 +35,9 @@ impl AgentServer for Gemini {
|
||||
let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().to_string());
|
||||
let is_remote = delegate.project.read(cx).is_via_remote_server();
|
||||
let store = delegate.store.downgrade();
|
||||
let mut extra_env = load_proxy_env(cx);
|
||||
let default_mode = self.default_mode(cx);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
extra_env.insert("SURFACE".to_owned(), "zed".to_owned());
|
||||
let mut extra_env = HashMap::default();
|
||||
if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
|
||||
extra_env.insert("GEMINI_API_KEY".into(), api_key.key);
|
||||
}
|
||||
@@ -56,16 +55,8 @@ impl AgentServer for Gemini {
|
||||
))
|
||||
})??
|
||||
.await?;
|
||||
|
||||
let connection = crate::acp::connect(
|
||||
name,
|
||||
command,
|
||||
root_dir.as_ref(),
|
||||
default_mode,
|
||||
is_remote,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
let connection =
|
||||
crate::acp::connect(name, command, root_dir.as_ref(), is_remote, cx).await?;
|
||||
Ok((connection, login))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
use agent_client_protocol as acp;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::AgentServerCommand;
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use gpui::{App, SharedString};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AllAgentServersSettings::register(cx);
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi, SettingsKey)]
|
||||
#[settings_key(key = "agent_servers")]
|
||||
pub struct AllAgentServersSettings {
|
||||
pub gemini: Option<BuiltinAgentServerSettings>,
|
||||
pub claude: Option<BuiltinAgentServerSettings>,
|
||||
|
||||
/// Custom agent servers configured by the user
|
||||
#[serde(flatten)]
|
||||
pub custom: HashMap<SharedString, CustomAgentServerSettings>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
|
||||
pub struct BuiltinAgentServerSettings {
|
||||
/// Absolute path to a binary to be used when launching this agent.
|
||||
///
|
||||
/// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
|
||||
#[serde(rename = "command")]
|
||||
pub path: Option<PathBuf>,
|
||||
/// If a binary is specified in `command`, it will be passed these arguments.
|
||||
pub args: Option<Vec<String>>,
|
||||
/// If a binary is specified in `command`, it will be passed these environment variables.
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
/// Whether to skip searching `$PATH` for an agent server binary when
|
||||
/// launching this agent.
|
||||
///
|
||||
/// This has no effect if a `command` is specified. Otherwise, when this is
|
||||
/// `false`, Zed will search `$PATH` for an agent server binary and, if one
|
||||
/// is found, use it for threads with this agent. If no agent binary is
|
||||
/// found on `$PATH`, Zed will automatically install and use its own binary.
|
||||
/// When this is `true`, Zed will not search `$PATH`, and will always use
|
||||
/// its own binary.
|
||||
///
|
||||
/// Default: true
|
||||
pub ignore_system_version: Option<bool>,
|
||||
/// The default mode for new threads.
|
||||
///
|
||||
/// Note: Not all agents support modes.
|
||||
///
|
||||
/// Default: None
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_mode: Option<acp::SessionModeId>,
|
||||
}
|
||||
|
||||
impl BuiltinAgentServerSettings {
|
||||
pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
|
||||
self.path.map(|path| AgentServerCommand {
|
||||
path,
|
||||
args: self.args.unwrap_or_default(),
|
||||
env: self.env,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AgentServerCommand> for BuiltinAgentServerSettings {
|
||||
fn from(value: AgentServerCommand) -> Self {
|
||||
BuiltinAgentServerSettings {
|
||||
path: Some(value.path),
|
||||
args: Some(value.args),
|
||||
env: value.env,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
|
||||
pub struct CustomAgentServerSettings {
|
||||
#[serde(flatten)]
|
||||
pub command: AgentServerCommand,
|
||||
/// The default mode for new threads.
|
||||
///
|
||||
/// Note: Not all agents support modes.
|
||||
///
|
||||
/// Default: None
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_mode: Option<acp::SessionModeId>,
|
||||
}
|
||||
|
||||
impl settings::Settings for AllAgentServersSettings {
|
||||
type FileContent = Self;
|
||||
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
|
||||
let mut settings = AllAgentServersSettings::default();
|
||||
|
||||
for AllAgentServersSettings {
|
||||
gemini,
|
||||
claude,
|
||||
custom,
|
||||
} in sources.defaults_and_customizations()
|
||||
{
|
||||
if gemini.is_some() {
|
||||
settings.gemini = gemini.clone();
|
||||
}
|
||||
if claude.is_some() {
|
||||
settings.claude = claude.clone();
|
||||
}
|
||||
|
||||
// Merge custom agents
|
||||
for (name, config) in custom {
|
||||
// Skip built-in agent names to avoid conflicts
|
||||
if name != "gemini" && name != "claude" {
|
||||
settings.custom.insert(name.clone(), config.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
@@ -269,10 +269,6 @@ pub struct AgentSettingsContent {
|
||||
/// Whenever a tool action would normally wait for your confirmation
|
||||
/// that you allow it, always choose to allow it.
|
||||
///
|
||||
/// This setting has no effect on external agents that support permission modes, such as Claude Code.
|
||||
///
|
||||
/// Set `agent_servers.claude.default_mode` to `bypassPermissions`, to disable all permission requests when using Claude Code.
|
||||
///
|
||||
/// Default: false
|
||||
always_allow_tool_actions: Option<bool>,
|
||||
/// Where to show a popup notification when the agent is waiting for user input.
|
||||
|
||||
@@ -25,7 +25,6 @@ agent_servers.workspace = true
|
||||
agent_settings.workspace = true
|
||||
ai_onboarding.workspace = true
|
||||
anyhow.workspace = true
|
||||
arrayvec.workspace = true
|
||||
assistant_context.workspace = true
|
||||
assistant_slash_command.workspace = true
|
||||
assistant_slash_commands.workspace = true
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
mod completion_provider;
|
||||
mod entry_view_state;
|
||||
mod message_editor;
|
||||
mod mode_selector;
|
||||
mod model_selector;
|
||||
mod model_selector_popover;
|
||||
mod thread_history;
|
||||
mod thread_view;
|
||||
|
||||
pub use mode_selector::ModeSelector;
|
||||
pub use model_selector::AcpModelSelector;
|
||||
pub use model_selector_popover::AcpModelSelectorPopover;
|
||||
pub use thread_history::*;
|
||||
|
||||
@@ -1066,21 +1066,13 @@ struct MentionCompletion {
|
||||
impl MentionCompletion {
|
||||
fn try_parse(allow_non_file_mentions: bool, line: &str, offset_to_line: usize) -> Option<Self> {
|
||||
let last_mention_start = line.rfind('@')?;
|
||||
|
||||
// No whitespace immediately after '@'
|
||||
if line[last_mention_start + 1..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_whitespace())
|
||||
{
|
||||
return None;
|
||||
if last_mention_start >= line.len() {
|
||||
return Some(Self::default());
|
||||
}
|
||||
|
||||
// Must be a word boundary before '@'
|
||||
if last_mention_start > 0
|
||||
&& line[..last_mention_start]
|
||||
&& line
|
||||
.chars()
|
||||
.last()
|
||||
.nth(last_mention_start - 1)
|
||||
.is_some_and(|c| !c.is_whitespace())
|
||||
{
|
||||
return None;
|
||||
@@ -1093,9 +1085,7 @@ impl MentionCompletion {
|
||||
|
||||
let mut parts = rest_of_line.split_whitespace();
|
||||
let mut end = last_mention_start + 1;
|
||||
|
||||
if let Some(mode_text) = parts.next() {
|
||||
// Safe since we check no leading whitespace above
|
||||
end += mode_text.len();
|
||||
|
||||
if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok()
|
||||
@@ -1288,23 +1278,5 @@ mod tests {
|
||||
argument: Some("main".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MentionCompletion::try_parse(true, "Lorem@symbol", 0),
|
||||
None,
|
||||
"Should not parse mention inside word"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MentionCompletion::try_parse(true, "Lorem @ file", 0),
|
||||
None,
|
||||
"Should not parse with a space after @"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
MentionCompletion::try_parse(true, "@ file", 0),
|
||||
None,
|
||||
"Should not parse with a space after @ at the start of the line"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
use acp_thread::AgentSessionModes;
|
||||
use agent_client_protocol as acp;
|
||||
use agent_servers::AgentServer;
|
||||
use fs::Fs;
|
||||
use gpui::{Context, Entity, FocusHandle, WeakEntity, Window, prelude::*};
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
use ui::{
|
||||
Button, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{CycleModeSelector, ToggleProfileSelector};
|
||||
|
||||
pub struct ModeSelector {
|
||||
connection: Rc<dyn AgentSessionModes>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
focus_handle: FocusHandle,
|
||||
fs: Arc<dyn Fs>,
|
||||
setting_mode: bool,
|
||||
}
|
||||
|
||||
impl ModeSelector {
|
||||
pub fn new(
|
||||
session_modes: Rc<dyn AgentSessionModes>,
|
||||
agent_server: Rc<dyn AgentServer>,
|
||||
fs: Arc<dyn Fs>,
|
||||
focus_handle: FocusHandle,
|
||||
) -> Self {
|
||||
Self {
|
||||
connection: session_modes,
|
||||
agent_server,
|
||||
menu_handle: PopoverMenuHandle::default(),
|
||||
fs,
|
||||
setting_mode: false,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_handle(&self) -> PopoverMenuHandle<ContextMenu> {
|
||||
self.menu_handle.clone()
|
||||
}
|
||||
|
||||
pub fn cycle_mode(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let all_modes = self.connection.all_modes();
|
||||
let current_mode = self.connection.current_mode();
|
||||
|
||||
let current_index = all_modes
|
||||
.iter()
|
||||
.position(|mode| mode.id.0 == current_mode.0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let next_index = (current_index + 1) % all_modes.len();
|
||||
self.set_mode(all_modes[next_index].id.clone(), cx);
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: acp::SessionModeId, cx: &mut Context<Self>) {
|
||||
let task = self.connection.set_mode(mode, cx);
|
||||
self.setting_mode = true;
|
||||
cx.notify();
|
||||
|
||||
cx.spawn(async move |this: WeakEntity<ModeSelector>, cx| {
|
||||
if let Err(err) = task.await {
|
||||
log::error!("Failed to set session mode: {:?}", err);
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
this.setting_mode = false;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn build_context_menu(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<ContextMenu> {
|
||||
let weak_self = cx.weak_entity();
|
||||
|
||||
ContextMenu::build(window, cx, move |mut menu, _window, cx| {
|
||||
let all_modes = self.connection.all_modes();
|
||||
let current_mode = self.connection.current_mode();
|
||||
let default_mode = self.agent_server.default_mode(cx);
|
||||
|
||||
for mode in all_modes {
|
||||
let is_selected = &mode.id == ¤t_mode;
|
||||
let is_default = Some(&mode.id) == default_mode.as_ref();
|
||||
let entry = ContextMenuEntry::new(mode.name.clone())
|
||||
.toggleable(IconPosition::End, is_selected);
|
||||
|
||||
let entry = if let Some(description) = &mode.description {
|
||||
entry.documentation_aside(ui::DocumentationSide::Left, {
|
||||
let description = description.clone();
|
||||
|
||||
move |cx| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new(description.clone()))
|
||||
.child(
|
||||
h_flex()
|
||||
.pt_1()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.gap_0p5()
|
||||
.text_sm()
|
||||
.text_color(Color::Muted.color(cx))
|
||||
.child("Hold")
|
||||
.child(div().pt_0p5().children(ui::render_modifiers(
|
||||
&gpui::Modifiers::secondary_key(),
|
||||
PlatformStyle::platform(),
|
||||
None,
|
||||
Some(ui::TextSize::Default.rems(cx).into()),
|
||||
true,
|
||||
)))
|
||||
.child(div().map(|this| {
|
||||
if is_default {
|
||||
this.child("to also unset as default")
|
||||
} else {
|
||||
this.child("to also set as default")
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
entry
|
||||
};
|
||||
|
||||
menu.push_item(entry.handler({
|
||||
let mode_id = mode.id.clone();
|
||||
let weak_self = weak_self.clone();
|
||||
move |window, cx| {
|
||||
weak_self
|
||||
.update(cx, |this, cx| {
|
||||
if window.modifiers().secondary() {
|
||||
this.agent_server.set_default_mode(
|
||||
if is_default {
|
||||
None
|
||||
} else {
|
||||
Some(mode_id.clone())
|
||||
},
|
||||
this.fs.clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
this.set_mode(mode_id.clone(), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
menu.key_context("ModeSelector")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ModeSelector {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let current_mode_id = self.connection.current_mode();
|
||||
let current_mode_name = self
|
||||
.connection
|
||||
.all_modes()
|
||||
.iter()
|
||||
.find(|mode| mode.id == current_mode_id)
|
||||
.map(|mode| mode.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
|
||||
let this = cx.entity();
|
||||
|
||||
let trigger_button = Button::new("mode-selector-trigger", current_mode_name)
|
||||
.label_size(LabelSize::Small)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.color(Color::Muted)
|
||||
.icon(IconName::ChevronDown)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_position(IconPosition::End)
|
||||
.icon_color(Color::Muted)
|
||||
.disabled(self.setting_mode);
|
||||
|
||||
PopoverMenu::new("mode-selector")
|
||||
.trigger_with_tooltip(
|
||||
trigger_button,
|
||||
Tooltip::element({
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
move |window, cx| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.pb_1()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(Label::new("Cycle Through Modes"))
|
||||
.children(KeyBinding::for_action_in(
|
||||
&CycleModeSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Label::new("Toggle Mode Menu"))
|
||||
.children(KeyBinding::for_action_in(
|
||||
&ToggleProfileSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.anchor(gpui::Corner::BottomRight)
|
||||
.with_handle(self.menu_handle.clone())
|
||||
.menu(move |window, cx| {
|
||||
Some(this.update(cx, |this, cx| this.build_context_menu(window, cx)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ use agent_client_protocol as acp;
|
||||
use gpui::{Entity, FocusHandle};
|
||||
use picker::popover_menu::PickerPopoverMenu;
|
||||
use ui::{
|
||||
ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
|
||||
prelude::*,
|
||||
ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, Tooltip, Window, prelude::*,
|
||||
};
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
@@ -59,22 +58,15 @@ impl Render for AcpModelSelectorPopover {
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
let color = if self.menu_handle.is_deployed() {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
PickerPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
.when_some(model_icon, |this, icon| {
|
||||
this.child(Icon::new(icon).color(color).size(IconSize::XSmall))
|
||||
this.child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall))
|
||||
})
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.color(color)
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small)
|
||||
.ml_0p5(),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ use agent_servers::{AgentServer, AgentServerDelegate};
|
||||
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
|
||||
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use arrayvec::ArrayVec;
|
||||
use audio::{Audio, Sound};
|
||||
use buffer_diff::BufferDiff;
|
||||
use client::zed_urls;
|
||||
@@ -55,7 +54,6 @@ use zed_actions::assistant::OpenRulesLibrary;
|
||||
|
||||
use super::entry_view_state::EntryViewState;
|
||||
use crate::acp::AcpModelSelectorPopover;
|
||||
use crate::acp::ModeSelector;
|
||||
use crate::acp::entry_view_state::{EntryViewEvent, ViewEvent};
|
||||
use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
|
||||
use crate::agent_diff::AgentDiff;
|
||||
@@ -66,9 +64,8 @@ use crate::ui::{
|
||||
AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
|
||||
};
|
||||
use crate::{
|
||||
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
|
||||
CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, OpenAgentDiff, OpenHistory, RejectAll,
|
||||
RejectOnce, ToggleBurnMode, ToggleProfileSelector,
|
||||
AgentDiffPane, AgentPanel, ContinueThread, ContinueWithBurnMode, ExpandMessageEditor, Follow,
|
||||
KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
|
||||
};
|
||||
|
||||
pub const MIN_EDITOR_LINES: usize = 4;
|
||||
@@ -301,7 +298,6 @@ enum ThreadState {
|
||||
Ready {
|
||||
thread: Entity<AcpThread>,
|
||||
title_editor: Option<Entity<Editor>>,
|
||||
mode_selector: Option<Entity<ModeSelector>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
},
|
||||
LoadError(LoadError),
|
||||
@@ -400,7 +396,6 @@ impl AcpThreadView {
|
||||
message_editor,
|
||||
model_selector: None,
|
||||
profile_selector: None,
|
||||
|
||||
notifications: Vec::new(),
|
||||
notification_subscriptions: HashMap::default(),
|
||||
list_state: list_state.clone(),
|
||||
@@ -599,23 +594,6 @@ impl AcpThreadView {
|
||||
})
|
||||
});
|
||||
|
||||
let mode_selector = thread
|
||||
.read(cx)
|
||||
.connection()
|
||||
.session_modes(thread.read(cx).session_id(), cx)
|
||||
.map(|session_modes| {
|
||||
let fs = this.project.read(cx).fs().clone();
|
||||
let focus_handle = this.focus_handle(cx);
|
||||
cx.new(|_cx| {
|
||||
ModeSelector::new(
|
||||
session_modes,
|
||||
this.agent.clone(),
|
||||
fs,
|
||||
focus_handle,
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
let mut subscriptions = vec![
|
||||
cx.subscribe_in(&thread, window, Self::handle_thread_event),
|
||||
cx.observe(&action_log, |_, _, cx| cx.notify()),
|
||||
@@ -637,11 +615,9 @@ impl AcpThreadView {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
this.thread_state = ThreadState::Ready {
|
||||
thread,
|
||||
title_editor,
|
||||
mode_selector,
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
this.message_editor.focus_handle(cx).focus(window);
|
||||
@@ -794,15 +770,6 @@ impl AcpThreadView {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mode_selector(&self) -> Option<&Entity<ModeSelector>> {
|
||||
match &self.thread_state {
|
||||
ThreadState::Ready { mode_selector, .. } => mode_selector.as_ref(),
|
||||
ThreadState::Unauthenticated { .. }
|
||||
| ThreadState::Loading { .. }
|
||||
| ThreadState::LoadError { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(&self, cx: &App) -> SharedString {
|
||||
match &self.thread_state {
|
||||
ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
|
||||
@@ -960,7 +927,7 @@ impl AcpThreadView {
|
||||
}
|
||||
}
|
||||
ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
|
||||
self.regenerate(event.entry_index, editor.clone(), window, cx);
|
||||
self.regenerate(event.entry_index, editor, window, cx);
|
||||
}
|
||||
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
|
||||
self.cancel_editing(&Default::default(), window, cx);
|
||||
@@ -1184,7 +1151,7 @@ impl AcpThreadView {
|
||||
fn regenerate(
|
||||
&mut self,
|
||||
entry_ix: usize,
|
||||
message_editor: Entity<MessageEditor>,
|
||||
message_editor: &Entity<MessageEditor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -1201,18 +1168,16 @@ impl AcpThreadView {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
|
||||
|
||||
let task = cx.spawn(async move |_, cx| {
|
||||
let contents = contents.await?;
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
|
||||
.await?;
|
||||
let contents =
|
||||
message_editor.update(cx, |message_editor, cx| message_editor.contents(cx))?;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.send_impl(contents, window, cx);
|
||||
})?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
Ok(contents)
|
||||
});
|
||||
self.send_impl(task, window, cx);
|
||||
}
|
||||
|
||||
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -1398,10 +1363,6 @@ impl AcpThreadView {
|
||||
|
||||
self.available_commands.replace(available_commands);
|
||||
}
|
||||
AcpThreadEvent::ModeUpdated(_mode) => {
|
||||
// The connection keeps track of the mode
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -1674,16 +1635,14 @@ impl AcpThreadView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
|
||||
fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.restore_checkpoint(message_id.clone(), cx)
|
||||
})
|
||||
.update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
|
||||
.detach_and_log_err(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_entry(
|
||||
@@ -1753,9 +1712,8 @@ impl AcpThreadView {
|
||||
.label_size(LabelSize::XSmall)
|
||||
.icon_color(Color::Muted)
|
||||
.color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.restore_checkpoint(&message_id, cx);
|
||||
this.rewind(&message_id, cx);
|
||||
}))
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
@@ -1826,7 +1784,7 @@ impl AcpThreadView {
|
||||
let editor = editor.clone();
|
||||
move |this, _, window, cx| {
|
||||
this.regenerate(
|
||||
entry_ix, editor.clone(), window, cx,
|
||||
entry_ix, &editor, window, cx,
|
||||
);
|
||||
}
|
||||
})).into_any_element()
|
||||
@@ -2092,7 +2050,6 @@ impl AcpThreadView {
|
||||
acp::ToolKind::Execute => IconName::ToolTerminal,
|
||||
acp::ToolKind::Think => IconName::ToolThink,
|
||||
acp::ToolKind::Fetch => IconName::ToolWeb,
|
||||
acp::ToolKind::SwitchMode => IconName::ArrowRightLeft,
|
||||
acp::ToolKind::Other => IconName::ToolHammer,
|
||||
})
|
||||
}
|
||||
@@ -2143,68 +2100,59 @@ impl AcpThreadView {
|
||||
})
|
||||
};
|
||||
|
||||
let tool_output_display =
|
||||
if is_open {
|
||||
match &tool_call.status {
|
||||
ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
|
||||
.w_full()
|
||||
.children(tool_call.content.iter().enumerate().map(
|
||||
|(content_ix, content)| {
|
||||
div()
|
||||
.child(self.render_tool_call_content(
|
||||
entry_ix,
|
||||
content,
|
||||
content_ix,
|
||||
tool_call,
|
||||
use_card_layout,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
},
|
||||
))
|
||||
.child(self.render_permission_buttons(
|
||||
tool_call.kind,
|
||||
options,
|
||||
let tool_output_display = if is_open {
|
||||
match &tool_call.status {
|
||||
ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
|
||||
.w_full()
|
||||
.children(tool_call.content.iter().map(|content| {
|
||||
div()
|
||||
.child(self.render_tool_call_content(
|
||||
entry_ix,
|
||||
content,
|
||||
tool_call,
|
||||
use_card_layout,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}))
|
||||
.child(self.render_permission_buttons(
|
||||
options,
|
||||
entry_ix,
|
||||
tool_call.id.clone(),
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallStatus::Pending | ToolCallStatus::InProgress
|
||||
if is_edit
|
||||
&& tool_call.content.is_empty()
|
||||
&& self.as_native_connection(cx).is_some() =>
|
||||
{
|
||||
self.render_diff_loading(cx).into_any()
|
||||
}
|
||||
ToolCallStatus::Pending
|
||||
| ToolCallStatus::InProgress
|
||||
| ToolCallStatus::Completed
|
||||
| ToolCallStatus::Failed
|
||||
| ToolCallStatus::Canceled => v_flex()
|
||||
.w_full()
|
||||
.children(tool_call.content.iter().map(|content| {
|
||||
div().child(self.render_tool_call_content(
|
||||
entry_ix,
|
||||
tool_call.id.clone(),
|
||||
content,
|
||||
tool_call,
|
||||
use_card_layout,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallStatus::Pending | ToolCallStatus::InProgress
|
||||
if is_edit
|
||||
&& tool_call.content.is_empty()
|
||||
&& self.as_native_connection(cx).is_some() =>
|
||||
{
|
||||
self.render_diff_loading(cx).into_any()
|
||||
}
|
||||
ToolCallStatus::Pending
|
||||
| ToolCallStatus::InProgress
|
||||
| ToolCallStatus::Completed
|
||||
| ToolCallStatus::Failed
|
||||
| ToolCallStatus::Canceled => v_flex()
|
||||
.w_full()
|
||||
.children(tool_call.content.iter().enumerate().map(
|
||||
|(content_ix, content)| {
|
||||
div().child(self.render_tool_call_content(
|
||||
entry_ix,
|
||||
content,
|
||||
content_ix,
|
||||
tool_call,
|
||||
use_card_layout,
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
},
|
||||
))
|
||||
.into_any(),
|
||||
ToolCallStatus::Rejected => Empty.into_any(),
|
||||
}
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}))
|
||||
.into_any(),
|
||||
ToolCallStatus::Rejected => Empty.into_any(),
|
||||
}
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.map(|this| {
|
||||
@@ -2329,7 +2277,6 @@ impl AcpThreadView {
|
||||
&self,
|
||||
entry_ix: usize,
|
||||
content: &ToolCallContent,
|
||||
context_ix: usize,
|
||||
tool_call: &ToolCall,
|
||||
card_layout: bool,
|
||||
window: &Window,
|
||||
@@ -2343,7 +2290,6 @@ impl AcpThreadView {
|
||||
self.render_markdown_output(
|
||||
markdown.clone(),
|
||||
tool_call.id.clone(),
|
||||
context_ix,
|
||||
card_layout,
|
||||
window,
|
||||
cx,
|
||||
@@ -2363,7 +2309,6 @@ impl AcpThreadView {
|
||||
&self,
|
||||
markdown: Entity<Markdown>,
|
||||
tool_call_id: acp::ToolCallId,
|
||||
context_ix: usize,
|
||||
card_layout: bool,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
@@ -2380,13 +2325,11 @@ impl AcpThreadView {
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
.when(card_layout, |this| {
|
||||
this.px_2().pb_2().when(context_ix > 0, |this| {
|
||||
this.border_t_1()
|
||||
.pt_2()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
this.p_2()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
})
|
||||
.text_xs()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
|
||||
.when(!card_layout, |this| {
|
||||
@@ -2467,70 +2410,41 @@ impl AcpThreadView {
|
||||
|
||||
fn render_permission_buttons(
|
||||
&self,
|
||||
kind: acp::ToolKind,
|
||||
options: &[acp::PermissionOption],
|
||||
entry_ix: usize,
|
||||
tool_call_id: acp::ToolCallId,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
let is_first = self.thread().is_some_and(|thread| {
|
||||
thread
|
||||
.read(cx)
|
||||
.first_tool_awaiting_confirmation()
|
||||
.is_some_and(|call| call.id == tool_call_id)
|
||||
});
|
||||
let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3> = ArrayVec::new();
|
||||
|
||||
div()
|
||||
.p_1()
|
||||
h_flex()
|
||||
.py_1()
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.flex_wrap()
|
||||
.border_t_1()
|
||||
.border_color(self.tool_card_border_color(cx))
|
||||
.w_full()
|
||||
.map(|this| {
|
||||
if kind == acp::ToolKind::SwitchMode {
|
||||
this.v_flex()
|
||||
} else {
|
||||
this.h_flex().justify_end().flex_wrap()
|
||||
}
|
||||
})
|
||||
.gap_0p5()
|
||||
.children(options.iter().map(move |option| {
|
||||
.child(
|
||||
div()
|
||||
.min_w(rems_from_px(145.))
|
||||
.child(LoadingLabel::new("Waiting for Confirmation").size(LabelSize::Small)),
|
||||
)
|
||||
.child(h_flex().gap_0p5().children(options.iter().map(|option| {
|
||||
let option_id = SharedString::from(option.id.0.clone());
|
||||
Button::new((option_id, entry_ix), option.name.clone())
|
||||
.map(|this| {
|
||||
let (this, action) = match option.kind {
|
||||
acp::PermissionOptionKind::AllowOnce => (
|
||||
this.icon(IconName::Check).icon_color(Color::Success),
|
||||
Some(&AllowOnce as &dyn Action),
|
||||
),
|
||||
acp::PermissionOptionKind::AllowAlways => (
|
||||
this.icon(IconName::CheckDouble).icon_color(Color::Success),
|
||||
Some(&AllowAlways as &dyn Action),
|
||||
),
|
||||
acp::PermissionOptionKind::RejectOnce => (
|
||||
this.icon(IconName::Close).icon_color(Color::Error),
|
||||
Some(&RejectOnce as &dyn Action),
|
||||
),
|
||||
acp::PermissionOptionKind::RejectAlways => {
|
||||
(this.icon(IconName::Close).icon_color(Color::Error), None)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(action) = action else {
|
||||
return this;
|
||||
};
|
||||
|
||||
if !is_first || seen_kinds.contains(&option.kind) {
|
||||
return this;
|
||||
.map(|this| match option.kind {
|
||||
acp::PermissionOptionKind::AllowOnce => {
|
||||
this.icon(IconName::Check).icon_color(Color::Success)
|
||||
}
|
||||
acp::PermissionOptionKind::AllowAlways => {
|
||||
this.icon(IconName::CheckDouble).icon_color(Color::Success)
|
||||
}
|
||||
acp::PermissionOptionKind::RejectOnce => {
|
||||
this.icon(IconName::Close).icon_color(Color::Error)
|
||||
}
|
||||
acp::PermissionOptionKind::RejectAlways => {
|
||||
this.icon(IconName::Close).icon_color(Color::Error)
|
||||
}
|
||||
|
||||
seen_kinds.push(option.kind);
|
||||
|
||||
this.key_binding(
|
||||
KeyBinding::for_action_in(action, &self.focus_handle, window, cx)
|
||||
.map(|kb| kb.size(rems_from_px(10.))),
|
||||
)
|
||||
})
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon_size(IconSize::XSmall)
|
||||
@@ -2549,7 +2463,7 @@ impl AcpThreadView {
|
||||
);
|
||||
}
|
||||
}))
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
fn render_diff_loading(&self, cx: &Context<Self>) -> AnyElement {
|
||||
@@ -3810,15 +3724,6 @@ impl AcpThreadView {
|
||||
.on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
|
||||
if let Some(profile_selector) = this.profile_selector.as_ref() {
|
||||
profile_selector.read(cx).menu_handle().toggle(window, cx);
|
||||
} else if let Some(mode_selector) = this.mode_selector() {
|
||||
mode_selector.read(cx).menu_handle().toggle(window, cx);
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
|
||||
if let Some(mode_selector) = this.mode_selector() {
|
||||
mode_selector.update(cx, |mode_selector, cx| {
|
||||
mode_selector.cycle_mode(window, cx);
|
||||
});
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
|
||||
@@ -3885,7 +3790,6 @@ impl AcpThreadView {
|
||||
.gap_1()
|
||||
.children(self.render_token_usage(cx))
|
||||
.children(self.profile_selector.clone())
|
||||
.children(self.mode_selector().cloned())
|
||||
.children(self.model_selector.clone())
|
||||
.child(self.render_send_button(cx)),
|
||||
),
|
||||
@@ -3996,42 +3900,6 @@ impl AcpThreadView {
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
|
||||
}
|
||||
|
||||
fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowOnce, window, cx);
|
||||
}
|
||||
|
||||
fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.authorize_pending_tool_call(acp::PermissionOptionKind::RejectOnce, window, cx);
|
||||
}
|
||||
|
||||
fn authorize_pending_tool_call(
|
||||
&mut self,
|
||||
kind: acp::PermissionOptionKind,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<()> {
|
||||
let thread = self.thread()?.read(cx);
|
||||
let tool_call = thread.first_tool_awaiting_confirmation()?;
|
||||
let ToolCallStatus::WaitingForConfirmation { options, .. } = &tool_call.status else {
|
||||
return None;
|
||||
};
|
||||
let option = options.iter().find(|o| o.kind == kind)?;
|
||||
|
||||
self.authorize_tool_call(
|
||||
tool_call.id.clone(),
|
||||
option.id.clone(),
|
||||
option.kind,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn render_burn_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let thread = self.as_native_thread(cx)?.read(cx);
|
||||
|
||||
@@ -5137,9 +5005,6 @@ impl AcpThreadView {
|
||||
cloud_llm_client::Plan::ZedProTrial | cloud_llm_client::Plan::ZedFree => {
|
||||
"Upgrade to Zed Pro for more prompts."
|
||||
}
|
||||
cloud_llm_client::Plan::ZedProV2
|
||||
| cloud_llm_client::Plan::ZedProTrialV2
|
||||
| cloud_llm_client::Plan::ZedFreeV2 => "",
|
||||
};
|
||||
|
||||
Callout::new()
|
||||
@@ -5353,9 +5218,6 @@ impl Render for AcpThreadView {
|
||||
.on_action(cx.listener(Self::toggle_burn_mode))
|
||||
.on_action(cx.listener(Self::keep_all))
|
||||
.on_action(cx.listener(Self::reject_all))
|
||||
.on_action(cx.listener(Self::allow_always))
|
||||
.on_action(cx.listener(Self::allow_once))
|
||||
.on_action(cx.listener(Self::reject_once))
|
||||
.track_focus(&self.focus_handle)
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.child(match &self.thread_state {
|
||||
|
||||
@@ -515,11 +515,9 @@ impl AgentConfiguration {
|
||||
.blend(cx.theme().colors().text_accent.opacity(0.2));
|
||||
|
||||
let (plan_name, label_color, bg_color) = match plan {
|
||||
Plan::ZedFree | Plan::ZedFreeV2 => ("Free", Color::Default, free_chip_bg),
|
||||
Plan::ZedProTrial | Plan::ZedProTrialV2 => {
|
||||
("Pro Trial", Color::Accent, pro_chip_bg)
|
||||
}
|
||||
Plan::ZedPro | Plan::ZedProV2 => ("Pro", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
|
||||
Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
|
||||
};
|
||||
|
||||
Chip::new(plan_name.to_string())
|
||||
@@ -1322,7 +1320,6 @@ async fn open_new_agent_servers_entry_in_settings_editor(
|
||||
args: vec![],
|
||||
env: Some(HashMap::default()),
|
||||
},
|
||||
default_mode: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1529,8 +1529,7 @@ impl AgentDiff {
|
||||
| AcpThreadEvent::ToolAuthorizationRequired
|
||||
| AcpThreadEvent::PromptCapabilitiesUpdated
|
||||
| AcpThreadEvent::AvailableCommandsUpdated(_)
|
||||
| AcpThreadEvent::Retry(_)
|
||||
| AcpThreadEvent::ModeUpdated(_) => {}
|
||||
| AcpThreadEvent::Retry(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2538,7 +2538,7 @@ impl AgentPanel {
|
||||
}
|
||||
},
|
||||
)
|
||||
.anchor(Corner::TopRight)
|
||||
.anchor(Corner::TopLeft)
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu({
|
||||
let workspace = self.workspace.clone();
|
||||
@@ -2717,13 +2717,10 @@ impl AgentPanel {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_agent_thread(
|
||||
AgentType::Custom {
|
||||
name: agent_name.clone().into(),
|
||||
command: custom_settings
|
||||
.get(&agent_name.0)
|
||||
.map(|settings| {
|
||||
settings.command.clone()
|
||||
})
|
||||
.unwrap_or(placeholder_command()),
|
||||
name: agent_name
|
||||
.clone()
|
||||
.into(),
|
||||
command: custom_settings.get(&agent_name.0).map(|settings| settings.command.clone()).unwrap_or(placeholder_command())
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
@@ -3008,9 +3005,6 @@ impl AgentPanel {
|
||||
_ => {
|
||||
let history_is_empty = if cx.has_flag::<GeminiAndNativeFeatureFlag>() {
|
||||
self.acp_history_store.read(cx).is_empty(cx)
|
||||
&& self
|
||||
.history_store
|
||||
.update(cx, |store, cx| store.recent_entries(1, cx).is_empty())
|
||||
} else {
|
||||
self.history_store
|
||||
.update(cx, |store, cx| store.recent_entries(1, cx).is_empty())
|
||||
@@ -3072,8 +3066,6 @@ impl AgentPanel {
|
||||
return None;
|
||||
}
|
||||
|
||||
let plan = self.user_store.read(cx).plan()?;
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.absolute()
|
||||
@@ -3082,18 +3074,15 @@ impl AgentPanel {
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.opacity(0.85)
|
||||
.block_mouse_except_scroll()
|
||||
.child(EndTrialUpsell::new(
|
||||
plan,
|
||||
Arc::new({
|
||||
let this = cx.entity();
|
||||
move |_, cx| {
|
||||
this.update(cx, |_this, cx| {
|
||||
TrialEndUpsell::set_dismissed(true, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
)),
|
||||
.child(EndTrialUpsell::new(Arc::new({
|
||||
let this = cx.entity();
|
||||
move |_, cx| {
|
||||
this.update(cx, |_this, cx| {
|
||||
TrialEndUpsell::set_dismissed(true, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3529,7 +3518,6 @@ impl AgentPanel {
|
||||
let error_message = match plan {
|
||||
Plan::ZedPro => "Upgrade to usage-based billing for more prompts.",
|
||||
Plan::ZedProTrial | Plan::ZedFree => "Upgrade to Zed Pro for more prompts.",
|
||||
Plan::ZedProV2 | Plan::ZedProTrialV2 | Plan::ZedFreeV2 => "",
|
||||
};
|
||||
|
||||
Callout::new()
|
||||
|
||||
@@ -72,10 +72,8 @@ actions!(
|
||||
ToggleOptionsMenu,
|
||||
/// Deletes the recently opened thread from history.
|
||||
DeleteRecentlyOpenThread,
|
||||
/// Toggles the profile or mode selector for switching between agent profiles.
|
||||
/// Toggles the profile selector for switching between agent profiles.
|
||||
ToggleProfileSelector,
|
||||
/// Cycles through available session modes.
|
||||
CycleModeSelector,
|
||||
/// Removes all added context from the current conversation.
|
||||
RemoveAllContext,
|
||||
/// Expands the message editor to full size.
|
||||
@@ -116,12 +114,6 @@ actions!(
|
||||
RejectAll,
|
||||
/// Keeps all suggestions or changes.
|
||||
KeepAll,
|
||||
/// Allow this operation only this time.
|
||||
AllowOnce,
|
||||
/// Allow this operation and remember the choice.
|
||||
AllowAlways,
|
||||
/// Reject this operation only this time.
|
||||
RejectOnce,
|
||||
/// Follows the agent's suggestions.
|
||||
Follow,
|
||||
/// Resets the trial upsell notification.
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::buffer_codegen::BufferCodegen;
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
|
||||
use crate::terminal_codegen::TerminalCodegen;
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||
use crate::{RemoveAllContext, ToggleContextPicker};
|
||||
use agent::{
|
||||
context_store::ContextStore,
|
||||
thread_store::{TextThreadStore, ThreadStore},
|
||||
};
|
||||
use client::ErrorExt;
|
||||
use collections::VecDeque;
|
||||
use db::kvp::Dismissable;
|
||||
use editor::actions::Paste;
|
||||
use editor::display_map::EditorMargins;
|
||||
use editor::{
|
||||
ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer,
|
||||
actions::{MoveDown, MoveUp},
|
||||
};
|
||||
use feature_flags::{FeatureFlagAppExt as _, ZedProFeatureFlag};
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
Subscription, TextStyle, WeakEntity, Window,
|
||||
AnyElement, App, ClickEvent, Context, CursorStyle, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window, anchored, deferred, point,
|
||||
};
|
||||
use language_model::{LanguageModel, LanguageModelRegistry};
|
||||
use parking_lot::Mutex;
|
||||
@@ -22,19 +33,12 @@ use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use theme::ThemeSettings;
|
||||
use ui::utils::WithRemSize;
|
||||
use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
|
||||
use ui::{
|
||||
CheckboxWithLabel, IconButtonShape, KeyBinding, Popover, PopoverMenuHandle, Tooltip, prelude::*,
|
||||
};
|
||||
use workspace::Workspace;
|
||||
use zed_actions::agent::ToggleModelSelector;
|
||||
|
||||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::buffer_codegen::BufferCodegen;
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
|
||||
use crate::terminal_codegen::TerminalCodegen;
|
||||
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
|
||||
use crate::{RemoveAllContext, ToggleContextPicker};
|
||||
|
||||
pub struct PromptEditor<T> {
|
||||
pub editor: Entity<Editor>,
|
||||
mode: PromptEditorMode,
|
||||
@@ -140,16 +144,47 @@ impl<T: 'static> Render for PromptEditor<T> {
|
||||
};
|
||||
|
||||
let error_message = SharedString::from(error.to_string());
|
||||
el.child(
|
||||
div()
|
||||
.id("error")
|
||||
.tooltip(Tooltip::text(error_message))
|
||||
.child(
|
||||
Icon::new(IconName::XCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
),
|
||||
)
|
||||
if error.error_code() == proto::ErrorCode::RateLimitExceeded
|
||||
&& cx.has_flag::<ZedProFeatureFlag>()
|
||||
{
|
||||
el.child(
|
||||
v_flex()
|
||||
.child(
|
||||
IconButton::new(
|
||||
"rate-limit-error",
|
||||
IconName::XCircle,
|
||||
)
|
||||
.toggle_state(self.show_rate_limit_notice)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.on_click(
|
||||
cx.listener(Self::toggle_rate_limit_notice),
|
||||
),
|
||||
)
|
||||
.children(self.show_rate_limit_notice.then(|| {
|
||||
deferred(
|
||||
anchored()
|
||||
.position_mode(
|
||||
gpui::AnchoredPositionMode::Local,
|
||||
)
|
||||
.position(point(px(0.), px(24.)))
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.child(self.render_rate_limit_notice(cx)),
|
||||
)
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
el.child(
|
||||
div()
|
||||
.id("error")
|
||||
.tooltip(Tooltip::text(error_message))
|
||||
.child(
|
||||
Icon::new(IconName::XCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -275,6 +310,19 @@ impl<T: 'static> PromptEditor<T> {
|
||||
crate::active_thread::attach_pasted_images_as_context(&self.context_store, cx);
|
||||
}
|
||||
|
||||
fn toggle_rate_limit_notice(
|
||||
&mut self,
|
||||
_: &ClickEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_rate_limit_notice = !self.show_rate_limit_notice;
|
||||
if self.show_rate_limit_notice {
|
||||
window.focus(&self.editor.focus_handle(cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_prompt_editor_events(
|
||||
&mut self,
|
||||
_: &Entity<Editor>,
|
||||
@@ -659,6 +707,61 @@ impl<T: 'static> PromptEditor<T> {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_rate_limit_notice(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Popover::new().child(
|
||||
v_flex()
|
||||
.occlude()
|
||||
.p_2()
|
||||
.child(
|
||||
Label::new("Out of Tokens")
|
||||
.size(LabelSize::Small)
|
||||
.weight(FontWeight::BOLD),
|
||||
)
|
||||
.child(Label::new(
|
||||
"Try Zed Pro for higher limits, a wider range of models, and more.",
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(CheckboxWithLabel::new(
|
||||
"dont-show-again",
|
||||
Label::new("Don't show again"),
|
||||
if RateLimitNotice::dismissed() {
|
||||
ui::ToggleState::Selected
|
||||
} else {
|
||||
ui::ToggleState::Unselected
|
||||
},
|
||||
|selection, _, cx| {
|
||||
let is_dismissed = match selection {
|
||||
ui::ToggleState::Unselected => false,
|
||||
ui::ToggleState::Indeterminate => return,
|
||||
ui::ToggleState::Selected => true,
|
||||
};
|
||||
|
||||
RateLimitNotice::set_dismissed(is_dismissed, cx);
|
||||
},
|
||||
))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("dismiss", "Dismiss")
|
||||
.style(ButtonStyle::Transparent)
|
||||
.on_click(cx.listener(Self::toggle_rate_limit_notice)),
|
||||
)
|
||||
.child(Button::new("more-info", "More Info").on_click(
|
||||
|_event, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(zed_actions::OpenAccountSettings),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_editor(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
@@ -875,7 +978,15 @@ impl PromptEditor<BufferCodegen> {
|
||||
self.editor
|
||||
.update(cx, |editor, _| editor.set_read_only(false));
|
||||
}
|
||||
CodegenStatus::Error(_error) => {
|
||||
CodegenStatus::Error(error) => {
|
||||
if cx.has_flag::<ZedProFeatureFlag>()
|
||||
&& error.error_code() == proto::ErrorCode::RateLimitExceeded
|
||||
&& !RateLimitNotice::dismissed()
|
||||
{
|
||||
self.show_rate_limit_notice = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
self.edited_since_done = false;
|
||||
self.editor
|
||||
.update(cx, |editor, _| editor.set_read_only(false));
|
||||
@@ -1078,6 +1189,12 @@ impl PromptEditor<TerminalCodegen> {
|
||||
}
|
||||
}
|
||||
|
||||
struct RateLimitNotice;
|
||||
|
||||
impl Dismissable for RateLimitNotice {
|
||||
const KEY: &'static str = "dismissed-rate-limit-notice";
|
||||
}
|
||||
|
||||
pub enum CodegenStatus {
|
||||
Idle,
|
||||
Pending,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use cloud_llm_client::Plan;
|
||||
use collections::{HashSet, IndexMap};
|
||||
use feature_flags::ZedProFeatureFlag;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
|
||||
use language_model::{
|
||||
@@ -11,6 +13,8 @@ use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use ui::{ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
|
||||
|
||||
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
|
||||
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
|
||||
|
||||
@@ -527,9 +531,13 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
|
||||
fn render_footer(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
use feature_flags::FeatureFlagAppExt;
|
||||
|
||||
let plan = Plan::ZedPro;
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
@@ -538,6 +546,28 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
.p_1()
|
||||
.gap_4()
|
||||
.justify_between()
|
||||
.when(cx.has_flag::<ZedProFeatureFlag>(), |this| {
|
||||
this.child(match plan {
|
||||
Plan::ZedPro => Button::new("zed-pro", "Zed Pro")
|
||||
.icon(IconName::ZedAssistant)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.on_click(|_, window, cx| {
|
||||
window
|
||||
.dispatch_action(Box::new(zed_actions::OpenAccountSettings), cx)
|
||||
}),
|
||||
Plan::ZedFree | Plan::ZedProTrial => Button::new(
|
||||
"try-pro",
|
||||
if plan == Plan::ZedProTrial {
|
||||
"Upgrade to Pro"
|
||||
} else {
|
||||
"Try Pro"
|
||||
},
|
||||
)
|
||||
.on_click(|_, _, cx| cx.open_url(TRY_ZED_PRO_URL)),
|
||||
})
|
||||
})
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.icon(IconName::Settings)
|
||||
|
||||
@@ -6,8 +6,8 @@ use gpui::{Action, Entity, FocusHandle, Subscription, prelude::*};
|
||||
use settings::{Settings as _, SettingsStore, update_settings_file};
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, TintColor,
|
||||
Tooltip, prelude::*,
|
||||
ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, Tooltip,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
/// Trait for types that can provide and manage agent profiles
|
||||
@@ -170,8 +170,7 @@ impl Render for ProfileSelector {
|
||||
.icon(IconName::ChevronDown)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.icon_position(IconPosition::End)
|
||||
.icon_color(Color::Muted)
|
||||
.selected_style(ButtonStyle::Tinted(TintColor::Accent));
|
||||
.icon_color(Color::Muted);
|
||||
|
||||
PopoverMenu::new("profile-selector")
|
||||
.trigger_with_tooltip(trigger_button, {
|
||||
@@ -196,10 +195,6 @@ impl Render for ProfileSelector {
|
||||
.menu(move |window, cx| {
|
||||
Some(this.update(cx, |this, cx| this.build_context_menu(window, cx)))
|
||||
})
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(-2.0),
|
||||
})
|
||||
.into_any_element()
|
||||
} else {
|
||||
Button::new("tools-not-supported-button", "Tools Unsupported")
|
||||
|
||||
@@ -2,27 +2,24 @@ use std::sync::Arc;
|
||||
|
||||
use ai_onboarding::{AgentPanelOnboardingCard, PlanDefinitions};
|
||||
use client::zed_urls;
|
||||
use cloud_llm_client::Plan;
|
||||
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
|
||||
use ui::{Divider, Tooltip, prelude::*};
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct EndTrialUpsell {
|
||||
plan: Plan,
|
||||
dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>,
|
||||
}
|
||||
|
||||
impl EndTrialUpsell {
|
||||
pub fn new(plan: Plan, dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> Self {
|
||||
Self {
|
||||
plan,
|
||||
dismiss_upsell,
|
||||
}
|
||||
pub fn new(dismiss_upsell: Arc<dyn Fn(&mut Window, &mut App)>) -> Self {
|
||||
Self { dismiss_upsell }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for EndTrialUpsell {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
let pro_section = v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
@@ -36,7 +33,7 @@ impl RenderOnce for EndTrialUpsell {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(self.plan.is_v2(), false))
|
||||
.child(plan_definitions.pro_plan(false))
|
||||
.child(
|
||||
Button::new("cta-button", "Upgrade to Zed Pro")
|
||||
.full_width()
|
||||
@@ -67,7 +64,7 @@ impl RenderOnce for EndTrialUpsell {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.free_plan(self.plan.is_v2()));
|
||||
.child(plan_definitions.free_plan());
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(Headline::new("Your Zed Pro Trial has expired"))
|
||||
@@ -112,7 +109,6 @@ impl Component for EndTrialUpsell {
|
||||
Some(
|
||||
v_flex()
|
||||
.child(EndTrialUpsell {
|
||||
plan: Plan::ZedFree,
|
||||
dismiss_upsell: Arc::new(|_, _| {}),
|
||||
})
|
||||
.into_any_element(),
|
||||
|
||||
@@ -38,20 +38,20 @@ impl RenderOnce for UsageCallout {
|
||||
|
||||
let (title, message, button_text, url) = if is_limit_reached {
|
||||
match self.plan {
|
||||
Plan::ZedFree | Plan::ZedFreeV2 => (
|
||||
Plan::ZedFree => (
|
||||
"Out of free prompts",
|
||||
"Upgrade to continue, wait for the next reset, or switch to API key."
|
||||
.to_string(),
|
||||
"Upgrade",
|
||||
zed_urls::account_url(cx),
|
||||
),
|
||||
Plan::ZedProTrial | Plan::ZedProTrialV2 => (
|
||||
Plan::ZedProTrial => (
|
||||
"Out of trial prompts",
|
||||
"Upgrade to Zed Pro to continue, or switch to API key.".to_string(),
|
||||
"Upgrade",
|
||||
zed_urls::account_url(cx),
|
||||
),
|
||||
Plan::ZedPro | Plan::ZedProV2 => (
|
||||
Plan::ZedPro => (
|
||||
"Out of included prompts",
|
||||
"Enable usage-based billing to continue.".to_string(),
|
||||
"Manage",
|
||||
|
||||
@@ -18,7 +18,6 @@ default = []
|
||||
client.workspace = true
|
||||
cloud_llm_client.workspace = true
|
||||
component.workspace = true
|
||||
feature_flags.workspace = true
|
||||
gpui.workspace = true
|
||||
language_model.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -18,7 +18,6 @@ pub use young_account_banner::YoungAccountBanner;
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use feature_flags::{BillingV2FeatureFlag, FeatureFlagAppExt as _};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement};
|
||||
use ui::{Divider, RegisterComponent, Tooltip, prelude::*};
|
||||
|
||||
@@ -85,8 +84,9 @@ impl ZedAiOnboarding {
|
||||
self
|
||||
}
|
||||
|
||||
fn render_sign_in_disclaimer(&self, cx: &mut App) -> AnyElement {
|
||||
fn render_sign_in_disclaimer(&self, _cx: &mut App) -> AnyElement {
|
||||
let signing_in = matches!(self.sign_in_status, SignInStatus::SigningIn);
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
@@ -96,7 +96,7 @@ impl ZedAiOnboarding {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(cx.has_flag::<BillingV2FeatureFlag>(), false))
|
||||
.child(plan_definitions.pro_plan(false))
|
||||
.child(
|
||||
Button::new("sign_in", "Try Zed Pro for Free")
|
||||
.disabled(signing_in)
|
||||
@@ -113,14 +113,17 @@ impl ZedAiOnboarding {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_free_plan_state(&self, is_v2: bool, cx: &mut App) -> AnyElement {
|
||||
fn render_free_plan_state(&self, cx: &mut App) -> AnyElement {
|
||||
let young_account_banner = YoungAccountBanner;
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
if self.account_too_young {
|
||||
v_flex()
|
||||
.relative()
|
||||
.max_w_full()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed AI"))
|
||||
.child(YoungAccountBanner)
|
||||
.child(young_account_banner)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
@@ -136,7 +139,7 @@ impl ZedAiOnboarding {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(is_v2, true))
|
||||
.child(plan_definitions.pro_plan(true))
|
||||
.child(
|
||||
Button::new("pro", "Get Started")
|
||||
.full_width()
|
||||
@@ -179,7 +182,7 @@ impl ZedAiOnboarding {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.free_plan(is_v2)),
|
||||
.child(plan_definitions.free_plan()),
|
||||
)
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
@@ -217,7 +220,7 @@ impl ZedAiOnboarding {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.pro_trial(is_v2, true))
|
||||
.child(plan_definitions.pro_trial(true))
|
||||
.child(
|
||||
Button::new("pro", "Start Free Trial")
|
||||
.full_width()
|
||||
@@ -235,7 +238,9 @@ impl ZedAiOnboarding {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_trial_state(&self, is_v2: bool, _cx: &mut App) -> AnyElement {
|
||||
fn render_trial_state(&self, _cx: &mut App) -> AnyElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
@@ -245,7 +250,7 @@ impl ZedAiOnboarding {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(PlanDefinitions.pro_trial(is_v2, false))
|
||||
.child(plan_definitions.pro_trial(false))
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
|this, dismiss_callback| {
|
||||
@@ -269,7 +274,9 @@ impl ZedAiOnboarding {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_pro_plan_state(&self, is_v2: bool, _cx: &mut App) -> AnyElement {
|
||||
fn render_pro_plan_state(&self, _cx: &mut App) -> AnyElement {
|
||||
let plan_definitions = PlanDefinitions;
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Headline::new("Welcome to Zed Pro"))
|
||||
@@ -278,7 +285,7 @@ impl ZedAiOnboarding {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(is_v2, false))
|
||||
.child(plan_definitions.pro_plan(false))
|
||||
.when_some(
|
||||
self.dismiss_onboarding.as_ref(),
|
||||
|this, dismiss_callback| {
|
||||
@@ -307,16 +314,9 @@ impl RenderOnce for ZedAiOnboarding {
|
||||
fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
|
||||
if matches!(self.sign_in_status, SignInStatus::SignedIn) {
|
||||
match self.plan {
|
||||
None => self.render_free_plan_state(cx.has_flag::<BillingV2FeatureFlag>(), cx),
|
||||
Some(plan @ (Plan::ZedFree | Plan::ZedFreeV2)) => {
|
||||
self.render_free_plan_state(plan.is_v2(), cx)
|
||||
}
|
||||
Some(plan @ (Plan::ZedProTrial | Plan::ZedProTrialV2)) => {
|
||||
self.render_trial_state(plan.is_v2(), cx)
|
||||
}
|
||||
Some(plan @ (Plan::ZedPro | Plan::ZedProV2)) => {
|
||||
self.render_pro_plan_state(plan.is_v2(), cx)
|
||||
}
|
||||
None | Some(Plan::ZedFree) => self.render_free_plan_state(cx),
|
||||
Some(Plan::ZedProTrial) => self.render_trial_state(cx),
|
||||
Some(Plan::ZedPro) => self.render_pro_plan_state(cx),
|
||||
}
|
||||
} else {
|
||||
self.render_sign_in_disclaimer(cx)
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::sync::Arc;
|
||||
|
||||
use client::{Client, UserStore, zed_urls};
|
||||
use cloud_llm_client::Plan;
|
||||
use feature_flags::{BillingV2FeatureFlag, FeatureFlagAppExt};
|
||||
use gpui::{AnyElement, App, Entity, IntoElement, RenderOnce, Window};
|
||||
use ui::{CommonAnimationExt, Divider, Vector, VectorName, prelude::*};
|
||||
|
||||
@@ -50,9 +49,8 @@ impl AiUpsellCard {
|
||||
|
||||
impl RenderOnce for AiUpsellCard {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_v2_plan = self
|
||||
.user_plan
|
||||
.map_or(cx.has_flag::<BillingV2FeatureFlag>(), |plan| plan.is_v2());
|
||||
let plan_definitions = PlanDefinitions;
|
||||
let young_account_banner = YoungAccountBanner;
|
||||
|
||||
let pro_section = v_flex()
|
||||
.flex_grow()
|
||||
@@ -69,7 +67,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(is_v2_plan, false));
|
||||
.child(plan_definitions.pro_plan(false));
|
||||
|
||||
let free_section = v_flex()
|
||||
.flex_grow()
|
||||
@@ -86,7 +84,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.free_plan(is_v2_plan));
|
||||
.child(plan_definitions.free_plan());
|
||||
|
||||
let grid_bg = h_flex()
|
||||
.absolute()
|
||||
@@ -171,11 +169,11 @@ impl RenderOnce for AiUpsellCard {
|
||||
|
||||
match self.sign_in_status {
|
||||
SignInStatus::SignedIn => match self.user_plan {
|
||||
None | Some(Plan::ZedFree | Plan::ZedFreeV2) => card
|
||||
None | Some(Plan::ZedFree) => card
|
||||
.child(Label::new("Try Zed AI").size(LabelSize::Large))
|
||||
.map(|this| {
|
||||
if self.account_too_young {
|
||||
this.child(YoungAccountBanner).child(
|
||||
this.child(young_account_banner).child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
@@ -190,7 +188,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(is_v2_plan, true))
|
||||
.child(plan_definitions.pro_plan(true))
|
||||
.child(
|
||||
Button::new("pro", "Get Started")
|
||||
.full_width()
|
||||
@@ -237,7 +235,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
)
|
||||
}
|
||||
}),
|
||||
Some(plan @ (Plan::ZedProTrial | Plan::ZedProTrialV2)) => card
|
||||
Some(Plan::ZedProTrial) => card
|
||||
.child(pro_trial_stamp)
|
||||
.child(Label::new("You're in the Zed Pro Trial").size(LabelSize::Large))
|
||||
.child(
|
||||
@@ -245,8 +243,8 @@ impl RenderOnce for AiUpsellCard {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(PlanDefinitions.pro_trial(plan.is_v2(), false)),
|
||||
Some(plan @ (Plan::ZedPro | Plan::ZedProV2)) => card
|
||||
.child(plan_definitions.pro_trial(false)),
|
||||
Some(Plan::ZedPro) => card
|
||||
.child(certified_user_stamp)
|
||||
.child(Label::new("You're in the Zed Pro plan").size(LabelSize::Large))
|
||||
.child(
|
||||
@@ -254,7 +252,7 @@ impl RenderOnce for AiUpsellCard {
|
||||
.color(Color::Muted)
|
||||
.mb_2(),
|
||||
)
|
||||
.child(PlanDefinitions.pro_plan(plan.is_v2(), false)),
|
||||
.child(plan_definitions.pro_plan(false)),
|
||||
},
|
||||
// Signed Out State
|
||||
_ => card
|
||||
|
||||
@@ -7,13 +7,13 @@ pub struct PlanDefinitions;
|
||||
impl PlanDefinitions {
|
||||
pub const AI_DESCRIPTION: &'static str = "Zed offers a complete agentic experience, with robust editing and reviewing features to collaborate with AI.";
|
||||
|
||||
pub fn free_plan(&self, _is_v2: bool) -> impl IntoElement {
|
||||
pub fn free_plan(&self) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(ListBulletItem::new("50 prompts with Claude models"))
|
||||
.child(ListBulletItem::new("2,000 accepted edit predictions"))
|
||||
}
|
||||
|
||||
pub fn pro_trial(&self, _is_v2: bool, period: bool) -> impl IntoElement {
|
||||
pub fn pro_trial(&self, period: bool) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(ListBulletItem::new("150 prompts with Claude models"))
|
||||
.child(ListBulletItem::new(
|
||||
@@ -26,7 +26,7 @@ impl PlanDefinitions {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pro_plan(&self, _is_v2: bool, price: bool) -> impl IntoElement {
|
||||
pub fn pro_plan(&self, price: bool) -> impl IntoElement {
|
||||
List::new()
|
||||
.child(ListBulletItem::new("500 prompts with Claude models"))
|
||||
.child(ListBulletItem::new(
|
||||
|
||||
@@ -1520,15 +1520,7 @@ impl EditAgentTest {
|
||||
selected_model: &SelectedModel,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Arc<dyn LanguageModel>> {
|
||||
cx.update(|cx| {
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
let provider = registry
|
||||
.provider(&selected_model.provider)
|
||||
.expect("Provider not found");
|
||||
provider.authenticate(cx)
|
||||
})?
|
||||
.await?;
|
||||
cx.update(|cx| {
|
||||
let (provider, model) = cx.update(|cx| {
|
||||
let models = LanguageModelRegistry::read_global(cx);
|
||||
let model = models
|
||||
.available_models(cx)
|
||||
@@ -1537,8 +1529,11 @@ impl EditAgentTest {
|
||||
&& model.id() == selected_model.model
|
||||
})
|
||||
.expect("Model not found");
|
||||
model
|
||||
})
|
||||
let provider = models.provider(&model.provider_id()).unwrap();
|
||||
(provider, model)
|
||||
})?;
|
||||
cx.update(|cx| provider.authenticate(cx))?.await?;
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
async fn eval(&self, eval: EvalInput, cx: &mut TestAppContext) -> Result<EvalOutput> {
|
||||
|
||||
@@ -119,11 +119,9 @@ struct AutoUpdateSetting(bool);
|
||||
///
|
||||
/// Default: true
|
||||
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize, SettingsUi, SettingsKey)]
|
||||
#[settings_key(None)]
|
||||
#[settings_ui(group = "Auto Update")]
|
||||
struct AutoUpdateSettingContent {
|
||||
pub auto_update: Option<bool>,
|
||||
}
|
||||
#[serde(transparent)]
|
||||
#[settings_key(key = "auto_update")]
|
||||
struct AutoUpdateSettingContent(bool);
|
||||
|
||||
impl Settings for AutoUpdateSetting {
|
||||
type FileContent = AutoUpdateSettingContent;
|
||||
@@ -136,22 +134,17 @@ impl Settings for AutoUpdateSetting {
|
||||
sources.user,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|value| value.and_then(|val| val.auto_update))
|
||||
.or(sources.default.auto_update)
|
||||
.ok_or_else(Self::missing_default)?;
|
||||
.find_map(|value| value.copied())
|
||||
.unwrap_or(*sources.default);
|
||||
|
||||
Ok(Self(auto_update))
|
||||
Ok(Self(auto_update.0))
|
||||
}
|
||||
|
||||
fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
|
||||
let mut cur = &mut Some(*current);
|
||||
vscode.enum_setting("update.mode", &mut cur, |s| match s {
|
||||
"none" | "manual" => Some(AutoUpdateSettingContent {
|
||||
auto_update: Some(false),
|
||||
}),
|
||||
_ => Some(AutoUpdateSettingContent {
|
||||
auto_update: Some(true),
|
||||
}),
|
||||
"none" | "manual" => Some(AutoUpdateSettingContent(false)),
|
||||
_ => Some(AutoUpdateSettingContent(true)),
|
||||
});
|
||||
*current = cur.unwrap();
|
||||
}
|
||||
@@ -564,7 +557,6 @@ impl AutoUpdater {
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.status = AutoUpdateStatus::Checking;
|
||||
log::info!("Auto Update: checking for updates");
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use auto_update::AutoUpdater;
|
||||
use client::proto::UpdateNotification;
|
||||
use editor::{Editor, MultiBuffer};
|
||||
use gpui::{App, Context, DismissEvent, Entity, Window, actions, prelude::*};
|
||||
use http_client::HttpClient;
|
||||
@@ -137,8 +138,6 @@ pub fn notify_if_app_was_updated(cx: &mut App) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct UpdateNotification;
|
||||
|
||||
let should_show_notification = updater.read(cx).should_show_update_notification(cx);
|
||||
cx.spawn(async move |cx| {
|
||||
let should_show_notification = should_show_notification.await?;
|
||||
|
||||
@@ -25,9 +25,11 @@ gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
postage.workspace = true
|
||||
rand.workspace = true
|
||||
release_channel.workspace = true
|
||||
rpc.workspace = true
|
||||
settings.workspace = true
|
||||
sum_tree.workspace = true
|
||||
text.workspace = true
|
||||
time.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod channel_buffer;
|
||||
mod channel_chat;
|
||||
mod channel_store;
|
||||
|
||||
use client::{Client, UserStore};
|
||||
@@ -6,6 +7,10 @@ use gpui::{App, Entity};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use channel_buffer::{ACKNOWLEDGE_DEBOUNCE_INTERVAL, ChannelBuffer, ChannelBufferEvent};
|
||||
pub use channel_chat::{
|
||||
ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, MessageParams,
|
||||
mentions_to_proto,
|
||||
};
|
||||
pub use channel_store::{Channel, ChannelEvent, ChannelMembership, ChannelStore};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -14,4 +19,5 @@ mod channel_store_tests;
|
||||
pub fn init(client: &Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
|
||||
channel_store::init(client, user_store, cx);
|
||||
channel_buffer::init(&client.clone().into());
|
||||
channel_chat::init(&client.clone().into());
|
||||
}
|
||||
|
||||
861
crates/channel/src/channel_chat.rs
Normal file
861
crates/channel/src/channel_chat.rs
Normal file
@@ -0,0 +1,861 @@
|
||||
use crate::{Channel, ChannelStore};
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::{
|
||||
ChannelId, Client, Subscription, TypedEnvelope, UserId, proto,
|
||||
user::{User, UserStore},
|
||||
};
|
||||
use collections::HashSet;
|
||||
use futures::lock::Mutex;
|
||||
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
|
||||
use rand::prelude::*;
|
||||
use rpc::AnyProtoClient;
|
||||
use std::{
|
||||
ops::{ControlFlow, Range},
|
||||
sync::Arc,
|
||||
};
|
||||
use sum_tree::{Bias, Dimensions, SumTree};
|
||||
use time::OffsetDateTime;
|
||||
use util::{ResultExt as _, TryFutureExt, post_inc};
|
||||
|
||||
pub struct ChannelChat {
|
||||
pub channel_id: ChannelId,
|
||||
messages: SumTree<ChannelMessage>,
|
||||
acknowledged_message_ids: HashSet<u64>,
|
||||
channel_store: Entity<ChannelStore>,
|
||||
loaded_all_messages: bool,
|
||||
last_acknowledged_id: Option<u64>,
|
||||
next_pending_message_id: usize,
|
||||
first_loaded_message_id: Option<u64>,
|
||||
user_store: Entity<UserStore>,
|
||||
rpc: Arc<Client>,
|
||||
outgoing_messages_lock: Arc<Mutex<()>>,
|
||||
rng: StdRng,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct MessageParams {
|
||||
pub text: String,
|
||||
pub mentions: Vec<(Range<usize>, UserId)>,
|
||||
pub reply_to_message_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChannelMessage {
|
||||
pub id: ChannelMessageId,
|
||||
pub body: String,
|
||||
pub timestamp: OffsetDateTime,
|
||||
pub sender: Arc<User>,
|
||||
pub nonce: u128,
|
||||
pub mentions: Vec<(Range<usize>, UserId)>,
|
||||
pub reply_to_message_id: Option<u64>,
|
||||
pub edited_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ChannelMessageId {
|
||||
Saved(u64),
|
||||
Pending(usize),
|
||||
}
|
||||
|
||||
impl From<ChannelMessageId> for Option<u64> {
|
||||
fn from(val: ChannelMessageId) -> Self {
|
||||
match val {
|
||||
ChannelMessageId::Saved(id) => Some(id),
|
||||
ChannelMessageId::Pending(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChannelMessageSummary {
|
||||
max_id: ChannelMessageId,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct Count(usize);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ChannelChatEvent {
|
||||
MessagesUpdated {
|
||||
old_range: Range<usize>,
|
||||
new_count: usize,
|
||||
},
|
||||
UpdateMessage {
|
||||
message_id: ChannelMessageId,
|
||||
message_ix: usize,
|
||||
},
|
||||
NewMessage {
|
||||
channel_id: ChannelId,
|
||||
message_id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventEmitter<ChannelChatEvent> for ChannelChat {}
|
||||
pub fn init(client: &AnyProtoClient) {
|
||||
client.add_entity_message_handler(ChannelChat::handle_message_sent);
|
||||
client.add_entity_message_handler(ChannelChat::handle_message_removed);
|
||||
client.add_entity_message_handler(ChannelChat::handle_message_updated);
|
||||
}
|
||||
|
||||
impl ChannelChat {
|
||||
pub async fn new(
|
||||
channel: Arc<Channel>,
|
||||
channel_store: Entity<ChannelStore>,
|
||||
user_store: Entity<UserStore>,
|
||||
client: Arc<Client>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Entity<Self>> {
|
||||
let channel_id = channel.id;
|
||||
let subscription = client.subscribe_to_entity(channel_id.0).unwrap();
|
||||
|
||||
let response = client
|
||||
.request(proto::JoinChannelChat {
|
||||
channel_id: channel_id.0,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let handle = cx.new(|cx| {
|
||||
cx.on_release(Self::release).detach();
|
||||
Self {
|
||||
channel_id: channel.id,
|
||||
user_store: user_store.clone(),
|
||||
channel_store,
|
||||
rpc: client.clone(),
|
||||
outgoing_messages_lock: Default::default(),
|
||||
messages: Default::default(),
|
||||
acknowledged_message_ids: Default::default(),
|
||||
loaded_all_messages: false,
|
||||
next_pending_message_id: 0,
|
||||
last_acknowledged_id: None,
|
||||
rng: StdRng::from_os_rng(),
|
||||
first_loaded_message_id: None,
|
||||
_subscription: subscription.set_entity(&cx.entity(), &cx.to_async()),
|
||||
}
|
||||
})?;
|
||||
Self::handle_loaded_messages(
|
||||
handle.downgrade(),
|
||||
user_store,
|
||||
client,
|
||||
response.messages,
|
||||
response.done,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn release(&mut self, _: &mut App) {
|
||||
self.rpc
|
||||
.send(proto::LeaveChannelChat {
|
||||
channel_id: self.channel_id.0,
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
pub fn channel(&self, cx: &App) -> Option<Arc<Channel>> {
|
||||
self.channel_store
|
||||
.read(cx)
|
||||
.channel_for_id(self.channel_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &Arc<Client> {
|
||||
&self.rpc
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
message: MessageParams,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<Task<Result<u64>>> {
|
||||
anyhow::ensure!(
|
||||
!message.text.trim().is_empty(),
|
||||
"message body can't be empty"
|
||||
);
|
||||
|
||||
let current_user = self
|
||||
.user_store
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.context("current_user is not present")?;
|
||||
|
||||
let channel_id = self.channel_id;
|
||||
let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id));
|
||||
let nonce = self.rng.random();
|
||||
self.insert_messages(
|
||||
SumTree::from_item(
|
||||
ChannelMessage {
|
||||
id: pending_id,
|
||||
body: message.text.clone(),
|
||||
sender: current_user,
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
mentions: message.mentions.clone(),
|
||||
nonce,
|
||||
reply_to_message_id: message.reply_to_message_id,
|
||||
edited_at: None,
|
||||
},
|
||||
&(),
|
||||
),
|
||||
cx,
|
||||
);
|
||||
let user_store = self.user_store.clone();
|
||||
let rpc = self.rpc.clone();
|
||||
let outgoing_messages_lock = self.outgoing_messages_lock.clone();
|
||||
|
||||
// todo - handle messages that fail to send (e.g. >1024 chars)
|
||||
Ok(cx.spawn(async move |this, cx| {
|
||||
let outgoing_message_guard = outgoing_messages_lock.lock().await;
|
||||
let request = rpc.request(proto::SendChannelMessage {
|
||||
channel_id: channel_id.0,
|
||||
body: message.text,
|
||||
nonce: Some(nonce.into()),
|
||||
mentions: mentions_to_proto(&message.mentions),
|
||||
reply_to_message_id: message.reply_to_message_id,
|
||||
});
|
||||
let response = request.await?;
|
||||
drop(outgoing_message_guard);
|
||||
let response = response.message.context("invalid message")?;
|
||||
let id = response.id;
|
||||
let message = ChannelMessage::from_proto(response, &user_store, cx).await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
if this.first_loaded_message_id.is_none() {
|
||||
this.first_loaded_message_id = Some(id);
|
||||
}
|
||||
})?;
|
||||
Ok(id)
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn remove_message(&mut self, id: u64, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let response = self.rpc.request(proto::RemoveChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: id,
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
response.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.message_removed(id, cx);
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_message(
|
||||
&mut self,
|
||||
id: u64,
|
||||
message: MessageParams,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<Task<Result<()>>> {
|
||||
self.message_update(
|
||||
ChannelMessageId::Saved(id),
|
||||
message.text.clone(),
|
||||
message.mentions.clone(),
|
||||
Some(OffsetDateTime::now_utc()),
|
||||
cx,
|
||||
);
|
||||
|
||||
let nonce: u128 = self.rng.random();
|
||||
|
||||
let request = self.rpc.request(proto::UpdateChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: id,
|
||||
body: message.text,
|
||||
nonce: Some(nonce.into()),
|
||||
mentions: mentions_to_proto(&message.mentions),
|
||||
});
|
||||
Ok(cx.spawn(async move |_, _| {
|
||||
request.await?;
|
||||
Ok(())
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn load_more_messages(&mut self, cx: &mut Context<Self>) -> Option<Task<Option<()>>> {
|
||||
if self.loaded_all_messages {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rpc = self.rpc.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let channel_id = self.channel_id;
|
||||
let before_message_id = self.first_loaded_message_id()?;
|
||||
Some(cx.spawn(async move |this, cx| {
|
||||
async move {
|
||||
let response = rpc
|
||||
.request(proto::GetChannelMessages {
|
||||
channel_id: channel_id.0,
|
||||
before_message_id,
|
||||
})
|
||||
.await?;
|
||||
Self::handle_loaded_messages(
|
||||
this,
|
||||
user_store,
|
||||
rpc,
|
||||
response.messages,
|
||||
response.done,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err()
|
||||
.await
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn first_loaded_message_id(&mut self) -> Option<u64> {
|
||||
self.first_loaded_message_id
|
||||
}
|
||||
|
||||
/// Load a message by its id, if it's already stored locally.
|
||||
pub fn find_loaded_message(&self, id: u64) -> Option<&ChannelMessage> {
|
||||
self.messages.iter().find(|message| match message.id {
|
||||
ChannelMessageId::Saved(message_id) => message_id == id,
|
||||
ChannelMessageId::Pending(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load all of the chat messages since a certain message id.
|
||||
///
|
||||
/// For now, we always maintain a suffix of the channel's messages.
|
||||
pub async fn load_history_since_message(
|
||||
chat: Entity<Self>,
|
||||
message_id: u64,
|
||||
mut cx: AsyncApp,
|
||||
) -> Option<usize> {
|
||||
loop {
|
||||
let step = chat
|
||||
.update(&mut cx, |chat, cx| {
|
||||
if let Some(first_id) = chat.first_loaded_message_id()
|
||||
&& first_id <= message_id
|
||||
{
|
||||
let mut cursor = chat
|
||||
.messages
|
||||
.cursor::<Dimensions<ChannelMessageId, Count>>(&());
|
||||
let message_id = ChannelMessageId::Saved(message_id);
|
||||
cursor.seek(&message_id, Bias::Left);
|
||||
return ControlFlow::Break(
|
||||
if cursor
|
||||
.item()
|
||||
.is_some_and(|message| message.id == message_id)
|
||||
{
|
||||
Some(cursor.start().1.0)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
}
|
||||
ControlFlow::Continue(chat.load_more_messages(cx))
|
||||
})
|
||||
.log_err()?;
|
||||
match step {
|
||||
ControlFlow::Break(ix) => return ix,
|
||||
ControlFlow::Continue(task) => task?.await?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acknowledge_last_message(&mut self, cx: &mut Context<Self>) {
|
||||
if let ChannelMessageId::Saved(latest_message_id) = self.messages.summary().max_id
|
||||
&& self
|
||||
.last_acknowledged_id
|
||||
.is_none_or(|acknowledged_id| acknowledged_id < latest_message_id)
|
||||
{
|
||||
self.rpc
|
||||
.send(proto::AckChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: latest_message_id,
|
||||
})
|
||||
.ok();
|
||||
self.last_acknowledged_id = Some(latest_message_id);
|
||||
self.channel_store.update(cx, |store, cx| {
|
||||
store.acknowledge_message_id(self.channel_id, latest_message_id, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_loaded_messages(
|
||||
this: WeakEntity<Self>,
|
||||
user_store: Entity<UserStore>,
|
||||
rpc: Arc<Client>,
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
loaded_all_messages: bool,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
let loaded_messages = messages_from_proto(proto_messages, &user_store, cx).await?;
|
||||
|
||||
let first_loaded_message_id = loaded_messages.first().map(|m| m.id);
|
||||
let loaded_message_ids = this.read_with(cx, |this, _| {
|
||||
let mut loaded_message_ids: HashSet<u64> = HashSet::default();
|
||||
for message in loaded_messages.iter() {
|
||||
if let Some(saved_message_id) = message.id.into() {
|
||||
loaded_message_ids.insert(saved_message_id);
|
||||
}
|
||||
}
|
||||
for message in this.messages.iter() {
|
||||
if let Some(saved_message_id) = message.id.into() {
|
||||
loaded_message_ids.insert(saved_message_id);
|
||||
}
|
||||
}
|
||||
loaded_message_ids
|
||||
})?;
|
||||
|
||||
let missing_ancestors = loaded_messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
if let Some(ancestor_id) = message.reply_to_message_id
|
||||
&& !loaded_message_ids.contains(&ancestor_id)
|
||||
{
|
||||
return Some(ancestor_id);
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let loaded_ancestors = if missing_ancestors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let response = rpc
|
||||
.request(proto::GetChannelMessagesById {
|
||||
message_ids: missing_ancestors,
|
||||
})
|
||||
.await?;
|
||||
Some(messages_from_proto(response.messages, &user_store, cx).await?)
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.first_loaded_message_id = first_loaded_message_id.and_then(|msg_id| msg_id.into());
|
||||
this.loaded_all_messages = loaded_all_messages;
|
||||
this.insert_messages(loaded_messages, cx);
|
||||
if let Some(loaded_ancestors) = loaded_ancestors {
|
||||
this.insert_messages(loaded_ancestors, cx);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rejoin(&mut self, cx: &mut Context<Self>) {
|
||||
let user_store = self.user_store.clone();
|
||||
let rpc = self.rpc.clone();
|
||||
let channel_id = self.channel_id;
|
||||
cx.spawn(async move |this, cx| {
|
||||
async move {
|
||||
let response = rpc
|
||||
.request(proto::JoinChannelChat {
|
||||
channel_id: channel_id.0,
|
||||
})
|
||||
.await?;
|
||||
Self::handle_loaded_messages(
|
||||
this.clone(),
|
||||
user_store.clone(),
|
||||
rpc.clone(),
|
||||
response.messages,
|
||||
response.done,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pending_messages = this.read_with(cx, |this, _| {
|
||||
this.pending_messages().cloned().collect::<Vec<_>>()
|
||||
})?;
|
||||
|
||||
for pending_message in pending_messages {
|
||||
let request = rpc.request(proto::SendChannelMessage {
|
||||
channel_id: channel_id.0,
|
||||
body: pending_message.body,
|
||||
mentions: mentions_to_proto(&pending_message.mentions),
|
||||
nonce: Some(pending_message.nonce.into()),
|
||||
reply_to_message_id: pending_message.reply_to_message_id,
|
||||
});
|
||||
let response = request.await?;
|
||||
let message = ChannelMessage::from_proto(
|
||||
response.message.context("invalid message")?,
|
||||
&user_store,
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.log_err()
|
||||
.await
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn message_count(&self) -> usize {
|
||||
self.messages.summary().count
|
||||
}
|
||||
|
||||
pub fn messages(&self) -> &SumTree<ChannelMessage> {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
pub fn message(&self, ix: usize) -> &ChannelMessage {
|
||||
let mut cursor = self.messages.cursor::<Count>(&());
|
||||
cursor.seek(&Count(ix), Bias::Right);
|
||||
cursor.item().unwrap()
|
||||
}
|
||||
|
||||
pub fn acknowledge_message(&mut self, id: u64) {
|
||||
if self.acknowledged_message_ids.insert(id) {
|
||||
self.rpc
|
||||
.send(proto::AckChannelMessage {
|
||||
channel_id: self.channel_id.0,
|
||||
message_id: id,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn messages_in_range(&self, range: Range<usize>) -> impl Iterator<Item = &ChannelMessage> {
|
||||
let mut cursor = self.messages.cursor::<Count>(&());
|
||||
cursor.seek(&Count(range.start), Bias::Right);
|
||||
cursor.take(range.len())
|
||||
}
|
||||
|
||||
pub fn pending_messages(&self) -> impl Iterator<Item = &ChannelMessage> {
|
||||
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
|
||||
cursor.seek(&ChannelMessageId::Pending(0), Bias::Left);
|
||||
cursor
|
||||
}
|
||||
|
||||
async fn handle_message_sent(
|
||||
this: Entity<Self>,
|
||||
message: TypedEnvelope<proto::ChannelMessageSent>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
|
||||
let message = message.payload.message.context("empty message")?;
|
||||
let message_id = message.id;
|
||||
|
||||
let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.insert_messages(SumTree::from_item(message, &()), cx);
|
||||
cx.emit(ChannelChatEvent::NewMessage {
|
||||
channel_id: this.channel_id,
|
||||
message_id,
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message_removed(
|
||||
this: Entity<Self>,
|
||||
message: TypedEnvelope<proto::RemoveChannelMessage>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.message_removed(message.payload.message_id, cx)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message_updated(
|
||||
this: Entity<Self>,
|
||||
message: TypedEnvelope<proto::ChannelMessageUpdate>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<()> {
|
||||
let user_store = this.read_with(&cx, |this, _| this.user_store.clone())?;
|
||||
let message = message.payload.message.context("empty message")?;
|
||||
|
||||
let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.message_update(
|
||||
message.id,
|
||||
message.body,
|
||||
message.mentions,
|
||||
message.edited_at,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_messages(&mut self, messages: SumTree<ChannelMessage>, cx: &mut Context<Self>) {
|
||||
if let Some((first_message, last_message)) = messages.first().zip(messages.last()) {
|
||||
let nonces = messages
|
||||
.cursor::<()>(&())
|
||||
.map(|m| m.nonce)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut old_cursor = self
|
||||
.messages
|
||||
.cursor::<Dimensions<ChannelMessageId, Count>>(&());
|
||||
let mut new_messages = old_cursor.slice(&first_message.id, Bias::Left);
|
||||
let start_ix = old_cursor.start().1.0;
|
||||
let removed_messages = old_cursor.slice(&last_message.id, Bias::Right);
|
||||
let removed_count = removed_messages.summary().count;
|
||||
let new_count = messages.summary().count;
|
||||
let end_ix = start_ix + removed_count;
|
||||
|
||||
new_messages.append(messages, &());
|
||||
|
||||
let mut ranges = Vec::<Range<usize>>::new();
|
||||
if new_messages.last().unwrap().is_pending() {
|
||||
new_messages.append(old_cursor.suffix(), &());
|
||||
} else {
|
||||
new_messages.append(
|
||||
old_cursor.slice(&ChannelMessageId::Pending(0), Bias::Left),
|
||||
&(),
|
||||
);
|
||||
|
||||
while let Some(message) = old_cursor.item() {
|
||||
let message_ix = old_cursor.start().1.0;
|
||||
if nonces.contains(&message.nonce) {
|
||||
if ranges.last().is_some_and(|r| r.end == message_ix) {
|
||||
ranges.last_mut().unwrap().end += 1;
|
||||
} else {
|
||||
ranges.push(message_ix..message_ix + 1);
|
||||
}
|
||||
} else {
|
||||
new_messages.push(message.clone(), &());
|
||||
}
|
||||
old_cursor.next();
|
||||
}
|
||||
}
|
||||
|
||||
drop(old_cursor);
|
||||
self.messages = new_messages;
|
||||
|
||||
for range in ranges.into_iter().rev() {
|
||||
cx.emit(ChannelChatEvent::MessagesUpdated {
|
||||
old_range: range,
|
||||
new_count: 0,
|
||||
});
|
||||
}
|
||||
cx.emit(ChannelChatEvent::MessagesUpdated {
|
||||
old_range: start_ix..end_ix,
|
||||
new_count,
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn message_removed(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
|
||||
let mut messages = cursor.slice(&ChannelMessageId::Saved(id), Bias::Left);
|
||||
if let Some(item) = cursor.item()
|
||||
&& item.id == ChannelMessageId::Saved(id)
|
||||
{
|
||||
let deleted_message_ix = messages.summary().count;
|
||||
cursor.next();
|
||||
messages.append(cursor.suffix(), &());
|
||||
drop(cursor);
|
||||
self.messages = messages;
|
||||
|
||||
// If the message that was deleted was the last acknowledged message,
|
||||
// replace the acknowledged message with an earlier one.
|
||||
self.channel_store.update(cx, |store, _| {
|
||||
let summary = self.messages.summary();
|
||||
if summary.count == 0 {
|
||||
store.set_acknowledged_message_id(self.channel_id, None);
|
||||
} else if deleted_message_ix == summary.count
|
||||
&& let ChannelMessageId::Saved(id) = summary.max_id
|
||||
{
|
||||
store.set_acknowledged_message_id(self.channel_id, Some(id));
|
||||
}
|
||||
});
|
||||
|
||||
cx.emit(ChannelChatEvent::MessagesUpdated {
|
||||
old_range: deleted_message_ix..deleted_message_ix + 1,
|
||||
new_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn message_update(
|
||||
&mut self,
|
||||
id: ChannelMessageId,
|
||||
body: String,
|
||||
mentions: Vec<(Range<usize>, u64)>,
|
||||
edited_at: Option<OffsetDateTime>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut cursor = self.messages.cursor::<ChannelMessageId>(&());
|
||||
let mut messages = cursor.slice(&id, Bias::Left);
|
||||
let ix = messages.summary().count;
|
||||
|
||||
if let Some(mut message_to_update) = cursor.item().cloned() {
|
||||
message_to_update.body = body;
|
||||
message_to_update.mentions = mentions;
|
||||
message_to_update.edited_at = edited_at;
|
||||
messages.push(message_to_update, &());
|
||||
cursor.next();
|
||||
}
|
||||
|
||||
messages.append(cursor.suffix(), &());
|
||||
drop(cursor);
|
||||
self.messages = messages;
|
||||
|
||||
cx.emit(ChannelChatEvent::UpdateMessage {
|
||||
message_ix: ix,
|
||||
message_id: id,
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async fn messages_from_proto(
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
user_store: &Entity<UserStore>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<SumTree<ChannelMessage>> {
|
||||
let messages = ChannelMessage::from_proto_vec(proto_messages, user_store, cx).await?;
|
||||
let mut result = SumTree::default();
|
||||
result.extend(messages, &());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
impl ChannelMessage {
|
||||
pub async fn from_proto(
|
||||
message: proto::ChannelMessage,
|
||||
user_store: &Entity<UserStore>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let sender = user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.get_user(message.sender_id, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
let edited_at = message.edited_at.and_then(|t| -> Option<OffsetDateTime> {
|
||||
if let Ok(a) = OffsetDateTime::from_unix_timestamp(t as i64) {
|
||||
return Some(a);
|
||||
}
|
||||
|
||||
None
|
||||
});
|
||||
|
||||
Ok(ChannelMessage {
|
||||
id: ChannelMessageId::Saved(message.id),
|
||||
body: message.body,
|
||||
mentions: message
|
||||
.mentions
|
||||
.into_iter()
|
||||
.filter_map(|mention| {
|
||||
let range = mention.range?;
|
||||
Some((range.start as usize..range.end as usize, mention.user_id))
|
||||
})
|
||||
.collect(),
|
||||
timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)?,
|
||||
sender,
|
||||
nonce: message.nonce.context("nonce is required")?.into(),
|
||||
reply_to_message_id: message.reply_to_message_id,
|
||||
edited_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.id, ChannelMessageId::Pending(_))
|
||||
}
|
||||
|
||||
pub async fn from_proto_vec(
|
||||
proto_messages: Vec<proto::ChannelMessage>,
|
||||
user_store: &Entity<UserStore>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Vec<Self>> {
|
||||
let unique_user_ids = proto_messages
|
||||
.iter()
|
||||
.map(|m| m.sender_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
user_store
|
||||
.update(cx, |user_store, cx| {
|
||||
user_store.get_users(unique_user_ids, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
let mut messages = Vec::with_capacity(proto_messages.len());
|
||||
for message in proto_messages {
|
||||
messages.push(ChannelMessage::from_proto(message, user_store, cx).await?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mentions_to_proto(mentions: &[(Range<usize>, UserId)]) -> Vec<proto::ChatMention> {
|
||||
mentions
|
||||
.iter()
|
||||
.map(|(range, user_id)| proto::ChatMention {
|
||||
range: Some(proto::Range {
|
||||
start: range.start as u64,
|
||||
end: range.end as u64,
|
||||
}),
|
||||
user_id: *user_id,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl sum_tree::Item for ChannelMessage {
|
||||
type Summary = ChannelMessageSummary;
|
||||
|
||||
fn summary(&self, _cx: &()) -> Self::Summary {
|
||||
ChannelMessageSummary {
|
||||
max_id: self.id,
|
||||
count: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelMessageId {
|
||||
fn default() -> Self {
|
||||
Self::Saved(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Summary for ChannelMessageSummary {
|
||||
type Context = ();
|
||||
|
||||
fn zero(_cx: &Self::Context) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &Self, _: &()) {
|
||||
self.max_id = summary.max_id;
|
||||
self.count += summary.count;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for ChannelMessageId {
|
||||
fn zero(_cx: &()) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) {
|
||||
debug_assert!(summary.max_id > *self);
|
||||
*self = summary.max_id;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for Count {
|
||||
fn zero(_cx: &()) -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &'a ChannelMessageSummary, _: &()) {
|
||||
self.0 += summary.count;
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for MessageParams {
|
||||
fn from(value: &'a str) -> Self {
|
||||
Self {
|
||||
text: value.into(),
|
||||
mentions: Vec::new(),
|
||||
reply_to_message_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
mod channel_index;
|
||||
|
||||
use crate::channel_buffer::ChannelBuffer;
|
||||
use crate::{ChannelMessage, channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use channel_index::ChannelIndex;
|
||||
use client::{ChannelId, Client, ClientSettings, Subscription, User, UserId, UserStore};
|
||||
@@ -41,6 +41,7 @@ pub struct ChannelStore {
|
||||
outgoing_invites: HashSet<(ChannelId, UserId)>,
|
||||
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
|
||||
opened_buffers: HashMap<ChannelId, OpenEntityHandle<ChannelBuffer>>,
|
||||
opened_chats: HashMap<ChannelId, OpenEntityHandle<ChannelChat>>,
|
||||
client: Arc<Client>,
|
||||
did_subscribe: bool,
|
||||
channels_loaded: (watch::Sender<bool>, watch::Receiver<bool>),
|
||||
@@ -62,8 +63,10 @@ pub struct Channel {
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChannelState {
|
||||
latest_chat_message: Option<u64>,
|
||||
latest_notes_version: NotesVersion,
|
||||
observed_notes_version: NotesVersion,
|
||||
observed_chat_message: Option<u64>,
|
||||
role: Option<ChannelRole>,
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ impl ChannelStore {
|
||||
channel_participants: Default::default(),
|
||||
outgoing_invites: Default::default(),
|
||||
opened_buffers: Default::default(),
|
||||
opened_chats: Default::default(),
|
||||
update_channels_tx,
|
||||
client,
|
||||
user_store,
|
||||
@@ -358,12 +362,89 @@ impl ChannelStore {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_channel_messages(
|
||||
&self,
|
||||
message_ids: Vec<u64>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<ChannelMessage>>> {
|
||||
let request = if message_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.client
|
||||
.request(proto::GetChannelMessagesById { message_ids }),
|
||||
)
|
||||
};
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Some(request) = request {
|
||||
let response = request.await?;
|
||||
let this = this.upgrade().context("channel store dropped")?;
|
||||
let user_store = this.read_with(cx, |this, _| this.user_store.clone())?;
|
||||
ChannelMessage::from_proto_vec(response.messages, &user_store, cx).await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_states
|
||||
.get(&channel_id)
|
||||
.is_some_and(|state| state.has_channel_buffer_changed())
|
||||
}
|
||||
|
||||
pub fn has_new_messages(&self, channel_id: ChannelId) -> bool {
|
||||
self.channel_states
|
||||
.get(&channel_id)
|
||||
.is_some_and(|state| state.has_new_messages())
|
||||
}
|
||||
|
||||
pub fn set_acknowledged_message_id(&mut self, channel_id: ChannelId, message_id: Option<u64>) {
|
||||
if let Some(state) = self.channel_states.get_mut(&channel_id) {
|
||||
state.latest_chat_message = message_id;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn last_acknowledge_message_id(&self, channel_id: ChannelId) -> Option<u64> {
|
||||
self.channel_states.get(&channel_id).and_then(|state| {
|
||||
if let Some(last_message_id) = state.latest_chat_message
|
||||
&& state
|
||||
.last_acknowledged_message_id()
|
||||
.is_some_and(|id| id < last_message_id)
|
||||
{
|
||||
return state.last_acknowledged_message_id();
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn acknowledge_message_id(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
message_id: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.channel_states
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.acknowledge_message_id(message_id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn update_latest_message_id(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
message_id: u64,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.channel_states
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.update_latest_message_id(message_id);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn acknowledge_notes_version(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
@@ -392,6 +473,23 @@ impl ChannelStore {
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
pub fn open_channel_chat(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Entity<ChannelChat>>> {
|
||||
let client = self.client.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let this = cx.entity();
|
||||
self.open_channel_resource(
|
||||
channel_id,
|
||||
"chat",
|
||||
|this| &mut this.opened_chats,
|
||||
async move |channel, cx| ChannelChat::new(channel, this, user_store, client, cx).await,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Asynchronously open a given resource associated with a channel.
|
||||
///
|
||||
/// Make sure that the resource is only opened once, even if this method
|
||||
@@ -833,6 +931,13 @@ impl ChannelStore {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
for message_id in message.payload.observed_channel_message_id {
|
||||
this.acknowledge_message_id(
|
||||
ChannelId(message_id.channel_id),
|
||||
message_id.message_id,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
for membership in message.payload.channel_memberships {
|
||||
if let Some(role) = ChannelRole::from_i32(membership.role) {
|
||||
this.channel_states
|
||||
@@ -852,6 +957,16 @@ impl ChannelStore {
|
||||
self.outgoing_invites.clear();
|
||||
self.disconnect_channel_buffers_task.take();
|
||||
|
||||
for chat in self.opened_chats.values() {
|
||||
if let OpenEntityHandle::Open(chat) = chat
|
||||
&& let Some(chat) = chat.upgrade()
|
||||
{
|
||||
chat.update(cx, |chat, cx| {
|
||||
chat.rejoin(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut buffer_versions = Vec::new();
|
||||
for buffer in self.opened_buffers.values() {
|
||||
if let OpenEntityHandle::Open(buffer) = buffer
|
||||
@@ -979,6 +1094,7 @@ impl ChannelStore {
|
||||
self.channel_participants.clear();
|
||||
self.outgoing_invites.clear();
|
||||
self.opened_buffers.clear();
|
||||
self.opened_chats.clear();
|
||||
self.disconnect_channel_buffers_task = None;
|
||||
self.channel_states.clear();
|
||||
}
|
||||
@@ -1015,6 +1131,7 @@ impl ChannelStore {
|
||||
|
||||
let channels_changed = !payload.channels.is_empty()
|
||||
|| !payload.delete_channels.is_empty()
|
||||
|| !payload.latest_channel_message_ids.is_empty()
|
||||
|| !payload.latest_channel_buffer_versions.is_empty();
|
||||
|
||||
if channels_changed {
|
||||
@@ -1064,6 +1181,13 @@ impl ChannelStore {
|
||||
.update_latest_notes_version(latest_buffer_version.epoch, &version)
|
||||
}
|
||||
|
||||
for latest_channel_message in payload.latest_channel_message_ids {
|
||||
self.channel_states
|
||||
.entry(ChannelId(latest_channel_message.channel_id))
|
||||
.or_default()
|
||||
.update_latest_message_id(latest_channel_message.message_id);
|
||||
}
|
||||
|
||||
self.channels_loaded.0.try_send(true).log_err();
|
||||
}
|
||||
|
||||
@@ -1127,6 +1251,29 @@ impl ChannelState {
|
||||
.changed_since(&self.observed_notes_version.version))
|
||||
}
|
||||
|
||||
fn has_new_messages(&self) -> bool {
|
||||
let latest_message_id = self.latest_chat_message;
|
||||
let observed_message_id = self.observed_chat_message;
|
||||
|
||||
latest_message_id.is_some_and(|latest_message_id| {
|
||||
latest_message_id > observed_message_id.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn last_acknowledged_message_id(&self) -> Option<u64> {
|
||||
self.observed_chat_message
|
||||
}
|
||||
|
||||
fn acknowledge_message_id(&mut self, message_id: u64) {
|
||||
let observed = self.observed_chat_message.get_or_insert(message_id);
|
||||
*observed = (*observed).max(message_id);
|
||||
}
|
||||
|
||||
fn update_latest_message_id(&mut self, message_id: u64) {
|
||||
self.latest_chat_message =
|
||||
Some(message_id.max(self.latest_chat_message.unwrap_or_default()));
|
||||
}
|
||||
|
||||
fn acknowledge_notes_version(&mut self, epoch: u64, version: &clock::Global) {
|
||||
if self.observed_notes_version.epoch == epoch {
|
||||
self.observed_notes_version.version.join(version);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::channel_chat::ChannelChatEvent;
|
||||
|
||||
use super::*;
|
||||
use client::{Client, UserStore};
|
||||
use client::{Client, UserStore, test::FakeServer};
|
||||
use clock::FakeSystemClock;
|
||||
use gpui::{App, AppContext as _, Entity, SemanticVersion};
|
||||
use gpui::{App, AppContext as _, Entity, SemanticVersion, TestAppContext};
|
||||
use http_client::FakeHttpClient;
|
||||
use rpc::proto::{self};
|
||||
use settings::SettingsStore;
|
||||
@@ -233,6 +235,201 @@ fn test_dangling_channel_paths(cx: &mut App) {
|
||||
assert_channels(&channel_store, &[(0, "a".to_string())], cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
let user_id = 5;
|
||||
let channel_id = 5;
|
||||
let channel_store = cx.update(init_test);
|
||||
let client = channel_store.read_with(cx, |s, _| s.client());
|
||||
let server = FakeServer::for_client(user_id, &client, cx).await;
|
||||
|
||||
// Get the available channels.
|
||||
server.send(proto::UpdateChannels {
|
||||
channels: vec![proto::Channel {
|
||||
id: channel_id,
|
||||
name: "the-channel".to_string(),
|
||||
visibility: proto::ChannelVisibility::Members as i32,
|
||||
parent_path: vec![],
|
||||
channel_order: 1,
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
cx.update(|cx| {
|
||||
assert_channels(&channel_store, &[(0, "the-channel".to_string())], cx);
|
||||
});
|
||||
|
||||
// Join a channel and populate its existing messages.
|
||||
let channel = channel_store.update(cx, |store, cx| {
|
||||
let channel_id = store.ordered_channels().next().unwrap().1.id;
|
||||
store.open_channel_chat(channel_id, cx)
|
||||
});
|
||||
let join_channel = server.receive::<proto::JoinChannelChat>().await.unwrap();
|
||||
server.respond(
|
||||
join_channel.receipt(),
|
||||
proto::JoinChannelChatResponse {
|
||||
messages: vec![
|
||||
proto::ChannelMessage {
|
||||
id: 10,
|
||||
body: "a".into(),
|
||||
timestamp: 1000,
|
||||
sender_id: 5,
|
||||
mentions: vec![],
|
||||
nonce: Some(1.into()),
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
proto::ChannelMessage {
|
||||
id: 11,
|
||||
body: "b".into(),
|
||||
timestamp: 1001,
|
||||
sender_id: 6,
|
||||
mentions: vec![],
|
||||
nonce: Some(2.into()),
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
],
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
|
||||
cx.executor().start_waiting();
|
||||
|
||||
// Client requests all users for the received messages
|
||||
let mut get_users = server.receive::<proto::GetUsers>().await.unwrap();
|
||||
get_users.payload.user_ids.sort();
|
||||
assert_eq!(get_users.payload.user_ids, vec![6]);
|
||||
server.respond(
|
||||
get_users.receipt(),
|
||||
proto::UsersResponse {
|
||||
users: vec![proto::User {
|
||||
id: 6,
|
||||
github_login: "maxbrunsfeld".into(),
|
||||
avatar_url: "http://avatar.com/maxbrunsfeld".into(),
|
||||
name: None,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
let channel = channel.await.unwrap();
|
||||
channel.update(cx, |channel, _| {
|
||||
assert_eq!(
|
||||
channel
|
||||
.messages_in_range(0..2)
|
||||
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
&[
|
||||
("user-5".into(), "a".into()),
|
||||
("maxbrunsfeld".into(), "b".into())
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Receive a new message.
|
||||
server.send(proto::ChannelMessageSent {
|
||||
channel_id,
|
||||
message: Some(proto::ChannelMessage {
|
||||
id: 12,
|
||||
body: "c".into(),
|
||||
timestamp: 1002,
|
||||
sender_id: 7,
|
||||
mentions: vec![],
|
||||
nonce: Some(3.into()),
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
}),
|
||||
});
|
||||
|
||||
// Client requests user for message since they haven't seen them yet
|
||||
let get_users = server.receive::<proto::GetUsers>().await.unwrap();
|
||||
assert_eq!(get_users.payload.user_ids, vec![7]);
|
||||
server.respond(
|
||||
get_users.receipt(),
|
||||
proto::UsersResponse {
|
||||
users: vec![proto::User {
|
||||
id: 7,
|
||||
github_login: "as-cii".into(),
|
||||
avatar_url: "http://avatar.com/as-cii".into(),
|
||||
name: None,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel.next_event(cx).await,
|
||||
ChannelChatEvent::MessagesUpdated {
|
||||
old_range: 2..2,
|
||||
new_count: 1,
|
||||
}
|
||||
);
|
||||
channel.update(cx, |channel, _| {
|
||||
assert_eq!(
|
||||
channel
|
||||
.messages_in_range(2..3)
|
||||
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
&[("as-cii".into(), "c".into())]
|
||||
)
|
||||
});
|
||||
|
||||
// Scroll up to view older messages.
|
||||
channel.update(cx, |channel, cx| {
|
||||
channel.load_more_messages(cx).unwrap().detach();
|
||||
});
|
||||
let get_messages = server.receive::<proto::GetChannelMessages>().await.unwrap();
|
||||
assert_eq!(get_messages.payload.channel_id, 5);
|
||||
assert_eq!(get_messages.payload.before_message_id, 10);
|
||||
server.respond(
|
||||
get_messages.receipt(),
|
||||
proto::GetChannelMessagesResponse {
|
||||
done: true,
|
||||
messages: vec![
|
||||
proto::ChannelMessage {
|
||||
id: 8,
|
||||
body: "y".into(),
|
||||
timestamp: 998,
|
||||
sender_id: 5,
|
||||
nonce: Some(4.into()),
|
||||
mentions: vec![],
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
proto::ChannelMessage {
|
||||
id: 9,
|
||||
body: "z".into(),
|
||||
timestamp: 999,
|
||||
sender_id: 6,
|
||||
nonce: Some(5.into()),
|
||||
mentions: vec![],
|
||||
reply_to_message_id: None,
|
||||
edited_at: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel.next_event(cx).await,
|
||||
ChannelChatEvent::MessagesUpdated {
|
||||
old_range: 0..0,
|
||||
new_count: 2,
|
||||
}
|
||||
);
|
||||
channel.update(cx, |channel, _| {
|
||||
assert_eq!(
|
||||
channel
|
||||
.messages_in_range(0..2)
|
||||
.map(|message| (message.sender.github_login.clone(), message.body.clone()))
|
||||
.collect::<Vec<_>>(),
|
||||
&[
|
||||
("user-5".into(), "y".into()),
|
||||
("maxbrunsfeld".into(), "z".into())
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut App) -> Entity<ChannelStore> {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
|
||||
@@ -22,7 +22,7 @@ use futures::{
|
||||
channel::oneshot, future::BoxFuture,
|
||||
};
|
||||
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
|
||||
use http_client::{HttpClient, HttpClientWithUrl, http, read_proxy_from_env};
|
||||
use http_client::{HttpClient, HttpClientWithUrl, http};
|
||||
use parking_lot::RwLock;
|
||||
use postage::watch;
|
||||
use proxy::connect_proxy_stream;
|
||||
@@ -132,20 +132,6 @@ pub struct ProxySettings {
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxySettings {
|
||||
pub fn proxy_url(&self) -> Option<Url> {
|
||||
self.proxy
|
||||
.as_ref()
|
||||
.and_then(|input| {
|
||||
input
|
||||
.parse::<Url>()
|
||||
.inspect_err(|e| log::error!("Error parsing proxy settings: {}", e))
|
||||
.ok()
|
||||
})
|
||||
.or_else(read_proxy_from_env)
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings for ProxySettings {
|
||||
type FileContent = ProxySettingsContent;
|
||||
|
||||
|
||||
@@ -80,18 +80,35 @@ pub enum Plan {
|
||||
#[default]
|
||||
#[serde(alias = "Free")]
|
||||
ZedFree,
|
||||
ZedFreeV2,
|
||||
#[serde(alias = "ZedPro")]
|
||||
ZedPro,
|
||||
ZedProV2,
|
||||
#[serde(alias = "ZedProTrial")]
|
||||
ZedProTrial,
|
||||
ZedProTrialV2,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
pub fn is_v2(&self) -> bool {
|
||||
matches!(self, Plan::ZedFreeV2 | Plan::ZedProV2 | Plan::ZedProTrialV2)
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Plan::ZedFree => "zed_free",
|
||||
Plan::ZedPro => "zed_pro",
|
||||
Plan::ZedProTrial => "zed_pro_trial",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_requests_limit(&self) -> UsageLimit {
|
||||
match self {
|
||||
Plan::ZedPro => UsageLimit::Limited(500),
|
||||
Plan::ZedProTrial => UsageLimit::Limited(150),
|
||||
Plan::ZedFree => UsageLimit::Limited(50),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn edit_predictions_limit(&self) -> UsageLimit {
|
||||
match self {
|
||||
Plan::ZedPro => UsageLimit::Unlimited,
|
||||
Plan::ZedProTrial => UsageLimit::Unlimited,
|
||||
Plan::ZedFree => UsageLimit::Limited(2_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +118,8 @@ impl FromStr for Plan {
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"zed_free" => Ok(Plan::ZedFree),
|
||||
"zed_free_v2" => Ok(Plan::ZedFreeV2),
|
||||
"zed_pro" => Ok(Plan::ZedPro),
|
||||
"zed_pro_v2" => Ok(Plan::ZedProV2),
|
||||
"zed_pro_trial" => Ok(Plan::ZedProTrial),
|
||||
"zed_pro_trial_v2" => Ok(Plan::ZedProTrialV2),
|
||||
plan => Err(anyhow::anyhow!("invalid plan: {plan:?}")),
|
||||
}
|
||||
}
|
||||
@@ -339,12 +353,6 @@ mod tests {
|
||||
|
||||
let plan = serde_json::from_value::<Plan>(json!("zed_pro_trial")).unwrap();
|
||||
assert_eq!(plan, Plan::ZedProTrial);
|
||||
|
||||
let plan = serde_json::from_value::<Plan>(json!("zed_pro_v2")).unwrap();
|
||||
assert_eq!(plan, Plan::ZedProV2);
|
||||
|
||||
let plan = serde_json::from_value::<Plan>(json!("zed_pro_trial_v2")).unwrap();
|
||||
assert_eq!(plan, Plan::ZedProTrialV2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,6 +26,7 @@ use semantic_version::SemanticVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::RangeInclusive;
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
ops::{Deref, DerefMut},
|
||||
@@ -485,7 +486,9 @@ pub struct ChannelsForUser {
|
||||
pub invited_channels: Vec<Channel>,
|
||||
|
||||
pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
pub observed_channel_messages: Vec<proto::ChannelMessageId>,
|
||||
pub latest_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
pub latest_channel_messages: Vec<proto::ChannelMessageId>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod embeddings;
|
||||
pub mod extensions;
|
||||
pub mod messages;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
pub mod rooms;
|
||||
|
||||
@@ -618,17 +618,25 @@ impl Database {
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
let latest_channel_messages = self.latest_channel_messages(&channel_ids, tx).await?;
|
||||
|
||||
let observed_buffer_versions = self
|
||||
.observed_channel_buffer_changes(&channel_ids_by_buffer_id, user_id, tx)
|
||||
.await?;
|
||||
|
||||
let observed_channel_messages = self
|
||||
.observed_channel_messages(&channel_ids, user_id, tx)
|
||||
.await?;
|
||||
|
||||
Ok(ChannelsForUser {
|
||||
channel_memberships,
|
||||
channels,
|
||||
invited_channels,
|
||||
channel_participants,
|
||||
latest_buffer_versions,
|
||||
latest_channel_messages,
|
||||
observed_buffer_versions,
|
||||
observed_channel_messages,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
725
crates/collab/src/db/queries/messages.rs
Normal file
725
crates/collab/src/db/queries/messages.rs
Normal file
@@ -0,0 +1,725 @@
|
||||
use super::*;
|
||||
use anyhow::Context as _;
|
||||
use rpc::Notification;
|
||||
use sea_orm::{SelectColumns, TryInsertResult};
|
||||
use time::OffsetDateTime;
|
||||
use util::ResultExt;
|
||||
|
||||
impl Database {
|
||||
/// Inserts a record representing a user joining the chat for a given channel.
|
||||
pub async fn join_channel_chat(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
connection_id: ConnectionId,
|
||||
user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
channel_chat_participant::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
connection_id: ActiveValue::Set(connection_id.id as i32),
|
||||
connection_server_id: ActiveValue::Set(ServerId(connection_id.owner_id as i32)),
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes `channel_chat_participant` records associated with the given connection ID.
|
||||
pub async fn channel_chat_connection_lost(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
channel_chat_participant::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
channel_chat_participant::Column::ConnectionServerId
|
||||
.eq(connection_id.owner_id),
|
||||
)
|
||||
.add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id)),
|
||||
)
|
||||
.exec(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes `channel_chat_participant` records associated with the given user ID so they
|
||||
/// will no longer get chat notifications.
|
||||
pub async fn leave_channel_chat(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
connection_id: ConnectionId,
|
||||
_user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
channel_chat_participant::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
channel_chat_participant::Column::ConnectionServerId
|
||||
.eq(connection_id.owner_id),
|
||||
)
|
||||
.add(channel_chat_participant::Column::ConnectionId.eq(connection_id.id))
|
||||
.add(channel_chat_participant::Column::ChannelId.eq(channel_id)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Retrieves the messages in the specified channel.
|
||||
///
|
||||
/// Use `before_message_id` to paginate through the channel's messages.
|
||||
pub async fn get_channel_messages(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
count: usize,
|
||||
before_message_id: Option<MessageId>,
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let mut condition =
|
||||
Condition::all().add(channel_message::Column::ChannelId.eq(channel_id));
|
||||
|
||||
if let Some(before_message_id) = before_message_id {
|
||||
condition = condition.add(channel_message::Column::Id.lt(before_message_id));
|
||||
}
|
||||
|
||||
let rows = channel_message::Entity::find()
|
||||
.filter(condition)
|
||||
.order_by_desc(channel_message::Column::Id)
|
||||
.limit(count as u64)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
self.load_channel_messages(rows, &tx).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns the channel messages with the given IDs.
|
||||
pub async fn get_channel_messages_by_id(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
message_ids: &[MessageId],
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
self.transaction(|tx| async move {
|
||||
let rows = channel_message::Entity::find()
|
||||
.filter(channel_message::Column::Id.is_in(message_ids.iter().copied()))
|
||||
.order_by_desc(channel_message::Column::Id)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut channels = HashMap::<ChannelId, channel::Model>::default();
|
||||
for row in &rows {
|
||||
channels.insert(
|
||||
row.channel_id,
|
||||
self.get_channel_internal(row.channel_id, &tx).await?,
|
||||
);
|
||||
}
|
||||
|
||||
for (_, channel) in channels {
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let messages = self.load_channel_messages(rows, &tx).await?;
|
||||
Ok(messages)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_channel_messages(
|
||||
&self,
|
||||
rows: Vec<channel_message::Model>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessage>> {
|
||||
let mut messages = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let nonce = row.nonce.as_u64_pair();
|
||||
proto::ChannelMessage {
|
||||
id: row.id.to_proto(),
|
||||
sender_id: row.sender_id.to_proto(),
|
||||
body: row.body,
|
||||
timestamp: row.sent_at.assume_utc().unix_timestamp() as u64,
|
||||
mentions: vec![],
|
||||
nonce: Some(proto::Nonce {
|
||||
upper_half: nonce.0,
|
||||
lower_half: nonce.1,
|
||||
}),
|
||||
reply_to_message_id: row.reply_to_message_id.map(|id| id.to_proto()),
|
||||
edited_at: row
|
||||
.edited_at
|
||||
.map(|t| t.assume_utc().unix_timestamp() as u64),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
messages.reverse();
|
||||
|
||||
let mut mentions = channel_message_mention::Entity::find()
|
||||
.filter(channel_message_mention::Column::MessageId.is_in(messages.iter().map(|m| m.id)))
|
||||
.order_by_asc(channel_message_mention::Column::MessageId)
|
||||
.order_by_asc(channel_message_mention::Column::StartOffset)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
let mut message_ix = 0;
|
||||
while let Some(mention) = mentions.next().await {
|
||||
let mention = mention?;
|
||||
let message_id = mention.message_id.to_proto();
|
||||
while let Some(message) = messages.get_mut(message_ix) {
|
||||
if message.id < message_id {
|
||||
message_ix += 1;
|
||||
} else {
|
||||
if message.id == message_id {
|
||||
message.mentions.push(proto::ChatMention {
|
||||
range: Some(proto::Range {
|
||||
start: mention.start_offset as u64,
|
||||
end: mention.end_offset as u64,
|
||||
}),
|
||||
user_id: mention.user_id.to_proto(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn format_mentions_to_entities(
|
||||
&self,
|
||||
message_id: MessageId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
) -> Result<Vec<tables::channel_message_mention::ActiveModel>> {
|
||||
Ok(mentions
|
||||
.iter()
|
||||
.filter_map(|mention| {
|
||||
let range = mention.range.as_ref()?;
|
||||
if !body.is_char_boundary(range.start as usize)
|
||||
|| !body.is_char_boundary(range.end as usize)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(channel_message_mention::ActiveModel {
|
||||
message_id: ActiveValue::Set(message_id),
|
||||
start_offset: ActiveValue::Set(range.start as i32),
|
||||
end_offset: ActiveValue::Set(range.end as i32),
|
||||
user_id: ActiveValue::Set(UserId::from_proto(mention.user_id)),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Creates a new channel message.
|
||||
pub async fn create_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
timestamp: OffsetDateTime,
|
||||
nonce: u128,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
) -> Result<CreatedChannelMessage> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let mut rows = channel_chat_participant::Entity::find()
|
||||
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut is_participant = false;
|
||||
let mut participant_connection_ids = HashSet::default();
|
||||
let mut participant_user_ids = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if row.user_id == user_id {
|
||||
is_participant = true;
|
||||
}
|
||||
participant_user_ids.push(row.user_id);
|
||||
participant_connection_ids.insert(row.connection());
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if !is_participant {
|
||||
Err(anyhow!("not a chat participant"))?;
|
||||
}
|
||||
|
||||
let timestamp = timestamp.to_offset(time::UtcOffset::UTC);
|
||||
let timestamp = time::PrimitiveDateTime::new(timestamp.date(), timestamp.time());
|
||||
|
||||
let result = channel_message::Entity::insert(channel_message::ActiveModel {
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
sender_id: ActiveValue::Set(user_id),
|
||||
body: ActiveValue::Set(body.to_string()),
|
||||
sent_at: ActiveValue::Set(timestamp),
|
||||
nonce: ActiveValue::Set(Uuid::from_u128(nonce)),
|
||||
id: ActiveValue::NotSet,
|
||||
reply_to_message_id: ActiveValue::Set(reply_to_message_id),
|
||||
edited_at: ActiveValue::NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
channel_message::Column::SenderId,
|
||||
channel_message::Column::Nonce,
|
||||
])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let message_id;
|
||||
let mut notifications = Vec::new();
|
||||
match result {
|
||||
TryInsertResult::Inserted(result) => {
|
||||
message_id = result.last_insert_id;
|
||||
let mentioned_user_ids =
|
||||
mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>();
|
||||
|
||||
let mentions = self.format_mentions_to_entities(message_id, body, mentions)?;
|
||||
if !mentions.is_empty() {
|
||||
channel_message_mention::Entity::insert_many(mentions)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for mentioned_user in mentioned_user_ids {
|
||||
notifications.extend(
|
||||
self.create_notification(
|
||||
UserId::from_proto(mentioned_user),
|
||||
rpc::Notification::ChannelMessageMention {
|
||||
message_id: message_id.to_proto(),
|
||||
sender_id: user_id.to_proto(),
|
||||
channel_id: channel_id.to_proto(),
|
||||
},
|
||||
false,
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
self.observe_channel_message_internal(channel_id, user_id, message_id, &tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
message_id = channel_message::Entity::find()
|
||||
.filter(channel_message::Column::Nonce.eq(Uuid::from_u128(nonce)))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.context("failed to insert message")?
|
||||
.id;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn observe_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
message_id: MessageId,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
self.observe_channel_message_internal(channel_id, user_id, message_id, &tx)
|
||||
.await?;
|
||||
let mut batch = NotificationBatch::default();
|
||||
batch.extend(
|
||||
self.mark_notification_as_read(
|
||||
user_id,
|
||||
&Notification::ChannelMessageMention {
|
||||
message_id: message_id.to_proto(),
|
||||
sender_id: Default::default(),
|
||||
channel_id: Default::default(),
|
||||
},
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
Ok(batch)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn observe_channel_message_internal(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
message_id: MessageId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<()> {
|
||||
observed_channel_messages::Entity::insert(observed_channel_messages::ActiveModel {
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
channel_message_id: ActiveValue::Set(message_id),
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
observed_channel_messages::Column::ChannelId,
|
||||
observed_channel_messages::Column::UserId,
|
||||
])
|
||||
.update_column(observed_channel_messages::Column::ChannelMessageId)
|
||||
.action_cond_where(observed_channel_messages::Column::ChannelMessageId.lt(message_id))
|
||||
.to_owned(),
|
||||
)
|
||||
// TODO: Try to upgrade SeaORM so we don't have to do this hack around their bug
|
||||
.exec_without_returning(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn observed_channel_messages(
|
||||
&self,
|
||||
channel_ids: &[ChannelId],
|
||||
user_id: UserId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessageId>> {
|
||||
let rows = observed_channel_messages::Entity::find()
|
||||
.filter(observed_channel_messages::Column::UserId.eq(user_id))
|
||||
.filter(
|
||||
observed_channel_messages::Column::ChannelId
|
||||
.is_in(channel_ids.iter().map(|id| id.0)),
|
||||
)
|
||||
.all(tx)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|message| proto::ChannelMessageId {
|
||||
channel_id: message.channel_id.to_proto(),
|
||||
message_id: message.channel_message_id.to_proto(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn latest_channel_messages(
|
||||
&self,
|
||||
channel_ids: &[ChannelId],
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessageId>> {
|
||||
let mut values = String::new();
|
||||
for id in channel_ids {
|
||||
if !values.is_empty() {
|
||||
values.push_str(", ");
|
||||
}
|
||||
write!(&mut values, "({})", id).unwrap();
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::default());
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (
|
||||
PARTITION BY channel_id
|
||||
ORDER BY id DESC
|
||||
) as row_number
|
||||
FROM channel_messages
|
||||
WHERE
|
||||
channel_id in ({values})
|
||||
) AS messages
|
||||
WHERE
|
||||
row_number = 1
|
||||
"#,
|
||||
);
|
||||
|
||||
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
|
||||
let mut last_messages = channel_message::Model::find_by_statement(stmt)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(result) = last_messages.next().await {
|
||||
let message = result?;
|
||||
results.push(proto::ChannelMessageId {
|
||||
channel_id: message.channel_id.to_proto(),
|
||||
message_id: message.id.to_proto(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn get_notification_kind_id_by_name(&self, notification_kind: &str) -> Option<i32> {
|
||||
self.notification_kinds_by_id
|
||||
.iter()
|
||||
.find(|(_, kind)| **kind == notification_kind)
|
||||
.map(|kind| kind.0.0)
|
||||
}
|
||||
|
||||
/// Removes the channel message with the given ID.
|
||||
pub async fn remove_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
message_id: MessageId,
|
||||
user_id: UserId,
|
||||
) -> Result<(Vec<ConnectionId>, Vec<NotificationId>)> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut rows = channel_chat_participant::Entity::find()
|
||||
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut is_participant = false;
|
||||
let mut participant_connection_ids = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if row.user_id == user_id {
|
||||
is_participant = true;
|
||||
}
|
||||
participant_connection_ids.push(row.connection());
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if !is_participant {
|
||||
Err(anyhow!("not a chat participant"))?;
|
||||
}
|
||||
|
||||
let result = channel_message::Entity::delete_by_id(message_id)
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected == 0 {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
if self
|
||||
.check_user_is_channel_admin(&channel, user_id, &tx)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let result = channel_message::Entity::delete_by_id(message_id)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("no such message"))?;
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("operation could not be completed"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let notification_kind_id =
|
||||
self.get_notification_kind_id_by_name("ChannelMessageMention");
|
||||
|
||||
let existing_notifications = notification::Entity::find()
|
||||
.filter(notification::Column::EntityId.eq(message_id))
|
||||
.filter(notification::Column::Kind.eq(notification_kind_id))
|
||||
.select_column(notification::Column::Id)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let existing_notification_ids = existing_notifications
|
||||
.into_iter()
|
||||
.map(|notification| notification.id)
|
||||
.collect();
|
||||
|
||||
// remove all the mention notifications for this message
|
||||
notification::Entity::delete_many()
|
||||
.filter(notification::Column::EntityId.eq(message_id))
|
||||
.filter(notification::Column::Kind.eq(notification_kind_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((participant_connection_ids, existing_notification_ids))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the channel message with the given ID, body and timestamp(edited_at).
|
||||
pub async fn update_channel_message(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
message_id: MessageId,
|
||||
user_id: UserId,
|
||||
body: &str,
|
||||
mentions: &[proto::ChatMention],
|
||||
edited_at: OffsetDateTime,
|
||||
) -> Result<UpdatedChannelMessage> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let mut rows = channel_chat_participant::Entity::find()
|
||||
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut is_participant = false;
|
||||
let mut participant_connection_ids = Vec::new();
|
||||
let mut participant_user_ids = Vec::new();
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if row.user_id == user_id {
|
||||
is_participant = true;
|
||||
}
|
||||
participant_user_ids.push(row.user_id);
|
||||
participant_connection_ids.push(row.connection());
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
if !is_participant {
|
||||
Err(anyhow!("not a chat participant"))?;
|
||||
}
|
||||
|
||||
let channel_message = channel_message::Entity::find_by_id(message_id)
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
|
||||
let Some(channel_message) = channel_message else {
|
||||
Err(anyhow!("Channel message not found"))?
|
||||
};
|
||||
|
||||
let edited_at = edited_at.to_offset(time::UtcOffset::UTC);
|
||||
let edited_at = time::PrimitiveDateTime::new(edited_at.date(), edited_at.time());
|
||||
|
||||
let updated_message = channel_message::ActiveModel {
|
||||
body: ActiveValue::Set(body.to_string()),
|
||||
edited_at: ActiveValue::Set(Some(edited_at)),
|
||||
reply_to_message_id: ActiveValue::Unchanged(channel_message.reply_to_message_id),
|
||||
id: ActiveValue::Unchanged(message_id),
|
||||
channel_id: ActiveValue::Unchanged(channel_id),
|
||||
sender_id: ActiveValue::Unchanged(user_id),
|
||||
sent_at: ActiveValue::Unchanged(channel_message.sent_at),
|
||||
nonce: ActiveValue::Unchanged(channel_message.nonce),
|
||||
};
|
||||
|
||||
let result = channel_message::Entity::update_many()
|
||||
.set(updated_message)
|
||||
.filter(channel_message::Column::Id.eq(message_id))
|
||||
.filter(channel_message::Column::SenderId.eq(user_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(anyhow!(
|
||||
"Attempted to edit a message (id: {message_id}) which does not exist anymore."
|
||||
))?;
|
||||
}
|
||||
|
||||
// we have to fetch the old mentions,
|
||||
// so we don't send a notification when the message has been edited that you are mentioned in
|
||||
let old_mentions = channel_message_mention::Entity::find()
|
||||
.filter(channel_message_mention::Column::MessageId.eq(message_id))
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
// remove all existing mentions
|
||||
channel_message_mention::Entity::delete_many()
|
||||
.filter(channel_message_mention::Column::MessageId.eq(message_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let new_mentions = self.format_mentions_to_entities(message_id, body, mentions)?;
|
||||
if !new_mentions.is_empty() {
|
||||
// insert new mentions
|
||||
channel_message_mention::Entity::insert_many(new_mentions)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut update_mention_user_ids = HashSet::default();
|
||||
let mut new_mention_user_ids =
|
||||
mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>();
|
||||
// Filter out users that were mentioned before
|
||||
for mention in &old_mentions {
|
||||
if new_mention_user_ids.contains(&mention.user_id.to_proto()) {
|
||||
update_mention_user_ids.insert(mention.user_id.to_proto());
|
||||
}
|
||||
|
||||
new_mention_user_ids.remove(&mention.user_id.to_proto());
|
||||
}
|
||||
|
||||
let notification_kind_id =
|
||||
self.get_notification_kind_id_by_name("ChannelMessageMention");
|
||||
|
||||
let existing_notifications = notification::Entity::find()
|
||||
.filter(notification::Column::EntityId.eq(message_id))
|
||||
.filter(notification::Column::Kind.eq(notification_kind_id))
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
// determine which notifications should be updated or deleted
|
||||
let mut deleted_notification_ids = HashSet::default();
|
||||
let mut updated_mention_notifications = Vec::new();
|
||||
for notification in existing_notifications {
|
||||
if update_mention_user_ids.contains(¬ification.recipient_id.to_proto()) {
|
||||
if let Some(notification) =
|
||||
self::notifications::model_to_proto(self, notification).log_err()
|
||||
{
|
||||
updated_mention_notifications.push(notification);
|
||||
}
|
||||
} else {
|
||||
deleted_notification_ids.insert(notification.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut notifications = Vec::new();
|
||||
for mentioned_user in new_mention_user_ids {
|
||||
notifications.extend(
|
||||
self.create_notification(
|
||||
UserId::from_proto(mentioned_user),
|
||||
rpc::Notification::ChannelMessageMention {
|
||||
message_id: message_id.to_proto(),
|
||||
sender_id: user_id.to_proto(),
|
||||
channel_id: channel_id.to_proto(),
|
||||
},
|
||||
false,
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(UpdatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
reply_to_message_id: channel_message.reply_to_message_id,
|
||||
timestamp: channel_message.sent_at,
|
||||
deleted_mention_notification_ids: deleted_notification_ids
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
updated_mention_notifications,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1193,6 +1193,7 @@ impl Database {
|
||||
self.transaction(|tx| async move {
|
||||
self.room_connection_lost(connection, &tx).await?;
|
||||
self.channel_buffer_connection_lost(connection, &tx).await?;
|
||||
self.channel_chat_connection_lost(connection, &tx).await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -7,6 +7,7 @@ mod db_tests;
|
||||
mod embedding_tests;
|
||||
mod extension_tests;
|
||||
mod feature_flag_tests;
|
||||
mod message_tests;
|
||||
mod user_tests;
|
||||
|
||||
use crate::migrations::run_database_migrations;
|
||||
@@ -20,7 +21,7 @@ use sqlx::migrate::MigrateDatabase;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicI32, Ordering::SeqCst},
|
||||
atomic::{AtomicI32, AtomicU32, Ordering::SeqCst},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -223,3 +224,11 @@ async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
|
||||
.unwrap()
|
||||
.user_id
|
||||
}
|
||||
|
||||
static TEST_CONNECTION_ID: AtomicU32 = AtomicU32::new(1);
|
||||
fn new_test_connection(server: ServerId) -> ConnectionId {
|
||||
ConnectionId {
|
||||
id: TEST_CONNECTION_ID.fetch_add(1, SeqCst),
|
||||
owner_id: server.0 as u32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
db::{
|
||||
Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId, UserId,
|
||||
tests::{assert_channel_tree_matches, channel_tree, new_test_user},
|
||||
tests::{assert_channel_tree_matches, channel_tree, new_test_connection, new_test_user},
|
||||
},
|
||||
test_both_dbs,
|
||||
};
|
||||
@@ -949,6 +949,41 @@ async fn test_user_is_channel_participant(db: &Arc<Database>) {
|
||||
)
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_guest_access,
|
||||
test_guest_access_postgres,
|
||||
test_guest_access_sqlite
|
||||
);
|
||||
|
||||
async fn test_guest_access(db: &Arc<Database>) {
|
||||
let server = db.create_server("test").await.unwrap();
|
||||
|
||||
let admin = new_test_user(db, "admin@example.com").await;
|
||||
let guest = new_test_user(db, "guest@example.com").await;
|
||||
let guest_connection = new_test_connection(server);
|
||||
|
||||
let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
|
||||
db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
db.join_channel_chat(zed_channel, guest_connection, guest)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
db.join_channel(zed_channel, guest, guest_connection)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
db.join_channel_chat(zed_channel, guest_connection, guest)
|
||||
.await
|
||||
.is_ok()
|
||||
)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId])]) {
|
||||
let actual = actual
|
||||
|
||||
421
crates/collab/src/db/tests/message_tests.rs
Normal file
421
crates/collab/src/db/tests/message_tests.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use super::new_test_user;
|
||||
use crate::{
|
||||
db::{ChannelRole, Database, MessageId},
|
||||
test_both_dbs,
|
||||
};
|
||||
use channel::mentions_to_proto;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_message_retrieval,
|
||||
test_channel_message_retrieval_postgres,
|
||||
test_channel_message_retrieval_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_retrieval(db: &Arc<Database>) {
|
||||
let user = new_test_user(db, "user@example.com").await;
|
||||
let channel = db.create_channel("channel", None, user).await.unwrap().0;
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
db.join_channel_chat(channel.id, rpc::ConnectionId { owner_id, id: 0 }, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut all_messages = Vec::new();
|
||||
for i in 0..10 {
|
||||
all_messages.push(
|
||||
db.create_channel_message(
|
||||
channel.id,
|
||||
user,
|
||||
&i.to_string(),
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
i,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id
|
||||
.to_proto(),
|
||||
);
|
||||
}
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel.id, user, 3, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|message| message.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(messages, &all_messages[7..10]);
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(
|
||||
channel.id,
|
||||
user,
|
||||
4,
|
||||
Some(MessageId::from_proto(all_messages[6])),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|message| message.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(messages, &all_messages[2..6]);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_message_nonces,
|
||||
test_channel_message_nonces_postgres,
|
||||
test_channel_message_nonces_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_nonces(db: &Arc<Database>) {
|
||||
let user_a = new_test_user(db, "user_a@example.com").await;
|
||||
let user_b = new_test_user(db, "user_b@example.com").await;
|
||||
let user_c = new_test_user(db, "user_c@example.com").await;
|
||||
let channel = db.create_root_channel("channel", user_a).await.unwrap();
|
||||
db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.invite_channel_member(channel, user_c, user_a, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, user_b, true)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, user_c, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user_a)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 1 }, user_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// As user A, create messages that reuse the same nonces. The requests
|
||||
// succeed, but return the same ids.
|
||||
let id1 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hi @user_b",
|
||||
&mentions_to_proto(&[(3..10, user_b.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
let id2 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hello, fellow users",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
let id3 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"bye @user_c (same nonce as first message)",
|
||||
&mentions_to_proto(&[(4..11, user_c.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
let id4 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"omg (same nonce as second message)",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
// As a different user, reuse one of the same nonces. This request succeeds
|
||||
// and returns a different id.
|
||||
let id5 = db
|
||||
.create_channel_message(
|
||||
channel,
|
||||
user_b,
|
||||
"omg @user_a (same nonce as user_a's first message)",
|
||||
&mentions_to_proto(&[(4..11, user_a.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
assert_ne!(id1, id2);
|
||||
assert_eq!(id1, id3);
|
||||
assert_eq!(id2, id4);
|
||||
assert_ne!(id5, id1);
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel, user_a, 5, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| (m.id, m.body, m.mentions))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
messages,
|
||||
&[
|
||||
(
|
||||
id1.to_proto(),
|
||||
"hi @user_b".into(),
|
||||
mentions_to_proto(&[(3..10, user_b.to_proto())]),
|
||||
),
|
||||
(
|
||||
id2.to_proto(),
|
||||
"hello, fellow users".into(),
|
||||
mentions_to_proto(&[])
|
||||
),
|
||||
(
|
||||
id5.to_proto(),
|
||||
"omg @user_a (same nonce as user_a's first message)".into(),
|
||||
mentions_to_proto(&[(4..11, user_a.to_proto())]),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_unseen_channel_messages,
|
||||
test_unseen_channel_messages_postgres,
|
||||
test_unseen_channel_messages_sqlite
|
||||
);
|
||||
|
||||
async fn test_unseen_channel_messages(db: &Arc<Database>) {
|
||||
let user = new_test_user(db, "user_a@example.com").await;
|
||||
let observer = new_test_user(db, "user_b@example.com").await;
|
||||
|
||||
let channel_1 = db.create_root_channel("channel", user).await.unwrap();
|
||||
let channel_2 = db.create_root_channel("channel-2", user).await.unwrap();
|
||||
|
||||
db.invite_channel_member(channel_1, observer, user, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.invite_channel_member(channel_2, observer, user, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.respond_to_channel_invite(channel_1, observer, true)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel_2, observer, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
let user_connection_id = rpc::ConnectionId { owner_id, id: 0 };
|
||||
|
||||
db.join_channel_chat(channel_1, user_connection_id, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = db
|
||||
.create_channel_message(
|
||||
channel_1,
|
||||
user,
|
||||
"1_1",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = db
|
||||
.create_channel_message(
|
||||
channel_1,
|
||||
user,
|
||||
"1_2",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let third_message = db
|
||||
.create_channel_message(
|
||||
channel_1,
|
||||
user,
|
||||
"1_3",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
3,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
db.join_channel_chat(channel_2, user_connection_id, user)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fourth_message = db
|
||||
.create_channel_message(
|
||||
channel_2,
|
||||
user,
|
||||
"2_1",
|
||||
&[],
|
||||
OffsetDateTime::now_utc(),
|
||||
4,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.message_id;
|
||||
|
||||
// Check that observer has new messages
|
||||
let latest_messages = db
|
||||
.transaction(|tx| async move {
|
||||
db.latest_channel_messages(&[channel_1, channel_2], &tx)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
latest_messages,
|
||||
[
|
||||
rpc::proto::ChannelMessageId {
|
||||
channel_id: channel_1.to_proto(),
|
||||
message_id: third_message.to_proto(),
|
||||
},
|
||||
rpc::proto::ChannelMessageId {
|
||||
channel_id: channel_2.to_proto(),
|
||||
message_id: fourth_message.to_proto(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_message_mentions,
|
||||
test_channel_message_mentions_postgres,
|
||||
test_channel_message_mentions_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_message_mentions(db: &Arc<Database>) {
|
||||
let user_a = new_test_user(db, "user_a@example.com").await;
|
||||
let user_b = new_test_user(db, "user_b@example.com").await;
|
||||
let user_c = new_test_user(db, "user_c@example.com").await;
|
||||
|
||||
let channel = db
|
||||
.create_channel("channel", None, user_a)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.id;
|
||||
db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, user_b, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
let connection_id = rpc::ConnectionId { owner_id, id: 0 };
|
||||
db.join_channel_chat(channel, connection_id, user_a)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"hi @user_b and @user_c",
|
||||
&mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"bye @user_c",
|
||||
&mentions_to_proto(&[(4..11, user_c.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
2,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"umm",
|
||||
&mentions_to_proto(&[]),
|
||||
OffsetDateTime::now_utc(),
|
||||
3,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_channel_message(
|
||||
channel,
|
||||
user_a,
|
||||
"@user_b, stop.",
|
||||
&mentions_to_proto(&[(0..7, user_b.to_proto())]),
|
||||
OffsetDateTime::now_utc(),
|
||||
4,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages = db
|
||||
.get_channel_messages(channel, user_b, 5, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| (m.body, m.mentions))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
&messages,
|
||||
&[
|
||||
(
|
||||
"hi @user_b and @user_c".into(),
|
||||
mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
|
||||
),
|
||||
(
|
||||
"bye @user_c".into(),
|
||||
mentions_to_proto(&[(4..11, user_c.to_proto())]),
|
||||
),
|
||||
("umm".into(), mentions_to_proto(&[]),),
|
||||
(
|
||||
"@user_b, stop.".into(),
|
||||
mentions_to_proto(&[(0..7, user_b.to_proto())]),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@ use crate::api::{CloudflareIpCountryHeader, SystemIdHeader};
|
||||
use crate::{
|
||||
AppState, Error, Result, auth,
|
||||
db::{
|
||||
self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser, Database,
|
||||
InviteMemberResult, MembershipUpdated, NotificationId, ProjectId, RejoinedProject,
|
||||
RemoveChannelMemberResult, RespondToChannelInvite, RoomId, ServerId, User, UserId,
|
||||
self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser,
|
||||
CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId,
|
||||
NotificationId, ProjectId, RejoinedProject, RemoveChannelMemberResult,
|
||||
RespondToChannelInvite, RoomId, ServerId, UpdatedChannelMessage, User, UserId,
|
||||
},
|
||||
executor::Executor,
|
||||
};
|
||||
@@ -65,6 +66,7 @@ use std::{
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::{Semaphore, watch};
|
||||
use tower::ServiceBuilder;
|
||||
use tracing::{
|
||||
@@ -78,6 +80,8 @@ pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// kubernetes gives terminated pods 10s to shutdown gracefully. After they're gone, we can clean up old resources.
|
||||
pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
const MESSAGE_COUNT_PER_PAGE: usize = 100;
|
||||
const MAX_MESSAGE_LEN: usize = 1024;
|
||||
const NOTIFICATION_COUNT_PER_PAGE: usize = 50;
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
|
||||
@@ -3593,36 +3597,235 @@ fn send_notifications(
|
||||
|
||||
/// Send a message to the channel
|
||||
async fn send_channel_message(
|
||||
_request: proto::SendChannelMessage,
|
||||
_response: Response<proto::SendChannelMessage>,
|
||||
_session: MessageContext,
|
||||
request: proto::SendChannelMessage,
|
||||
response: Response<proto::SendChannelMessage>,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
// Validate the message body.
|
||||
let body = request.body.trim().to_string();
|
||||
if body.len() > MAX_MESSAGE_LEN {
|
||||
return Err(anyhow!("message is too long"))?;
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err(anyhow!("message can't be blank"))?;
|
||||
}
|
||||
|
||||
// TODO: adjust mentions if body is trimmed
|
||||
|
||||
let timestamp = OffsetDateTime::now_utc();
|
||||
let nonce = request.nonce.context("nonce can't be blank")?;
|
||||
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let CreatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
} = session
|
||||
.db()
|
||||
.await
|
||||
.create_channel_message(
|
||||
channel_id,
|
||||
session.user_id(),
|
||||
&body,
|
||||
&request.mentions,
|
||||
timestamp,
|
||||
nonce.clone().into(),
|
||||
request.reply_to_message_id.map(MessageId::from_proto),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = proto::ChannelMessage {
|
||||
sender_id: session.user_id().to_proto(),
|
||||
id: message_id.to_proto(),
|
||||
body,
|
||||
mentions: request.mentions,
|
||||
timestamp: timestamp.unix_timestamp() as u64,
|
||||
nonce: Some(nonce),
|
||||
reply_to_message_id: request.reply_to_message_id,
|
||||
edited_at: None,
|
||||
};
|
||||
broadcast(
|
||||
Some(session.connection_id),
|
||||
participant_connection_ids.clone(),
|
||||
|connection| {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::ChannelMessageSent {
|
||||
channel_id: channel_id.to_proto(),
|
||||
message: Some(message.clone()),
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
response.send(proto::SendChannelMessageResponse {
|
||||
message: Some(message),
|
||||
})?;
|
||||
|
||||
let pool = &*session.connection_pool().await;
|
||||
let non_participants =
|
||||
pool.channel_connection_ids(channel_id)
|
||||
.filter_map(|(connection_id, _)| {
|
||||
if participant_connection_ids.contains(&connection_id) {
|
||||
None
|
||||
} else {
|
||||
Some(connection_id)
|
||||
}
|
||||
});
|
||||
broadcast(None, non_participants, |peer_id| {
|
||||
session.peer.send(
|
||||
peer_id,
|
||||
proto::UpdateChannels {
|
||||
latest_channel_message_ids: vec![proto::ChannelMessageId {
|
||||
channel_id: channel_id.to_proto(),
|
||||
message_id: message_id.to_proto(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
send_notifications(pool, &session.peer, notifications);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a channel message
|
||||
async fn remove_channel_message(
|
||||
_request: proto::RemoveChannelMessage,
|
||||
_response: Response<proto::RemoveChannelMessage>,
|
||||
_session: MessageContext,
|
||||
request: proto::RemoveChannelMessage,
|
||||
response: Response<proto::RemoveChannelMessage>,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
let (connection_ids, existing_notification_ids) = session
|
||||
.db()
|
||||
.await
|
||||
.remove_channel_message(channel_id, message_id, session.user_id())
|
||||
.await?;
|
||||
|
||||
broadcast(
|
||||
Some(session.connection_id),
|
||||
connection_ids,
|
||||
move |connection| {
|
||||
session.peer.send(connection, request.clone())?;
|
||||
|
||||
for notification_id in &existing_notification_ids {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::DeleteNotification {
|
||||
notification_id: (*notification_id).to_proto(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
response.send(proto::Ack {})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_channel_message(
|
||||
_request: proto::UpdateChannelMessage,
|
||||
_response: Response<proto::UpdateChannelMessage>,
|
||||
_session: MessageContext,
|
||||
request: proto::UpdateChannelMessage,
|
||||
response: Response<proto::UpdateChannelMessage>,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
let updated_at = OffsetDateTime::now_utc();
|
||||
let UpdatedChannelMessage {
|
||||
message_id,
|
||||
participant_connection_ids,
|
||||
notifications,
|
||||
reply_to_message_id,
|
||||
timestamp,
|
||||
deleted_mention_notification_ids,
|
||||
updated_mention_notifications,
|
||||
} = session
|
||||
.db()
|
||||
.await
|
||||
.update_channel_message(
|
||||
channel_id,
|
||||
message_id,
|
||||
session.user_id(),
|
||||
request.body.as_str(),
|
||||
&request.mentions,
|
||||
updated_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let nonce = request.nonce.clone().context("nonce can't be blank")?;
|
||||
|
||||
let message = proto::ChannelMessage {
|
||||
sender_id: session.user_id().to_proto(),
|
||||
id: message_id.to_proto(),
|
||||
body: request.body.clone(),
|
||||
mentions: request.mentions.clone(),
|
||||
timestamp: timestamp.assume_utc().unix_timestamp() as u64,
|
||||
nonce: Some(nonce),
|
||||
reply_to_message_id: reply_to_message_id.map(|id| id.to_proto()),
|
||||
edited_at: Some(updated_at.unix_timestamp() as u64),
|
||||
};
|
||||
|
||||
response.send(proto::Ack {})?;
|
||||
|
||||
let pool = &*session.connection_pool().await;
|
||||
broadcast(
|
||||
Some(session.connection_id),
|
||||
participant_connection_ids,
|
||||
|connection| {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::ChannelMessageUpdate {
|
||||
channel_id: channel_id.to_proto(),
|
||||
message: Some(message.clone()),
|
||||
},
|
||||
)?;
|
||||
|
||||
for notification_id in &deleted_mention_notification_ids {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::DeleteNotification {
|
||||
notification_id: (*notification_id).to_proto(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
for notification in &updated_mention_notifications {
|
||||
session.peer.send(
|
||||
connection,
|
||||
proto::UpdateNotification {
|
||||
notification: Some(notification.clone()),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
send_notifications(pool, &session.peer, notifications);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a channel message as read
|
||||
async fn acknowledge_channel_message(
|
||||
_request: proto::AckChannelMessage,
|
||||
_session: MessageContext,
|
||||
request: proto::AckChannelMessage,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let message_id = MessageId::from_proto(request.message_id);
|
||||
let notifications = session
|
||||
.db()
|
||||
.await
|
||||
.observe_channel_message(channel_id, session.user_id(), message_id)
|
||||
.await?;
|
||||
send_notifications(
|
||||
&*session.connection_pool().await,
|
||||
&session.peer,
|
||||
notifications,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a buffer version as synced
|
||||
@@ -3675,37 +3878,84 @@ async fn get_supermaven_api_key(
|
||||
|
||||
/// Start receiving chat updates for a channel
|
||||
async fn join_channel_chat(
|
||||
_request: proto::JoinChannelChat,
|
||||
_response: Response<proto::JoinChannelChat>,
|
||||
_session: MessageContext,
|
||||
request: proto::JoinChannelChat,
|
||||
response: Response<proto::JoinChannelChat>,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
|
||||
let db = session.db().await;
|
||||
db.join_channel_chat(channel_id, session.connection_id, session.user_id())
|
||||
.await?;
|
||||
let messages = db
|
||||
.get_channel_messages(channel_id, session.user_id(), MESSAGE_COUNT_PER_PAGE, None)
|
||||
.await?;
|
||||
response.send(proto::JoinChannelChatResponse {
|
||||
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
|
||||
messages,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop receiving chat updates for a channel
|
||||
async fn leave_channel_chat(
|
||||
_request: proto::LeaveChannelChat,
|
||||
_session: MessageContext,
|
||||
request: proto::LeaveChannelChat,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
session
|
||||
.db()
|
||||
.await
|
||||
.leave_channel_chat(channel_id, session.connection_id, session.user_id())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve the chat history for a channel
|
||||
async fn get_channel_messages(
|
||||
_request: proto::GetChannelMessages,
|
||||
_response: Response<proto::GetChannelMessages>,
|
||||
_session: MessageContext,
|
||||
request: proto::GetChannelMessages,
|
||||
response: Response<proto::GetChannelMessages>,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||
let messages = session
|
||||
.db()
|
||||
.await
|
||||
.get_channel_messages(
|
||||
channel_id,
|
||||
session.user_id(),
|
||||
MESSAGE_COUNT_PER_PAGE,
|
||||
Some(MessageId::from_proto(request.before_message_id)),
|
||||
)
|
||||
.await?;
|
||||
response.send(proto::GetChannelMessagesResponse {
|
||||
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
|
||||
messages,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve specific chat messages
|
||||
async fn get_channel_messages_by_id(
|
||||
_request: proto::GetChannelMessagesById,
|
||||
_response: Response<proto::GetChannelMessagesById>,
|
||||
_session: MessageContext,
|
||||
request: proto::GetChannelMessagesById,
|
||||
response: Response<proto::GetChannelMessagesById>,
|
||||
session: MessageContext,
|
||||
) -> Result<()> {
|
||||
Err(anyhow!("chat has been removed in the latest version of Zed").into())
|
||||
let message_ids = request
|
||||
.message_ids
|
||||
.iter()
|
||||
.map(|id| MessageId::from_proto(*id))
|
||||
.collect::<Vec<_>>();
|
||||
let messages = session
|
||||
.db()
|
||||
.await
|
||||
.get_channel_messages_by_id(session.user_id(), &message_ids)
|
||||
.await?;
|
||||
response.send(proto::GetChannelMessagesResponse {
|
||||
done: messages.len() < MESSAGE_COUNT_PER_PAGE,
|
||||
messages,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieve the current users notifications
|
||||
@@ -3845,6 +4095,7 @@ fn build_update_user_channels(channels: &ChannelsForUser) -> proto::UpdateUserCh
|
||||
})
|
||||
.collect(),
|
||||
observed_channel_buffer_version: channels.observed_buffer_versions.clone(),
|
||||
observed_channel_message_id: channels.observed_channel_messages.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3856,6 +4107,7 @@ fn build_channels_update(channels: ChannelsForUser) -> proto::UpdateChannels {
|
||||
}
|
||||
|
||||
update.latest_channel_buffer_versions = channels.latest_buffer_versions;
|
||||
update.latest_channel_message_ids = channels.latest_channel_messages;
|
||||
|
||||
for (channel_id, participants) in channels.channel_participants {
|
||||
update
|
||||
|
||||
@@ -6,6 +6,7 @@ use gpui::{Entity, TestAppContext};
|
||||
|
||||
mod channel_buffer_tests;
|
||||
mod channel_guest_tests;
|
||||
mod channel_message_tests;
|
||||
mod channel_tests;
|
||||
mod editor_tests;
|
||||
mod following_tests;
|
||||
|
||||
725
crates/collab/src/tests/channel_message_tests.rs
Normal file
725
crates/collab/src/tests/channel_message_tests.rs
Normal file
@@ -0,0 +1,725 @@
|
||||
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
|
||||
use channel::{ChannelChat, ChannelMessageId, MessageParams};
|
||||
use collab_ui::chat_panel::ChatPanel;
|
||||
use gpui::{BackgroundExecutor, Entity, TestAppContext};
|
||||
use rpc::Notification;
|
||||
use workspace::dock::Panel;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_basic_channel_messages(
|
||||
executor: BackgroundExecutor,
|
||||
mut cx_a: &mut TestAppContext,
|
||||
mut cx_b: &mut TestAppContext,
|
||||
mut cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let message_id = channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "hi @user_c!".into(),
|
||||
mentions: vec![(3..10, client_c.id())],
|
||||
reply_to_message_id: None,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let channel_chat_c = client_c
|
||||
.channel_store()
|
||||
.update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (chat, cx) in [
|
||||
(&channel_chat_a, &mut cx_a),
|
||||
(&channel_chat_b, &mut cx_b),
|
||||
(&channel_chat_c, &mut cx_c),
|
||||
] {
|
||||
chat.update(*cx, |c, _| {
|
||||
assert_eq!(
|
||||
c.messages()
|
||||
.iter()
|
||||
.map(|m| (m.body.as_str(), m.mentions.as_slice()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("hi @user_c!", [(3..10, client_c.id())].as_slice()),
|
||||
("two", &[]),
|
||||
("three", &[])
|
||||
],
|
||||
"results for user {}",
|
||||
c.client().id(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
client_c.notification_store().update(cx_c, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
assert_eq!(
|
||||
store.notification_at(0).unwrap().notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store.notification_at(1).unwrap().notification,
|
||||
Notification::ChannelInvitation {
|
||||
channel_id: channel_id.0,
|
||||
channel_name: "the-channel".to_string(),
|
||||
inviter_id: client_a.id()
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rejoin_channel_chat(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("two".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
|
||||
// While client A is disconnected, clients A and B both send new messages.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("five".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_b
|
||||
.update(cx_b, |c, cx| c.send_message("six".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A reconnects.
|
||||
server.allow_connections();
|
||||
executor.advance_clock(RECONNECT_TIMEOUT);
|
||||
|
||||
// Client A fetches the messages that were sent while they were disconnected
|
||||
// and resends their own messages which failed to send.
|
||||
let expected_messages = &["one", "two", "five", "six", "three", "four"];
|
||||
assert_messages(&channel_chat_a, expected_messages, cx_a);
|
||||
assert_messages(&channel_chat_b, expected_messages, cx_b);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remove_channel_message(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A sends some messages.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg_id_2 = channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "two @user_b".to_string(),
|
||||
mentions: vec![(4..12, client_b.id())],
|
||||
reply_to_message_id: None,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Clients A and B see all of the messages.
|
||||
executor.run_until_parked();
|
||||
let expected_messages = &["one", "two @user_b", "three"];
|
||||
assert_messages(&channel_chat_a, expected_messages, cx_a);
|
||||
assert_messages(&channel_chat_b, expected_messages, cx_b);
|
||||
|
||||
// Ensure that client B received a notification for the mention.
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id: msg_id_2,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Client A deletes one of their messages.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
let ChannelMessageId::Saved(id) = c.message(1).id else {
|
||||
panic!("message not saved")
|
||||
};
|
||||
c.remove_message(id, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client B sees that the message is gone.
|
||||
executor.run_until_parked();
|
||||
let expected_messages = &["one", "three"];
|
||||
assert_messages(&channel_chat_a, expected_messages, cx_a);
|
||||
assert_messages(&channel_chat_b, expected_messages, cx_b);
|
||||
|
||||
// Client C joins the channel chat, and does not see the deleted message.
|
||||
let channel_chat_c = client_c
|
||||
.channel_store()
|
||||
.update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_messages(&channel_chat_c, expected_messages, cx_c);
|
||||
|
||||
// Ensure we remove the notifications when the message is removed
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
// First notification is the channel invitation, second would be the mention
|
||||
// notification, which should now be removed.
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_messages(chat: &Entity<ChannelChat>, messages: &[&str], cx: &mut TestAppContext) {
|
||||
assert_eq!(
|
||||
chat.read_with(cx, |chat, _| {
|
||||
chat.messages()
|
||||
.iter()
|
||||
.map(|m| m.body.clone())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
messages
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_message_changes(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Client A sends a message, client B should see that there is a new message.
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(b_has_messages);
|
||||
|
||||
// Opening the chat should clear the changed flag.
|
||||
cx_b.update(|cx| {
|
||||
collab_ui::init(&client_b.app_state, cx);
|
||||
});
|
||||
let project_b = client_b.build_empty_local_project(cx_b);
|
||||
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
|
||||
|
||||
let chat_panel_b = workspace_b.update_in(cx_b, ChatPanel::new);
|
||||
chat_panel_b
|
||||
.update_in(cx_b, |chat_panel, window, cx| {
|
||||
chat_panel.set_active(true, window, cx);
|
||||
chat_panel.select_channel(channel_id, None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(!b_has_messages);
|
||||
|
||||
// Sending a message while the chat is open should not change the flag.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(!b_has_messages);
|
||||
|
||||
// Sending a message while the chat is closed should change the flag.
|
||||
chat_panel_b.update_in(cx_b, |chat_panel, window, cx| {
|
||||
chat_panel.set_active(false, window, cx);
|
||||
});
|
||||
|
||||
// Sending a message while the chat is open should not change the flag.
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(b_has_messages);
|
||||
|
||||
// Closing the chat should re-enable change tracking
|
||||
cx_b.update(|_, _| drop(chat_panel_b));
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let b_has_messages = cx_b.update(|_, cx| {
|
||||
client_b
|
||||
.channel_store()
|
||||
.read(cx)
|
||||
.has_new_messages(channel_id)
|
||||
});
|
||||
|
||||
assert!(b_has_messages);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_chat_replies(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Client A sends a message, client B should see that there is a new message.
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg_id = channel_chat_a
|
||||
.update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
let reply_id = channel_chat_b
|
||||
.update(cx_b, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "reply".into(),
|
||||
reply_to_message_id: Some(msg_id),
|
||||
mentions: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat
|
||||
.find_loaded_message(reply_id)
|
||||
.unwrap()
|
||||
.reply_to_message_id,
|
||||
Some(msg_id),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_chat_editing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Client A sends a message, client B should see that there is a new message.
|
||||
let channel_chat_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let channel_chat_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_chat(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let msg_id = channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.send_message(
|
||||
MessageParams {
|
||||
text: "Initial message".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: Vec::new(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
let update_message = channel_chat.find_loaded_message(msg_id).unwrap();
|
||||
|
||||
assert_eq!(update_message.body, "Updated body");
|
||||
assert_eq!(update_message.mentions, Vec::new());
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
let update_message = channel_chat.find_loaded_message(msg_id).unwrap();
|
||||
|
||||
assert_eq!(update_message.body, "Updated body");
|
||||
assert_eq!(update_message.mentions, Vec::new());
|
||||
});
|
||||
|
||||
// test mentions are updated correctly
|
||||
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert!(matches!(
|
||||
entry.notification,
|
||||
Notification::ChannelInvitation { .. }
|
||||
),);
|
||||
});
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body including a mention for @user_b".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: vec![(37..45, client_b.id())],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id: msg_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Test update message and keep the mention and check that the body is updated correctly
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body v2 including a mention for @user_b".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: vec![(37..45, client_b.id())],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body v2 including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body v2 including a mention for @user_b",
|
||||
)
|
||||
});
|
||||
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
let message = store.channel_message_for_id(msg_id);
|
||||
assert!(message.is_some());
|
||||
assert_eq!(
|
||||
message.unwrap().body,
|
||||
"Updated body v2 including a mention for @user_b"
|
||||
);
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelMessageMention {
|
||||
message_id: msg_id,
|
||||
sender_id: client_a.id(),
|
||||
channel_id: channel_id.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// If we remove a mention from a message the corresponding mention notification
|
||||
// should also be removed.
|
||||
|
||||
channel_chat_a
|
||||
.update(cx_a, |c, cx| {
|
||||
c.update_message(
|
||||
msg_id,
|
||||
MessageParams {
|
||||
text: "Updated body without a mention".into(),
|
||||
reply_to_message_id: None,
|
||||
mentions: vec![],
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_a.run_until_parked();
|
||||
cx_b.run_until_parked();
|
||||
|
||||
channel_chat_a.update(cx_a, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body without a mention",
|
||||
)
|
||||
});
|
||||
channel_chat_b.update(cx_b, |channel_chat, _| {
|
||||
assert_eq!(
|
||||
channel_chat.find_loaded_message(msg_id).unwrap().body,
|
||||
"Updated body without a mention",
|
||||
)
|
||||
});
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
// First notification is the channel invitation, second would be the mention
|
||||
// notification, which should now be removed.
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
});
|
||||
}
|
||||
@@ -37,15 +37,18 @@ client.workspace = true
|
||||
collections.workspace = true
|
||||
db.workspace = true
|
||||
editor.workspace = true
|
||||
emojis.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
notifications.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
release_channel.workspace = true
|
||||
rich_text.workspace = true
|
||||
rpc.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
1380
crates/collab_ui/src/chat_panel.rs
Normal file
1380
crates/collab_ui/src/chat_panel.rs
Normal file
File diff suppressed because it is too large
Load Diff
548
crates/collab_ui/src/chat_panel/message_editor.rs
Normal file
548
crates/collab_ui/src/chat_panel/message_editor.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use channel::{ChannelChat, ChannelStore, MessageParams};
|
||||
use client::{UserId, UserStore};
|
||||
use collections::HashSet;
|
||||
use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{
|
||||
AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight,
|
||||
HighlightStyle, IntoElement, Render, Task, TextStyle, WeakEntity, Window,
|
||||
};
|
||||
use language::{
|
||||
Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, ToOffset,
|
||||
language_settings::SoftWrap,
|
||||
};
|
||||
use project::{
|
||||
Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, search::SearchQuery,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
ops::Range,
|
||||
rc::Rc,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{TextSize, prelude::*};
|
||||
|
||||
use crate::panel_settings::MessageEditorSettings;
|
||||
|
||||
const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
static MENTIONS_SEARCH: LazyLock<SearchQuery> = LazyLock::new(|| {
|
||||
SearchQuery::regex(
|
||||
"@[-_\\w]+",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
pub struct MessageEditor {
|
||||
pub editor: Entity<Editor>,
|
||||
user_store: Entity<UserStore>,
|
||||
channel_chat: Option<Entity<ChannelChat>>,
|
||||
mentions: Vec<UserId>,
|
||||
mentions_task: Option<Task<()>>,
|
||||
reply_to_message_id: Option<u64>,
|
||||
edit_message_id: Option<u64>,
|
||||
}
|
||||
|
||||
struct MessageEditorCompletionProvider(WeakEntity<MessageEditor>);
|
||||
|
||||
impl CompletionProvider for MessageEditorCompletionProvider {
|
||||
fn completions(
|
||||
&self,
|
||||
_excerpt_id: ExcerptId,
|
||||
buffer: &Entity<Buffer>,
|
||||
buffer_position: language::Anchor,
|
||||
_: editor::CompletionContext,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
let Some(handle) = self.0.upgrade() else {
|
||||
return Task::ready(Ok(Vec::new()));
|
||||
};
|
||||
handle.update(cx, |message_editor, cx| {
|
||||
message_editor.completions(buffer, buffer_position, cx)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_completion_trigger(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: language::Anchor,
|
||||
text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
text == "@"
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageEditor {
|
||||
pub fn new(
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
user_store: Entity<UserStore>,
|
||||
channel_chat: Option<Entity<ChannelChat>>,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let this = cx.entity().downgrade();
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
editor.set_offset_content(false, cx);
|
||||
editor.set_use_autoclose(false);
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.set_show_wrap_guides(false, cx);
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_completion_provider(Some(Rc::new(MessageEditorCompletionProvider(this))));
|
||||
editor.set_auto_replace_emoji_shortcode(
|
||||
MessageEditorSettings::get_global(cx)
|
||||
.auto_replace_emoji_shortcode
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
});
|
||||
|
||||
let buffer = editor
|
||||
.read(cx)
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.expect("message editor must be singleton");
|
||||
|
||||
cx.subscribe_in(&buffer, window, Self::on_buffer_event)
|
||||
.detach();
|
||||
cx.observe_global::<settings::SettingsStore>(|this, cx| {
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
editor.set_auto_replace_emoji_shortcode(
|
||||
MessageEditorSettings::get_global(cx)
|
||||
.auto_replace_emoji_shortcode
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
|
||||
let markdown = language_registry.language_for_name("Markdown");
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
let markdown = markdown.await.context("failed to load Markdown language")?;
|
||||
buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
Self {
|
||||
editor,
|
||||
user_store,
|
||||
channel_chat,
|
||||
mentions: Vec::new(),
|
||||
mentions_task: None,
|
||||
reply_to_message_id: None,
|
||||
edit_message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reply_to_message_id(&self) -> Option<u64> {
|
||||
self.reply_to_message_id
|
||||
}
|
||||
|
||||
pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) {
|
||||
self.reply_to_message_id = Some(reply_to_message_id);
|
||||
}
|
||||
|
||||
pub fn clear_reply_to_message_id(&mut self) {
|
||||
self.reply_to_message_id = None;
|
||||
}
|
||||
|
||||
pub fn edit_message_id(&self) -> Option<u64> {
|
||||
self.edit_message_id
|
||||
}
|
||||
|
||||
pub fn set_edit_message_id(&mut self, edit_message_id: u64) {
|
||||
self.edit_message_id = Some(edit_message_id);
|
||||
}
|
||||
|
||||
pub fn clear_edit_message_id(&mut self) {
|
||||
self.edit_message_id = None;
|
||||
}
|
||||
|
||||
pub fn set_channel_chat(&mut self, chat: Entity<ChannelChat>, cx: &mut Context<Self>) {
|
||||
let channel_id = chat.read(cx).channel_id;
|
||||
self.channel_chat = Some(chat);
|
||||
let channel_name = ChannelStore::global(cx)
|
||||
.read(cx)
|
||||
.channel_for_id(channel_id)
|
||||
.map(|channel| channel.name.clone());
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
if let Some(channel_name) = channel_name {
|
||||
editor.set_placeholder_text(format!("Message #{channel_name}"), cx);
|
||||
} else {
|
||||
editor.set_placeholder_text("Message Channel", cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn take_message(&mut self, window: &mut Window, cx: &mut Context<Self>) -> MessageParams {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
let highlights = editor.text_highlights::<Self>(cx);
|
||||
let text = editor.text(cx);
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let mentions = if let Some((_, ranges)) = highlights {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| range.to_offset(&snapshot))
|
||||
.zip(self.mentions.iter().copied())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
editor.clear(window, cx);
|
||||
self.mentions.clear();
|
||||
let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id);
|
||||
|
||||
MessageParams {
|
||||
text,
|
||||
mentions,
|
||||
reply_to_message_id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn on_buffer_event(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
event: &language::BufferEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
|
||||
let buffer = buffer.read(cx).snapshot();
|
||||
self.mentions_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(MENTIONS_DEBOUNCE_INTERVAL)
|
||||
.await;
|
||||
Self::find_mentions(this, buffer, cx).await;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn completions(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<CompletionResponse>>> {
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_mention_candidates(buffer, end_anchor, cx)
|
||||
&& !candidates.is_empty()
|
||||
{
|
||||
return cx.spawn(async move |_, cx| {
|
||||
let completion_response = Self::completions_for_candidates(
|
||||
cx,
|
||||
query.as_str(),
|
||||
&candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_mention,
|
||||
)
|
||||
.await;
|
||||
Ok(vec![completion_response])
|
||||
});
|
||||
}
|
||||
|
||||
if let Some((start_anchor, query, candidates)) =
|
||||
self.collect_emoji_candidates(buffer, end_anchor, cx)
|
||||
&& !candidates.is_empty()
|
||||
{
|
||||
return cx.spawn(async move |_, cx| {
|
||||
let completion_response = Self::completions_for_candidates(
|
||||
cx,
|
||||
query.as_str(),
|
||||
candidates,
|
||||
start_anchor..end_anchor,
|
||||
Self::completion_for_emoji,
|
||||
)
|
||||
.await;
|
||||
Ok(vec![completion_response])
|
||||
});
|
||||
}
|
||||
|
||||
Task::ready(Ok(vec![CompletionResponse {
|
||||
completions: Vec::new(),
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: false,
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn completions_for_candidates(
|
||||
cx: &AsyncApp,
|
||||
query: &str,
|
||||
candidates: &[StringMatchCandidate],
|
||||
range: Range<Anchor>,
|
||||
completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
|
||||
) -> CompletionResponse {
|
||||
const LIMIT: usize = 10;
|
||||
let matches = fuzzy::match_strings(
|
||||
candidates,
|
||||
query,
|
||||
true,
|
||||
true,
|
||||
LIMIT,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let completions = matches
|
||||
.into_iter()
|
||||
.map(|mat| {
|
||||
let (new_text, label) = completion_fn(&mat);
|
||||
Completion {
|
||||
replace_range: range.clone(),
|
||||
new_text,
|
||||
label,
|
||||
icon_path: None,
|
||||
confirm: None,
|
||||
documentation: None,
|
||||
insert_text_mode: None,
|
||||
source: CompletionSource::Custom,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
CompletionResponse {
|
||||
is_incomplete: completions.len() >= LIMIT,
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
completions,
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
let label = CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!("@{}", mat.string),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
(mat.string.clone(), label)
|
||||
}
|
||||
|
||||
fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
|
||||
let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
|
||||
let label = CodeLabel {
|
||||
filter_range: 1..mat.string.len() + 1,
|
||||
text: format!(":{}: {}", mat.string, emoji),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
(emoji.to_string(), label)
|
||||
}
|
||||
|
||||
fn collect_mention_candidates(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
|
||||
let end_offset = end_anchor.to_offset(buffer.read(cx));
|
||||
|
||||
let query = buffer.read_with(cx, |buffer, _| {
|
||||
let mut query = String::new();
|
||||
for ch in buffer.reversed_chars_at(end_offset).take(100) {
|
||||
if ch == '@' {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
if ch.is_whitespace() || !ch.is_ascii() {
|
||||
break;
|
||||
}
|
||||
query.push(ch);
|
||||
}
|
||||
None
|
||||
})?;
|
||||
|
||||
let start_offset = end_offset - query.len();
|
||||
let start_anchor = buffer.read(cx).anchor_before(start_offset);
|
||||
|
||||
let mut names = HashSet::default();
|
||||
if let Some(chat) = self.channel_chat.as_ref() {
|
||||
let chat = chat.read(cx);
|
||||
for participant in ChannelStore::global(cx)
|
||||
.read(cx)
|
||||
.channel_participants(chat.channel_id)
|
||||
{
|
||||
names.insert(participant.github_login.clone());
|
||||
}
|
||||
for message in chat
|
||||
.messages_in_range(chat.message_count().saturating_sub(100)..chat.message_count())
|
||||
{
|
||||
names.insert(message.sender.github_login.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = names
|
||||
.into_iter()
|
||||
.map(|user| StringMatchCandidate::new(0, &user))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some((start_anchor, query, candidates))
|
||||
}
|
||||
|
||||
fn collect_emoji_candidates(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
end_anchor: Anchor,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
|
||||
static EMOJI_FUZZY_MATCH_CANDIDATES: LazyLock<Vec<StringMatchCandidate>> =
|
||||
LazyLock::new(|| {
|
||||
emojis::iter()
|
||||
.flat_map(|s| s.shortcodes())
|
||||
.map(|emoji| StringMatchCandidate::new(0, emoji))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let end_offset = end_anchor.to_offset(buffer.read(cx));
|
||||
|
||||
let query = buffer.read_with(cx, |buffer, _| {
|
||||
let mut query = String::new();
|
||||
for ch in buffer.reversed_chars_at(end_offset).take(100) {
|
||||
if ch == ':' {
|
||||
let next_char = buffer
|
||||
.reversed_chars_at(end_offset - query.len() - 1)
|
||||
.next();
|
||||
// Ensure we are at the start of the message or that the previous character is a whitespace
|
||||
if next_char.is_none() || next_char.unwrap().is_whitespace() {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
|
||||
// If the previous character is not a whitespace, we are in the middle of a word
|
||||
// and we only want to complete the shortcode if the word is made up of other emojis
|
||||
let mut containing_word = String::new();
|
||||
for ch in buffer
|
||||
.reversed_chars_at(end_offset - query.len() - 1)
|
||||
.take(100)
|
||||
{
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
containing_word.push(ch);
|
||||
}
|
||||
let containing_word = containing_word.chars().rev().collect::<String>();
|
||||
if util::word_consists_of_emojis(containing_word.as_str()) {
|
||||
return Some(query.chars().rev().collect::<String>());
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ch.is_whitespace() || !ch.is_ascii() {
|
||||
break;
|
||||
}
|
||||
query.push(ch);
|
||||
}
|
||||
None
|
||||
})?;
|
||||
|
||||
let start_offset = end_offset - query.len() - 1;
|
||||
let start_anchor = buffer.read(cx).anchor_before(start_offset);
|
||||
|
||||
Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
|
||||
}
|
||||
|
||||
async fn find_mentions(
|
||||
this: WeakEntity<MessageEditor>,
|
||||
buffer: BufferSnapshot,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) {
|
||||
let (buffer, ranges) = cx
|
||||
.background_spawn(async move {
|
||||
let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
|
||||
(buffer, ranges)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let mut anchor_ranges = Vec::new();
|
||||
let mut mentioned_user_ids = Vec::new();
|
||||
let mut text = String::new();
|
||||
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
let multi_buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
for range in ranges {
|
||||
text.clear();
|
||||
text.extend(buffer.text_for_range(range.clone()));
|
||||
if let Some(username) = text.strip_prefix('@')
|
||||
&& let Some(user) = this
|
||||
.user_store
|
||||
.read(cx)
|
||||
.cached_user_by_github_login(username)
|
||||
{
|
||||
let start = multi_buffer.anchor_after(range.start);
|
||||
let end = multi_buffer.anchor_after(range.end);
|
||||
|
||||
mentioned_user_ids.push(user.id);
|
||||
anchor_ranges.push(start..end);
|
||||
}
|
||||
}
|
||||
|
||||
editor.clear_highlights::<Self>(cx);
|
||||
editor.highlight_text::<Self>(
|
||||
anchor_ranges,
|
||||
HighlightStyle {
|
||||
font_weight: Some(FontWeight::BOLD),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
this.mentions = mentioned_user_ids;
|
||||
this.mentions_task.take();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub(crate) fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
|
||||
self.editor.read(cx).focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MessageEditor {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_style = TextStyle {
|
||||
color: if self.editor.read(cx).read_only(cx) {
|
||||
cx.theme().colors().text_disabled
|
||||
} else {
|
||||
cx.theme().colors().text
|
||||
},
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_fallbacks: settings.ui_font.fallbacks.clone(),
|
||||
font_size: TextSize::Small.rems(cx).into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
font_style: FontStyle::Normal,
|
||||
line_height: relative(1.3),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_sm()
|
||||
.child(EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ mod channel_modal;
|
||||
mod contact_finder;
|
||||
|
||||
use self::channel_modal::ChannelModal;
|
||||
use crate::{CollaborationPanelSettings, channel_view::ChannelView};
|
||||
use crate::{CollaborationPanelSettings, channel_view::ChannelView, chat_panel::ChatPanel};
|
||||
use anyhow::Context as _;
|
||||
use call::ActiveCall;
|
||||
use channel::{Channel, ChannelEvent, ChannelStore};
|
||||
@@ -38,7 +38,7 @@ use util::{ResultExt, TryFutureExt, maybe};
|
||||
use workspace::{
|
||||
Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace,
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, NotifyResultExt},
|
||||
notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
|
||||
};
|
||||
|
||||
actions!(
|
||||
@@ -261,6 +261,9 @@ enum ListEntry {
|
||||
ChannelNotes {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
ChannelChat {
|
||||
channel_id: ChannelId,
|
||||
},
|
||||
ChannelEditor {
|
||||
depth: usize,
|
||||
},
|
||||
@@ -492,6 +495,7 @@ impl CollabPanel {
|
||||
&& let Some(channel_id) = room.channel_id()
|
||||
{
|
||||
self.entries.push(ListEntry::ChannelNotes { channel_id });
|
||||
self.entries.push(ListEntry::ChannelChat { channel_id });
|
||||
}
|
||||
|
||||
// Populate the active user.
|
||||
@@ -1085,6 +1089,39 @@ impl CollabPanel {
|
||||
.tooltip(Tooltip::text("Open Channel Notes"))
|
||||
}
|
||||
|
||||
fn render_channel_chat(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
is_selected: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let has_messages_notification = channel_store.has_new_messages(channel_id);
|
||||
ListItem::new("channel-chat")
|
||||
.toggle_state(is_selected)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.join_channel_chat(channel_id, window, cx);
|
||||
}))
|
||||
.start_slot(
|
||||
h_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
.child(render_tree_branch(false, false, window, cx))
|
||||
.child(IconButton::new(0, IconName::Chat))
|
||||
.children(has_messages_notification.then(|| {
|
||||
div()
|
||||
.w_1p5()
|
||||
.absolute()
|
||||
.right(px(2.))
|
||||
.top(px(4.))
|
||||
.child(Indicator::dot().color(Color::Info))
|
||||
})),
|
||||
)
|
||||
.child(Label::new("chat"))
|
||||
.tooltip(Tooltip::text("Open Chat"))
|
||||
}
|
||||
|
||||
fn has_subchannels(&self, ix: usize) -> bool {
|
||||
self.entries.get(ix).is_some_and(|entry| {
|
||||
if let ListEntry::Channel { has_children, .. } = entry {
|
||||
@@ -1259,6 +1296,13 @@ impl CollabPanel {
|
||||
this.open_channel_notes(channel_id, window, cx)
|
||||
}),
|
||||
)
|
||||
.entry(
|
||||
"Open Chat",
|
||||
None,
|
||||
window.handler_for(&this, move |this, window, cx| {
|
||||
this.join_channel_chat(channel_id, window, cx)
|
||||
}),
|
||||
)
|
||||
.entry(
|
||||
"Copy Channel Link",
|
||||
None,
|
||||
@@ -1588,6 +1632,9 @@ impl CollabPanel {
|
||||
ListEntry::ChannelNotes { channel_id } => {
|
||||
self.open_channel_notes(*channel_id, window, cx)
|
||||
}
|
||||
ListEntry::ChannelChat { channel_id } => {
|
||||
self.join_channel_chat(*channel_id, window, cx)
|
||||
}
|
||||
ListEntry::OutgoingRequest(_) => {}
|
||||
ListEntry::ChannelEditor { .. } => {}
|
||||
}
|
||||
@@ -2211,6 +2258,28 @@ impl CollabPanel {
|
||||
.detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
|
||||
}
|
||||
|
||||
fn join_channel_chat(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel
|
||||
.select_channel(channel_id, None, cx)
|
||||
.detach_and_notify_err(window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn copy_channel_link(&mut self, channel_id: ChannelId, cx: &mut Context<Self>) {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let Some(channel) = channel_store.channel_for_id(channel_id) else {
|
||||
@@ -2329,6 +2398,9 @@ impl CollabPanel {
|
||||
ListEntry::ChannelNotes { channel_id } => self
|
||||
.render_channel_notes(*channel_id, is_selected, window, cx)
|
||||
.into_any_element(),
|
||||
ListEntry::ChannelChat { channel_id } => self
|
||||
.render_channel_chat(*channel_id, is_selected, window, cx)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2709,6 +2781,7 @@ impl CollabPanel {
|
||||
let disclosed =
|
||||
has_children.then(|| self.collapsed_channels.binary_search(&channel.id).is_err());
|
||||
|
||||
let has_messages_notification = channel_store.has_new_messages(channel_id);
|
||||
let has_notes_notification = channel_store.has_channel_buffer_changed(channel_id);
|
||||
|
||||
const FACEPILE_LIMIT: usize = 3;
|
||||
@@ -2836,6 +2909,21 @@ impl CollabPanel {
|
||||
.rounded_l_sm()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.child(
|
||||
IconButton::new("channel_chat", IconName::Chat)
|
||||
.style(ButtonStyle::Filled)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(if has_messages_notification {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.join_channel_chat(channel_id, window, cx)
|
||||
}))
|
||||
.tooltip(Tooltip::text("Open channel chat")),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("channel_notes", IconName::Reader)
|
||||
.style(ButtonStyle::Filled)
|
||||
@@ -3095,6 +3183,14 @@ impl PartialEq for ListEntry {
|
||||
return channel_id == other_id;
|
||||
}
|
||||
}
|
||||
ListEntry::ChannelChat { channel_id } => {
|
||||
if let ListEntry::ChannelChat {
|
||||
channel_id: other_id,
|
||||
} = other
|
||||
{
|
||||
return channel_id == other_id;
|
||||
}
|
||||
}
|
||||
ListEntry::ChannelInvite(channel_1) => {
|
||||
if let ListEntry::ChannelInvite(channel_2) = other {
|
||||
return channel_1.id == channel_2.id;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod channel_view;
|
||||
pub mod chat_panel;
|
||||
pub mod collab_panel;
|
||||
pub mod notification_panel;
|
||||
pub mod notifications;
|
||||
@@ -12,7 +13,9 @@ use gpui::{
|
||||
WindowDecorations, WindowKind, WindowOptions, point,
|
||||
};
|
||||
use panel_settings::MessageEditorSettings;
|
||||
pub use panel_settings::{CollaborationPanelSettings, NotificationPanelSettings};
|
||||
pub use panel_settings::{
|
||||
ChatPanelButton, ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
|
||||
};
|
||||
use release_channel::ReleaseChannel;
|
||||
use settings::Settings;
|
||||
use ui::px;
|
||||
@@ -20,10 +23,12 @@ use workspace::AppState;
|
||||
|
||||
pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
|
||||
CollaborationPanelSettings::register(cx);
|
||||
ChatPanelSettings::register(cx);
|
||||
NotificationPanelSettings::register(cx);
|
||||
MessageEditorSettings::register(cx);
|
||||
|
||||
channel_view::init(cx);
|
||||
chat_panel::init(cx);
|
||||
collab_panel::init(cx);
|
||||
notification_panel::init(cx);
|
||||
notifications::init(app_state, cx);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::NotificationPanelSettings;
|
||||
use crate::{NotificationPanelSettings, chat_panel::ChatPanel};
|
||||
use anyhow::Result;
|
||||
use channel::ChannelStore;
|
||||
use client::{ChannelId, Client, Notification, User, UserStore};
|
||||
@@ -6,8 +6,8 @@ use collections::HashMap;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
|
||||
AnyElement, App, AsyncWindowContext, ClickEvent, Context, CursorStyle, DismissEvent, Element,
|
||||
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
|
||||
ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task,
|
||||
WeakEntity, Window, actions, div, img, list, px,
|
||||
};
|
||||
@@ -71,6 +71,7 @@ pub struct NotificationPresenter {
|
||||
pub text: String,
|
||||
pub icon: &'static str,
|
||||
pub needs_response: bool,
|
||||
pub can_navigate: bool,
|
||||
}
|
||||
|
||||
actions!(
|
||||
@@ -233,6 +234,7 @@ impl NotificationPanel {
|
||||
actor,
|
||||
text,
|
||||
needs_response,
|
||||
can_navigate,
|
||||
..
|
||||
} = self.present_notification(entry, cx)?;
|
||||
|
||||
@@ -267,6 +269,14 @@ impl NotificationPanel {
|
||||
.py_1()
|
||||
.gap_2()
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
.when(can_navigate, |el| {
|
||||
el.cursor(CursorStyle::PointingHand).on_click({
|
||||
let notification = notification.clone();
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.did_click_notification(¬ification, window, cx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.children(actor.map(|actor| {
|
||||
img(actor.avatar_uri.clone())
|
||||
.flex_none()
|
||||
@@ -359,6 +369,7 @@ impl NotificationPanel {
|
||||
text: format!("{} wants to add you as a contact", requester.github_login),
|
||||
needs_response: user_store.has_incoming_contact_request(requester.id),
|
||||
actor: Some(requester),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ContactRequestAccepted { responder_id } => {
|
||||
@@ -368,6 +379,7 @@ impl NotificationPanel {
|
||||
text: format!("{} accepted your contact invite", responder.github_login),
|
||||
needs_response: false,
|
||||
actor: Some(responder),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ChannelInvitation {
|
||||
@@ -384,6 +396,29 @@ impl NotificationPanel {
|
||||
),
|
||||
needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
|
||||
actor: Some(inviter),
|
||||
can_navigate: false,
|
||||
})
|
||||
}
|
||||
Notification::ChannelMessageMention {
|
||||
sender_id,
|
||||
channel_id,
|
||||
message_id,
|
||||
} => {
|
||||
let sender = user_store.get_cached_user(sender_id)?;
|
||||
let channel = channel_store.channel_for_id(ChannelId(channel_id))?;
|
||||
let message = self
|
||||
.notification_store
|
||||
.read(cx)
|
||||
.channel_message_for_id(message_id)?;
|
||||
Some(NotificationPresenter {
|
||||
icon: "icons/conversations.svg",
|
||||
text: format!(
|
||||
"{} mentioned you in #{}:\n{}",
|
||||
sender.github_login, channel.name, message.body,
|
||||
),
|
||||
needs_response: false,
|
||||
actor: Some(sender),
|
||||
can_navigate: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -398,7 +433,9 @@ impl NotificationPanel {
|
||||
) {
|
||||
let should_mark_as_read = match notification {
|
||||
Notification::ContactRequestAccepted { .. } => true,
|
||||
Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } => false,
|
||||
Notification::ContactRequest { .. }
|
||||
| Notification::ChannelInvitation { .. }
|
||||
| Notification::ChannelMessageMention { .. } => false,
|
||||
};
|
||||
|
||||
if should_mark_as_read {
|
||||
@@ -420,6 +457,55 @@ impl NotificationPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn did_click_notification(
|
||||
&mut self,
|
||||
notification: &Notification,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Notification::ChannelMessageMention {
|
||||
message_id,
|
||||
channel_id,
|
||||
..
|
||||
} = notification.clone()
|
||||
&& let Some(workspace) = self.workspace.upgrade()
|
||||
{
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel
|
||||
.select_channel(ChannelId(channel_id), Some(message_id), cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn is_showing_notification(&self, notification: &Notification, cx: &mut Context<Self>) -> bool {
|
||||
if !self.active {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification
|
||||
&& let Some(workspace) = self.workspace.upgrade()
|
||||
{
|
||||
return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
|
||||
let panel = panel.read(cx);
|
||||
panel.is_scrolled_to_bottom()
|
||||
&& panel
|
||||
.active_chat()
|
||||
.is_some_and(|chat| chat.read(cx).channel_id.0 == *channel_id)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn on_notification_event(
|
||||
&mut self,
|
||||
_: &Entity<NotificationStore>,
|
||||
@@ -429,7 +515,9 @@ impl NotificationPanel {
|
||||
) {
|
||||
match event {
|
||||
NotificationEvent::NewNotification { entry } => {
|
||||
self.unseen_notifications.push(entry.clone());
|
||||
if !self.is_showing_notification(&entry.notification, cx) {
|
||||
self.unseen_notifications.push(entry.clone());
|
||||
}
|
||||
self.add_toast(entry, window, cx);
|
||||
}
|
||||
NotificationEvent::NotificationRemoved { entry }
|
||||
@@ -453,6 +541,10 @@ impl NotificationPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.is_showing_notification(&entry.notification, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
|
||||
else {
|
||||
return;
|
||||
@@ -476,6 +568,7 @@ impl NotificationPanel {
|
||||
workspace.show_notification(id, cx, |cx| {
|
||||
let workspace = cx.entity().downgrade();
|
||||
cx.new(|cx| NotificationToast {
|
||||
notification_id,
|
||||
actor,
|
||||
text,
|
||||
workspace,
|
||||
@@ -688,6 +781,7 @@ impl Panel for NotificationPanel {
|
||||
}
|
||||
|
||||
pub struct NotificationToast {
|
||||
notification_id: u64,
|
||||
actor: Option<Arc<User>>,
|
||||
text: String,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
@@ -705,10 +799,22 @@ impl WorkspaceNotification for NotificationToast {}
|
||||
impl NotificationToast {
|
||||
fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let workspace = self.workspace.clone();
|
||||
let notification_id = self.notification_id;
|
||||
window.defer(cx, move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.focus_panel::<NotificationPanel>(window, cx)
|
||||
if let Some(panel) = workspace.focus_panel::<NotificationPanel>(window, cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
let store = panel.notification_store.read(cx);
|
||||
if let Some(entry) = store.notification_for_id(notification_id) {
|
||||
panel.did_click_notification(
|
||||
&entry.clone().notification,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
|
||||
@@ -11,6 +11,39 @@ pub struct CollaborationPanelSettings {
|
||||
pub default_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChatPanelButton {
|
||||
Never,
|
||||
Always,
|
||||
#[default]
|
||||
WhenInCall,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ChatPanelSettings {
|
||||
pub button: ChatPanelButton,
|
||||
pub dock: DockPosition,
|
||||
pub default_width: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi, SettingsKey)]
|
||||
#[settings_key(key = "chat_panel")]
|
||||
pub struct ChatPanelSettingsContent {
|
||||
/// When to show the panel button in the status bar.
|
||||
///
|
||||
/// Default: only when in a call
|
||||
pub button: Option<ChatPanelButton>,
|
||||
/// Where to dock the panel.
|
||||
///
|
||||
/// Default: right
|
||||
pub dock: Option<DockPosition>,
|
||||
/// Default width of the panel in pixels.
|
||||
///
|
||||
/// Default: 240
|
||||
pub default_width: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi, SettingsKey)]
|
||||
#[settings_key(key = "collaboration_panel")]
|
||||
pub struct PanelSettingsContent {
|
||||
@@ -75,6 +108,19 @@ impl Settings for CollaborationPanelSettings {
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
impl Settings for ChatPanelSettings {
|
||||
type FileContent = ChatPanelSettingsContent;
|
||||
|
||||
fn load(
|
||||
sources: SettingsSources<Self::FileContent>,
|
||||
_: &mut gpui::App,
|
||||
) -> anyhow::Result<Self> {
|
||||
sources.json_merge()
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
}
|
||||
|
||||
impl Settings for NotificationPanelSettings {
|
||||
type FileContent = NotificationPanelSettingsContent;
|
||||
|
||||
|
||||
@@ -128,8 +128,6 @@ struct ModelCapabilities {
|
||||
supports: ModelSupportedFeatures,
|
||||
#[serde(rename = "type")]
|
||||
model_type: String,
|
||||
#[serde(default)]
|
||||
tokenizer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
|
||||
@@ -168,9 +166,6 @@ pub enum ModelVendor {
|
||||
Anthropic,
|
||||
#[serde(rename = "xAI")]
|
||||
XAI,
|
||||
/// Unknown vendor that we don't explicitly support yet
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
|
||||
@@ -219,10 +214,6 @@ impl Model {
|
||||
pub fn supports_parallel_tool_calls(&self) -> bool {
|
||||
self.capabilities.supports.parallel_tool_calls
|
||||
}
|
||||
|
||||
pub fn tokenizer(&self) -> Option<&str> {
|
||||
self.capabilities.tokenizer.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -910,45 +901,4 @@ mod tests {
|
||||
assert_eq!(schema.data[0].id, "gpt-4");
|
||||
assert_eq!(schema.data[1].id, "claude-3.7-sonnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_vendor_resilience() {
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{
|
||||
"billing": {
|
||||
"is_premium": false,
|
||||
"multiplier": 1
|
||||
},
|
||||
"capabilities": {
|
||||
"family": "future-model",
|
||||
"limits": {
|
||||
"max_context_window_tokens": 128000,
|
||||
"max_output_tokens": 8192,
|
||||
"max_prompt_tokens": 120000
|
||||
},
|
||||
"object": "model_capabilities",
|
||||
"supports": { "streaming": true, "tool_calls": true },
|
||||
"type": "chat"
|
||||
},
|
||||
"id": "future-model-v1",
|
||||
"is_chat_default": false,
|
||||
"is_chat_fallback": false,
|
||||
"model_picker_enabled": true,
|
||||
"name": "Future Model v1",
|
||||
"object": "model",
|
||||
"preview": false,
|
||||
"vendor": "SomeNewVendor",
|
||||
"version": "v1.0"
|
||||
}
|
||||
],
|
||||
"object": "list"
|
||||
}"#;
|
||||
|
||||
let schema: ModelSchema = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(schema.data.len(), 1);
|
||||
assert_eq!(schema.data[0].id, "future-model-v1");
|
||||
assert_eq!(schema.data[0].vendor, ModelVendor::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub enum DebugPanelDockPosition {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema, Clone, SettingsUi, SettingsKey)]
|
||||
#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, SettingsUi, SettingsKey)]
|
||||
#[serde(default)]
|
||||
// todo(settings_ui) @ben: I'm pretty sure not having the fields be optional here is a bug,
|
||||
// it means the defaults will override previously set values if a single key is missing
|
||||
|
||||
@@ -92,6 +92,7 @@ uuid.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
postage.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ctor.workspace = true
|
||||
|
||||
@@ -493,10 +493,6 @@ actions!(
|
||||
GoToTypeDefinition,
|
||||
/// Goes to type definition in a split pane.
|
||||
GoToTypeDefinitionSplit,
|
||||
/// Goes to the next document highlight.
|
||||
GoToNextDocumentHighlight,
|
||||
/// Goes to the previous document highlight.
|
||||
GoToPreviousDocumentHighlight,
|
||||
/// Scrolls down by half a page.
|
||||
HalfPageDown,
|
||||
/// Scrolls up by half a page.
|
||||
@@ -644,10 +640,6 @@ actions!(
|
||||
SelectEnclosingSymbol,
|
||||
/// Selects the next larger syntax node.
|
||||
SelectLargerSyntaxNode,
|
||||
/// Selects the next syntax node sibling.
|
||||
SelectNextSyntaxNode,
|
||||
/// Selects the previous syntax node sibling.
|
||||
SelectPreviousSyntaxNode,
|
||||
/// Extends selection left.
|
||||
SelectLeft,
|
||||
/// Selects the current line.
|
||||
|
||||
@@ -1502,7 +1502,6 @@ impl CodeActionsMenu {
|
||||
this.child(
|
||||
h_flex()
|
||||
.overflow_hidden()
|
||||
.text_sm()
|
||||
.child(
|
||||
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
|
||||
action.lsp_action.title().replace("\n", ""),
|
||||
|
||||
@@ -2609,7 +2609,7 @@ pub mod tests {
|
||||
);
|
||||
language.set_theme(&theme);
|
||||
|
||||
let (text, highlighted_ranges) = marked_text_ranges(r#"constˇ «a»«:» B = "c «d»""#, false);
|
||||
let (text, highlighted_ranges) = marked_text_ranges(r#"constˇ «a»: B = "c «d»""#, false);
|
||||
|
||||
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
|
||||
cx.condition(&buffer, |buf, _| !buf.is_parsing()).await;
|
||||
@@ -2658,7 +2658,7 @@ pub mod tests {
|
||||
[
|
||||
("const ".to_string(), None, None),
|
||||
("a".to_string(), None, Some(Hsla::blue())),
|
||||
(":".to_string(), Some(Hsla::red()), Some(Hsla::blue())),
|
||||
(":".to_string(), Some(Hsla::red()), None),
|
||||
(" B = ".to_string(), None, None),
|
||||
("\"c ".to_string(), Some(Hsla::green()), None),
|
||||
("d".to_string(), Some(Hsla::green()), Some(Hsla::blue())),
|
||||
|
||||
@@ -25,8 +25,9 @@ pub struct CustomHighlightsChunks<'a> {
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
struct HighlightEndpoint {
|
||||
offset: usize,
|
||||
is_start: bool,
|
||||
tag: HighlightKey,
|
||||
style: Option<HighlightStyle>,
|
||||
style: HighlightStyle,
|
||||
}
|
||||
|
||||
impl<'a> CustomHighlightsChunks<'a> {
|
||||
@@ -91,20 +92,17 @@ fn create_highlight_endpoints(
|
||||
break;
|
||||
}
|
||||
|
||||
let start = range.start.to_offset(buffer);
|
||||
let end = range.end.to_offset(buffer);
|
||||
if start == end {
|
||||
continue;
|
||||
}
|
||||
highlight_endpoints.push(HighlightEndpoint {
|
||||
offset: start,
|
||||
offset: range.start.to_offset(buffer),
|
||||
is_start: true,
|
||||
tag,
|
||||
style: Some(style),
|
||||
style,
|
||||
});
|
||||
highlight_endpoints.push(HighlightEndpoint {
|
||||
offset: end,
|
||||
offset: range.end.to_offset(buffer),
|
||||
is_start: false,
|
||||
tag,
|
||||
style: None,
|
||||
style,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -120,8 +118,8 @@ impl<'a> Iterator for CustomHighlightsChunks<'a> {
|
||||
let mut next_highlight_endpoint = usize::MAX;
|
||||
while let Some(endpoint) = self.highlight_endpoints.peek().copied() {
|
||||
if endpoint.offset <= self.offset {
|
||||
if let Some(style) = endpoint.style {
|
||||
self.active_highlights.insert(endpoint.tag, style);
|
||||
if endpoint.is_start {
|
||||
self.active_highlights.insert(endpoint.tag, endpoint.style);
|
||||
} else {
|
||||
self.active_highlights.remove(&endpoint.tag);
|
||||
}
|
||||
@@ -170,6 +168,6 @@ impl Ord for HighlightEndpoint {
|
||||
fn cmp(&self, other: &Self) -> cmp::Ordering {
|
||||
self.offset
|
||||
.cmp(&other.offset)
|
||||
.then_with(|| self.style.is_some().cmp(&other.style.is_some()))
|
||||
.then_with(|| other.is_start.cmp(&self.is_start))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,17 +177,15 @@ use snippet::Snippet;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
borrow::Cow,
|
||||
cell::OnceCell,
|
||||
cell::RefCell,
|
||||
cell::{OnceCell, RefCell},
|
||||
cmp::{self, Ordering, Reverse},
|
||||
iter::Peekable,
|
||||
mem,
|
||||
num::NonZeroU32,
|
||||
ops::Not,
|
||||
ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive},
|
||||
ops::{ControlFlow, Deref, DerefMut, Not, Range, RangeInclusive},
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
sync::{Arc, LazyLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
|
||||
@@ -236,6 +234,21 @@ pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
|
||||
pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
|
||||
pub(crate) const MINIMAP_FONT_SIZE: AbsoluteLength = AbsoluteLength::Pixels(px(2.));
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct LastCursorPosition {
|
||||
pub path: PathBuf,
|
||||
pub worktree_path: Arc<Path>,
|
||||
pub point: Point,
|
||||
}
|
||||
|
||||
pub static LAST_CURSOR_POSITION_WATCH: LazyLock<(
|
||||
Mutex<postage::watch::Sender<Option<LastCursorPosition>>>,
|
||||
postage::watch::Receiver<Option<LastCursorPosition>>,
|
||||
)> = LazyLock::new(|| {
|
||||
let (sender, receiver) = postage::watch::channel();
|
||||
(Mutex::new(sender), receiver)
|
||||
});
|
||||
|
||||
pub type RenderDiffHunkControlsFn = Arc<
|
||||
dyn Fn(
|
||||
u32,
|
||||
@@ -3064,10 +3077,28 @@ impl Editor {
|
||||
let new_cursor_position = newest_selection.head();
|
||||
let selection_start = newest_selection.start;
|
||||
|
||||
let new_cursor_point = new_cursor_position.to_point(buffer);
|
||||
if let Some(project) = self.project()
|
||||
&& let Some((path, worktree_path)) =
|
||||
self.file_at(new_cursor_point, cx).and_then(|file| {
|
||||
file.as_local().and_then(|file| {
|
||||
let worktree =
|
||||
project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?;
|
||||
Some((file.abs_path(cx), worktree.read(cx).abs_path()))
|
||||
})
|
||||
})
|
||||
{
|
||||
*LAST_CURSOR_POSITION_WATCH.0.lock().borrow_mut() = Some(LastCursorPosition {
|
||||
path,
|
||||
worktree_path,
|
||||
point: new_cursor_point,
|
||||
});
|
||||
}
|
||||
|
||||
if effects.nav_history.is_none() || effects.nav_history == Some(true) {
|
||||
self.push_to_nav_history(
|
||||
*old_cursor_position,
|
||||
Some(new_cursor_position.to_point(buffer)),
|
||||
Some(new_cursor_point),
|
||||
false,
|
||||
effects.nav_history == Some(true),
|
||||
cx,
|
||||
@@ -14946,13 +14977,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
let mut new_range = old_range.clone();
|
||||
while let Some((node, containing_range)) = buffer.syntax_ancestor(new_range.clone())
|
||||
while let Some((_node, containing_range)) =
|
||||
buffer.syntax_ancestor(new_range.clone())
|
||||
{
|
||||
if !node.is_named() {
|
||||
new_range = node.start_byte()..node.end_byte();
|
||||
continue;
|
||||
}
|
||||
|
||||
new_range = match containing_range {
|
||||
MultiOrSingleBufferOffsetRange::Single(_) => break,
|
||||
MultiOrSingleBufferOffsetRange::Multi(range) => range,
|
||||
@@ -15142,104 +15169,6 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_next_syntax_node(
|
||||
&mut self,
|
||||
_: &SelectNextSyntaxNode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
|
||||
if old_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
|
||||
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let mut selected_sibling = false;
|
||||
|
||||
let new_selections = old_selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let old_range = selection.start..selection.end;
|
||||
|
||||
if let Some(node) = buffer.syntax_next_sibling(old_range) {
|
||||
let new_range = node.byte_range();
|
||||
selected_sibling = true;
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start: new_range.start,
|
||||
end: new_range.end,
|
||||
goal: SelectionGoal::None,
|
||||
reversed: selection.reversed,
|
||||
}
|
||||
} else {
|
||||
selection.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if selected_sibling {
|
||||
self.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::fit()),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select(new_selections);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_prev_syntax_node(
|
||||
&mut self,
|
||||
_: &SelectPreviousSyntaxNode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_selections: Box<[_]> = self.selections.all::<usize>(cx).into();
|
||||
if old_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
|
||||
|
||||
let buffer = self.buffer.read(cx).snapshot(cx);
|
||||
let mut selected_sibling = false;
|
||||
|
||||
let new_selections = old_selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let old_range = selection.start..selection.end;
|
||||
|
||||
if let Some(node) = buffer.syntax_prev_sibling(old_range) {
|
||||
let new_range = node.byte_range();
|
||||
selected_sibling = true;
|
||||
Selection {
|
||||
id: selection.id,
|
||||
start: new_range.start,
|
||||
end: new_range.end,
|
||||
goal: SelectionGoal::None,
|
||||
reversed: selection.reversed,
|
||||
}
|
||||
} else {
|
||||
selection.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if selected_sibling {
|
||||
self.change_selections(
|
||||
SelectionEffects::scroll(Autoscroll::fit()),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select(new_selections);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||
if !EditorSettings::get_global(cx).gutter.runnables {
|
||||
self.clear_tasks();
|
||||
@@ -15948,87 +15877,6 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go_to_next_document_highlight(
|
||||
&mut self,
|
||||
_: &GoToNextDocumentHighlight,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.go_to_document_highlight_before_or_after_position(Direction::Next, window, cx);
|
||||
}
|
||||
|
||||
pub fn go_to_prev_document_highlight(
|
||||
&mut self,
|
||||
_: &GoToPreviousDocumentHighlight,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.go_to_document_highlight_before_or_after_position(Direction::Prev, window, cx);
|
||||
}
|
||||
|
||||
pub fn go_to_document_highlight_before_or_after_position(
|
||||
&mut self,
|
||||
direction: Direction,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
|
||||
let snapshot = self.snapshot(window, cx);
|
||||
let buffer = &snapshot.buffer_snapshot;
|
||||
let position = self.selections.newest::<Point>(cx).head();
|
||||
let anchor_position = buffer.anchor_after(position);
|
||||
|
||||
// Get all document highlights (both read and write)
|
||||
let mut all_highlights = Vec::new();
|
||||
|
||||
if let Some((_, read_highlights)) = self
|
||||
.background_highlights
|
||||
.get(&HighlightKey::Type(TypeId::of::<DocumentHighlightRead>()))
|
||||
{
|
||||
all_highlights.extend(read_highlights.iter());
|
||||
}
|
||||
|
||||
if let Some((_, write_highlights)) = self
|
||||
.background_highlights
|
||||
.get(&HighlightKey::Type(TypeId::of::<DocumentHighlightWrite>()))
|
||||
{
|
||||
all_highlights.extend(write_highlights.iter());
|
||||
}
|
||||
|
||||
if all_highlights.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort highlights by position
|
||||
all_highlights.sort_by(|a, b| a.start.cmp(&b.start, buffer));
|
||||
|
||||
let target_highlight = match direction {
|
||||
Direction::Next => {
|
||||
// Find the first highlight after the current position
|
||||
all_highlights
|
||||
.iter()
|
||||
.find(|highlight| highlight.start.cmp(&anchor_position, buffer).is_gt())
|
||||
}
|
||||
Direction::Prev => {
|
||||
// Find the last highlight before the current position
|
||||
all_highlights
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|highlight| highlight.end.cmp(&anchor_position, buffer).is_lt())
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(highlight) = target_highlight {
|
||||
let destination = highlight.start.to_point(buffer);
|
||||
let autoscroll = Autoscroll::center();
|
||||
|
||||
self.unfold_ranges(&[destination..destination], false, false, cx);
|
||||
self.change_selections(SelectionEffects::scroll(autoscroll), window, cx, |s| {
|
||||
s.select_ranges([destination..destination]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn go_to_line<T: 'static>(
|
||||
&mut self,
|
||||
position: Anchor,
|
||||
|
||||
@@ -8591,10 +8591,10 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
use mod1::mod2::{mod3, moˇd4};
|
||||
use mod1::mod2::{mod3, mo«ˇ»d4};
|
||||
|
||||
fn fn_1(para«ˇm1: bool, pa»ram2: &str) {
|
||||
let var1 = "teˇxt";
|
||||
let var1 = "te«ˇ»xt";
|
||||
}
|
||||
"#},
|
||||
cx,
|
||||
@@ -8609,10 +8609,10 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut TestAppContext) {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
use mod1::mod2::{mod3, moˇd4};
|
||||
use mod1::mod2::{mod3, mo«ˇ»d4};
|
||||
|
||||
fn fn_1(para«ˇm1: bool, pa»ram2: &str) {
|
||||
let var1 = "teˇxt";
|
||||
let var1 = "te«ˇ»xt";
|
||||
}
|
||||
"#},
|
||||
cx,
|
||||
@@ -8716,184 +8716,6 @@ async fn test_select_larger_syntax_node_for_cursor_at_end(cx: &mut TestAppContex
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_select_larger_syntax_node_for_cursor_at_symbol(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let language = Arc::new(Language::new(
|
||||
LanguageConfig {
|
||||
name: "JavaScript".into(),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
|
||||
));
|
||||
|
||||
let text = r#"
|
||||
let a = {
|
||||
key: "value",
|
||||
};
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
|
||||
|
||||
editor
|
||||
.condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
|
||||
.await;
|
||||
|
||||
// Test case 1: Cursor after '{'
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(0), 9)..DisplayPoint::new(DisplayRow(0), 9)
|
||||
]);
|
||||
});
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = {ˇ
|
||||
key: "value",
|
||||
};
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx);
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = «ˇ{
|
||||
key: "value",
|
||||
}»;
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Test case 2: Cursor after ':'
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(1), 8)..DisplayPoint::new(DisplayRow(1), 8)
|
||||
]);
|
||||
});
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = {
|
||||
key:ˇ "value",
|
||||
};
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx);
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = {
|
||||
«ˇkey: "value"»,
|
||||
};
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx);
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = «ˇ{
|
||||
key: "value",
|
||||
}»;
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Test case 3: Cursor after ','
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(1), 17)..DisplayPoint::new(DisplayRow(1), 17)
|
||||
]);
|
||||
});
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = {
|
||||
key: "value",ˇ
|
||||
};
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx);
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = «ˇ{
|
||||
key: "value",
|
||||
}»;
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Test case 4: Cursor after ';'
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(2), 2)..DisplayPoint::new(DisplayRow(2), 2)
|
||||
]);
|
||||
});
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
let a = {
|
||||
key: "value",
|
||||
};ˇ
|
||||
"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.select_larger_syntax_node(&SelectLargerSyntaxNode, window, cx);
|
||||
});
|
||||
editor.update(cx, |editor, cx| {
|
||||
assert_text_with_selections(
|
||||
editor,
|
||||
indoc! {r#"
|
||||
«ˇlet a = {
|
||||
key: "value",
|
||||
};
|
||||
»"#},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_select_larger_smaller_syntax_node_for_string(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -25508,196 +25330,6 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let language = Arc::new(Language::new(
|
||||
LanguageConfig::default(),
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
));
|
||||
|
||||
// Test hierarchical sibling navigation
|
||||
let text = r#"
|
||||
fn outer() {
|
||||
if condition {
|
||||
let a = 1;
|
||||
}
|
||||
let b = 2;
|
||||
}
|
||||
|
||||
fn another() {
|
||||
let c = 3;
|
||||
}
|
||||
"#;
|
||||
|
||||
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
|
||||
|
||||
// Wait for parsing to complete
|
||||
editor
|
||||
.condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
|
||||
.await;
|
||||
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
// Start by selecting "let a = 1;" inside the if block
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 26)
|
||||
]);
|
||||
});
|
||||
|
||||
let initial_selection = editor.selections.display_ranges(cx);
|
||||
assert_eq!(initial_selection.len(), 1, "Should have one selection");
|
||||
|
||||
// Test select next sibling - should move up levels to find the next sibling
|
||||
// Since "let a = 1;" has no siblings in the if block, it should move up
|
||||
// to find "let b = 2;" which is a sibling of the if block
|
||||
editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx);
|
||||
let next_selection = editor.selections.display_ranges(cx);
|
||||
|
||||
// Should have a selection and it should be different from the initial
|
||||
assert_eq!(
|
||||
next_selection.len(),
|
||||
1,
|
||||
"Should have one selection after next"
|
||||
);
|
||||
assert_ne!(
|
||||
next_selection[0], initial_selection[0],
|
||||
"Next sibling selection should be different"
|
||||
);
|
||||
|
||||
// Test hierarchical navigation by going to the end of the current function
|
||||
// and trying to navigate to the next function
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(5), 12)..DisplayPoint::new(DisplayRow(5), 22)
|
||||
]);
|
||||
});
|
||||
|
||||
editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx);
|
||||
let function_next_selection = editor.selections.display_ranges(cx);
|
||||
|
||||
// Should move to the next function
|
||||
assert_eq!(
|
||||
function_next_selection.len(),
|
||||
1,
|
||||
"Should have one selection after function next"
|
||||
);
|
||||
|
||||
// Test select previous sibling navigation
|
||||
editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx);
|
||||
let prev_selection = editor.selections.display_ranges(cx);
|
||||
|
||||
// Should have a selection and it should be different
|
||||
assert_eq!(
|
||||
prev_selection.len(),
|
||||
1,
|
||||
"Should have one selection after prev"
|
||||
);
|
||||
assert_ne!(
|
||||
prev_selection[0], function_next_selection[0],
|
||||
"Previous sibling selection should be different from next"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_next_prev_document_highlight(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.set_state(
|
||||
"let ˇvariable = 42;
|
||||
let another = variable + 1;
|
||||
let result = variable * 2;",
|
||||
);
|
||||
|
||||
// Set up document highlights manually (simulating LSP response)
|
||||
cx.update_editor(|editor, _window, cx| {
|
||||
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
|
||||
// Create highlights for "variable" occurrences
|
||||
let highlight_ranges = [
|
||||
Point::new(0, 4)..Point::new(0, 12), // First "variable"
|
||||
Point::new(1, 14)..Point::new(1, 22), // Second "variable"
|
||||
Point::new(2, 13)..Point::new(2, 21), // Third "variable"
|
||||
];
|
||||
|
||||
let anchor_ranges: Vec<_> = highlight_ranges
|
||||
.iter()
|
||||
.map(|range| range.clone().to_anchors(&buffer_snapshot))
|
||||
.collect();
|
||||
|
||||
editor.highlight_background::<DocumentHighlightRead>(
|
||||
&anchor_ranges,
|
||||
|theme| theme.colors().editor_document_highlight_read_background,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Go to next highlight - should move to second "variable"
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
"let variable = 42;
|
||||
let another = ˇvariable + 1;
|
||||
let result = variable * 2;",
|
||||
);
|
||||
|
||||
// Go to next highlight - should move to third "variable"
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
"let variable = 42;
|
||||
let another = variable + 1;
|
||||
let result = ˇvariable * 2;",
|
||||
);
|
||||
|
||||
// Go to next highlight - should stay at third "variable" (no wrap-around)
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.go_to_next_document_highlight(&GoToNextDocumentHighlight, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
"let variable = 42;
|
||||
let another = variable + 1;
|
||||
let result = ˇvariable * 2;",
|
||||
);
|
||||
|
||||
// Now test going backwards from third position
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
"let variable = 42;
|
||||
let another = ˇvariable + 1;
|
||||
let result = variable * 2;",
|
||||
);
|
||||
|
||||
// Go to previous highlight - should move to first "variable"
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
"let ˇvariable = 42;
|
||||
let another = variable + 1;
|
||||
let result = variable * 2;",
|
||||
);
|
||||
|
||||
// Go to previous highlight - should stay on first "variable"
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.go_to_prev_document_highlight(&GoToPreviousDocumentHighlight, window, cx);
|
||||
});
|
||||
cx.assert_editor_state(
|
||||
"let ˇvariable = 42;
|
||||
let another = variable + 1;
|
||||
let result = variable * 2;",
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec<Rgba> {
|
||||
editor
|
||||
|
||||
@@ -365,8 +365,6 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::toggle_comments);
|
||||
register_action(editor, window, Editor::select_larger_syntax_node);
|
||||
register_action(editor, window, Editor::select_smaller_syntax_node);
|
||||
register_action(editor, window, Editor::select_next_syntax_node);
|
||||
register_action(editor, window, Editor::select_prev_syntax_node);
|
||||
register_action(editor, window, Editor::unwrap_syntax_node);
|
||||
register_action(editor, window, Editor::select_enclosing_symbol);
|
||||
register_action(editor, window, Editor::move_to_enclosing_bracket);
|
||||
@@ -381,8 +379,6 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::go_to_prev_diagnostic);
|
||||
register_action(editor, window, Editor::go_to_next_hunk);
|
||||
register_action(editor, window, Editor::go_to_prev_hunk);
|
||||
register_action(editor, window, Editor::go_to_next_document_highlight);
|
||||
register_action(editor, window, Editor::go_to_prev_document_highlight);
|
||||
register_action(editor, window, |editor, action, window, cx| {
|
||||
editor
|
||||
.go_to_definition(action, window, cx)
|
||||
@@ -8300,7 +8296,7 @@ impl Element for EditorElement {
|
||||
let (mut snapshot, is_read_only) = self.editor.update(cx, |editor, cx| {
|
||||
(editor.snapshot(window, cx), editor.read_only(cx))
|
||||
});
|
||||
let style = &self.style;
|
||||
let style = self.style.clone();
|
||||
|
||||
let rem_size = window.rem_size();
|
||||
let font_id = window.text_system().resolve_font(&style.text.font());
|
||||
@@ -8775,7 +8771,7 @@ impl Element for EditorElement {
|
||||
blame.blame_for_rows(&[row_infos], cx).next()
|
||||
})
|
||||
.flatten()?;
|
||||
let mut element = render_inline_blame_entry(blame_entry, style, cx)?;
|
||||
let mut element = render_inline_blame_entry(blame_entry, &style, cx)?;
|
||||
let inline_blame_padding = ProjectSettings::get_global(cx)
|
||||
.git
|
||||
.inline_blame
|
||||
@@ -8795,7 +8791,7 @@ impl Element for EditorElement {
|
||||
let longest_line_width = layout_line(
|
||||
snapshot.longest_row(),
|
||||
&snapshot,
|
||||
style,
|
||||
&style,
|
||||
editor_width,
|
||||
is_row_soft_wrapped,
|
||||
window,
|
||||
@@ -8953,7 +8949,7 @@ impl Element for EditorElement {
|
||||
scroll_pixel_position,
|
||||
newest_selection_head,
|
||||
editor_width,
|
||||
style,
|
||||
&style,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -8971,7 +8967,7 @@ impl Element for EditorElement {
|
||||
end_row,
|
||||
line_height,
|
||||
em_width,
|
||||
style,
|
||||
&style,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -9116,7 +9112,7 @@ impl Element for EditorElement {
|
||||
&line_layouts,
|
||||
newest_selection_head,
|
||||
newest_selection_point,
|
||||
style,
|
||||
&style,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ use multi_buffer::ToPoint;
|
||||
use pretty_assertions::assert_eq;
|
||||
use project::{Project, project_settings::DiagnosticSeverity};
|
||||
use ui::{App, BorrowAppContext, px};
|
||||
use util::test::{generate_marked_text, marked_text_offsets, marked_text_ranges};
|
||||
use util::test::{marked_text_offsets, marked_text_ranges};
|
||||
|
||||
#[cfg(test)]
|
||||
#[ctor::ctor]
|
||||
@@ -104,14 +104,13 @@ pub fn assert_text_with_selections(
|
||||
marked_text: &str,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
let (unmarked_text, _text_ranges) = marked_text_ranges(marked_text, true);
|
||||
let (unmarked_text, text_ranges) = marked_text_ranges(marked_text, true);
|
||||
assert_eq!(editor.text(cx), unmarked_text, "text doesn't match");
|
||||
let actual = generate_marked_text(
|
||||
&editor.text(cx),
|
||||
&editor.selections.ranges(cx),
|
||||
marked_text.contains("«"),
|
||||
assert_eq!(
|
||||
editor.selections.ranges(cx),
|
||||
text_ranges,
|
||||
"selections don't match",
|
||||
);
|
||||
assert_eq!(actual, marked_text, "Selections don't match");
|
||||
}
|
||||
|
||||
// RA thinks this is dead code even though it is used in a whole lot of tests
|
||||
|
||||
@@ -66,10 +66,9 @@ impl FeatureFlag for LlmClosedBetaFeatureFlag {
|
||||
const NAME: &'static str = "llm-closed-beta";
|
||||
}
|
||||
|
||||
pub struct BillingV2FeatureFlag {}
|
||||
|
||||
impl FeatureFlag for BillingV2FeatureFlag {
|
||||
const NAME: &'static str = "billing-v2";
|
||||
pub struct ZedProFeatureFlag {}
|
||||
impl FeatureFlag for ZedProFeatureFlag {
|
||||
const NAME: &'static str = "zed-pro";
|
||||
}
|
||||
|
||||
pub struct NotebookFeatureFlag;
|
||||
|
||||
@@ -392,11 +392,10 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
let should_prepend_with_current_dir = this
|
||||
.read_with(cx, |picker, _| {
|
||||
!input_is_empty
|
||||
&& match &picker.delegate.directory_state {
|
||||
DirectoryState::List { error, .. } => error.is_none(),
|
||||
DirectoryState::Create { .. } => false,
|
||||
DirectoryState::None { .. } => false,
|
||||
}
|
||||
&& !matches!(
|
||||
picker.delegate.directory_state,
|
||||
DirectoryState::Create { .. }
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if should_prepend_with_current_dir {
|
||||
|
||||
@@ -39,9 +39,6 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
|
||||
|
||||
let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
|
||||
|
||||
insert_query(path!("sadjaoislkdjasldj"), &picker, cx).await;
|
||||
assert_eq!(collect_match_candidates(&picker, cx), Vec::<String>::new());
|
||||
|
||||
let query = path!("/root");
|
||||
insert_query(query, &picker, cx).await;
|
||||
assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
|
||||
|
||||
@@ -150,7 +150,6 @@ pub struct CommitSummary {
|
||||
pub subject: SharedString,
|
||||
/// This is a unix timestamp
|
||||
pub commit_timestamp: i64,
|
||||
pub author_name: SharedString,
|
||||
pub has_parent: bool,
|
||||
}
|
||||
|
||||
@@ -988,7 +987,6 @@ impl GitRepository for RealGitRepository {
|
||||
"%(upstream)",
|
||||
"%(upstream:track)",
|
||||
"%(committerdate:unix)",
|
||||
"%(authorname)",
|
||||
"%(contents:subject)",
|
||||
]
|
||||
.join("%00");
|
||||
@@ -1207,9 +1205,10 @@ impl GitRepository for RealGitRepository {
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command("git");
|
||||
let mut cmd = new_smol_command(&git_binary_path);
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["stash", "push", "--quiet"])
|
||||
@@ -1231,9 +1230,10 @@ impl GitRepository for RealGitRepository {
|
||||
|
||||
fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command("git");
|
||||
let mut cmd = new_smol_command(&git_binary_path);
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["stash", "pop"]);
|
||||
@@ -1258,9 +1258,10 @@ impl GitRepository for RealGitRepository {
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command("git");
|
||||
let mut cmd = new_smol_command(&git_binary_path);
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["commit", "--quiet", "-m"])
|
||||
@@ -1304,7 +1305,7 @@ impl GitRepository for RealGitRepository {
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
let working_directory = working_directory?;
|
||||
let mut command = new_smol_command("git");
|
||||
let mut command = new_smol_command(&self.git_binary_path);
|
||||
command
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory)
|
||||
@@ -1335,7 +1336,7 @@ impl GitRepository for RealGitRepository {
|
||||
let working_directory = self.working_directory();
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
let mut command = new_smol_command("git");
|
||||
let mut command = new_smol_command(&self.git_binary_path);
|
||||
command
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory?)
|
||||
@@ -1361,7 +1362,7 @@ impl GitRepository for RealGitRepository {
|
||||
let remote_name = format!("{}", fetch_options);
|
||||
let executor = cx.background_executor().clone();
|
||||
async move {
|
||||
let mut command = new_smol_command("git");
|
||||
let mut command = new_smol_command(&self.git_binary_path);
|
||||
command
|
||||
.envs(env.iter())
|
||||
.current_dir(&working_directory?)
|
||||
@@ -1816,7 +1817,6 @@ impl GitBinary {
|
||||
output.status.success(),
|
||||
GitBinaryCommandError {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
status: output.status,
|
||||
}
|
||||
);
|
||||
@@ -1839,10 +1839,9 @@ impl GitBinary {
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[error("Git command failed:\n{stdout}{stderr}\n")]
|
||||
#[error("Git command failed: {stdout}")]
|
||||
struct GitBinaryCommandError {
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
status: ExitStatus,
|
||||
}
|
||||
|
||||
@@ -2024,7 +2023,6 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
|
||||
let upstream_name = fields.next().context("no upstream")?.to_string();
|
||||
let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
|
||||
let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
|
||||
let author_name = fields.next().context("no authorname")?.to_string().into();
|
||||
let subject: SharedString = fields
|
||||
.next()
|
||||
.context("no contents:subject")?
|
||||
@@ -2038,7 +2036,6 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
|
||||
sha: head_sha,
|
||||
subject,
|
||||
commit_timestamp: commiterdate,
|
||||
author_name: author_name,
|
||||
has_parent: !parent_sha.is_empty(),
|
||||
}),
|
||||
upstream: if upstream_name.is_empty() {
|
||||
@@ -2349,7 +2346,7 @@ mod tests {
|
||||
fn test_branches_parsing() {
|
||||
// suppress "help: octal escapes are not supported, `\0` is always null"
|
||||
#[allow(clippy::octal_escapes)]
|
||||
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
|
||||
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
|
||||
assert_eq!(
|
||||
parse_branch_input(input).unwrap(),
|
||||
vec![Branch {
|
||||
@@ -2366,7 +2363,6 @@ mod tests {
|
||||
sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
|
||||
subject: "generated protobuf".into(),
|
||||
commit_timestamp: 1733187470,
|
||||
author_name: SharedString::new("John Doe"),
|
||||
has_parent: false,
|
||||
})
|
||||
}]
|
||||
|
||||
@@ -90,11 +90,6 @@ impl BlameRenderer for GitBlameRenderer {
|
||||
sha: blame_entry.sha.to_string().into(),
|
||||
subject: blame_entry.summary.clone().unwrap_or_default().into(),
|
||||
commit_timestamp: blame_entry.committer_time.unwrap_or_default(),
|
||||
author_name: blame_entry
|
||||
.committer_name
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
has_parent: true,
|
||||
},
|
||||
repository.downgrade(),
|
||||
@@ -234,7 +229,6 @@ impl BlameRenderer for GitBlameRenderer {
|
||||
.into()
|
||||
}),
|
||||
commit_timestamp: commit_details.commit_time.unix_timestamp(),
|
||||
author_name: commit_details.author_name.clone(),
|
||||
has_parent: false,
|
||||
};
|
||||
|
||||
@@ -380,7 +374,6 @@ impl BlameRenderer for GitBlameRenderer {
|
||||
sha: blame_entry.sha.to_string().into(),
|
||||
subject: blame_entry.summary.clone().unwrap_or_default().into(),
|
||||
commit_timestamp: blame_entry.committer_time.unwrap_or_default(),
|
||||
author_name: blame_entry.committer_name.unwrap_or_default().into(),
|
||||
has_parent: true,
|
||||
},
|
||||
repository.downgrade(),
|
||||
|
||||
@@ -10,8 +10,6 @@ use gpui::{
|
||||
};
|
||||
use picker::{Picker, PickerDelegate, PickerEditorPosition};
|
||||
use project::git_store::Repository;
|
||||
use project::project_settings::ProjectSettings;
|
||||
use settings::Settings;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use time_format::format_local_timestamp;
|
||||
@@ -124,13 +122,10 @@ impl BranchList {
|
||||
all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name));
|
||||
|
||||
all_branches.sort_by_key(|branch| {
|
||||
(
|
||||
!branch.is_head, // Current branch (is_head=true) comes first
|
||||
branch
|
||||
.most_recent_commit
|
||||
.as_ref()
|
||||
.map(|commit| 0 - commit.commit_timestamp),
|
||||
)
|
||||
branch
|
||||
.most_recent_commit
|
||||
.as_ref()
|
||||
.map(|commit| 0 - commit.commit_timestamp)
|
||||
});
|
||||
|
||||
all_branches
|
||||
@@ -456,7 +451,7 @@ impl PickerDelegate for BranchListDelegate {
|
||||
) -> Option<Self::ListItem> {
|
||||
let entry = &self.matches[ix];
|
||||
|
||||
let (commit_time, author_name, subject) = entry
|
||||
let (commit_time, subject) = entry
|
||||
.branch
|
||||
.most_recent_commit
|
||||
.as_ref()
|
||||
@@ -469,10 +464,9 @@ impl PickerDelegate for BranchListDelegate {
|
||||
OffsetDateTime::now_utc(),
|
||||
time_format::TimestampFormat::Relative,
|
||||
);
|
||||
let author = commit.author_name.clone();
|
||||
(Some(formatted_time), Some(author), Some(subject))
|
||||
(Some(formatted_time), Some(subject))
|
||||
})
|
||||
.unwrap_or_else(|| (None, None, None));
|
||||
.unwrap_or_else(|| (None, None));
|
||||
|
||||
let icon = if let Some(default_branch) = self.default_branch.clone()
|
||||
&& entry.is_new
|
||||
@@ -553,19 +547,7 @@ impl PickerDelegate for BranchListDelegate {
|
||||
"based off the current branch".to_string()
|
||||
}
|
||||
} else {
|
||||
let show_author_name = ProjectSettings::get_global(cx)
|
||||
.git
|
||||
.branch_picker
|
||||
.unwrap_or_default()
|
||||
.show_author_name;
|
||||
|
||||
subject.map_or("no commits found".into(), |subject| {
|
||||
if show_author_name && author_name.is_some() {
|
||||
format!("{} • {}", author_name.unwrap(), subject)
|
||||
} else {
|
||||
subject.to_string()
|
||||
}
|
||||
})
|
||||
subject.unwrap_or("no commits found".into()).to_string()
|
||||
};
|
||||
Label::new(message)
|
||||
.size(LabelSize::Small)
|
||||
|
||||
@@ -229,7 +229,6 @@ impl Render for CommitTooltip {
|
||||
.into()
|
||||
}),
|
||||
commit_timestamp: self.commit.commit_time.unix_timestamp(),
|
||||
author_name: self.commit.author_name.clone(),
|
||||
has_parent: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ use gpui::{
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, File};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
|
||||
ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
|
||||
LanguageModelRequestMessage, Role,
|
||||
};
|
||||
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
|
||||
use multi_buffer::ExcerptInfo;
|
||||
@@ -90,8 +91,6 @@ actions!(
|
||||
FocusChanges,
|
||||
/// Toggles automatic co-author suggestions.
|
||||
ToggleFillCoAuthors,
|
||||
/// Toggles sorting entries by path vs status.
|
||||
ToggleSortByPath,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -120,7 +119,6 @@ struct GitMenuState {
|
||||
has_staged_changes: bool,
|
||||
has_unstaged_changes: bool,
|
||||
has_new_changes: bool,
|
||||
sort_by_path: bool,
|
||||
}
|
||||
|
||||
fn git_panel_context_menu(
|
||||
@@ -162,16 +160,6 @@ fn git_panel_context_menu(
|
||||
"Trash Untracked Files",
|
||||
TrashUntrackedFiles.boxed_clone(),
|
||||
)
|
||||
.separator()
|
||||
.entry(
|
||||
if state.sort_by_path {
|
||||
"Sort by Status"
|
||||
} else {
|
||||
"Sort by Path"
|
||||
},
|
||||
Some(Box::new(ToggleSortByPath)),
|
||||
move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -363,7 +351,6 @@ pub struct GitPanel {
|
||||
pending: Vec<PendingOperation>,
|
||||
pending_commit: Option<Task<()>>,
|
||||
amend_pending: bool,
|
||||
original_commit_message: Option<String>,
|
||||
signoff_enabled: bool,
|
||||
pending_serialization: Task<()>,
|
||||
pub(crate) project: Entity<Project>,
|
||||
@@ -545,7 +532,6 @@ impl GitPanel {
|
||||
pending: Vec::new(),
|
||||
pending_commit: None,
|
||||
amend_pending: false,
|
||||
original_commit_message: None,
|
||||
signoff_enabled: false,
|
||||
pending_serialization: Task::ready(()),
|
||||
single_staged_entry: None,
|
||||
@@ -1727,7 +1713,6 @@ impl GitPanel {
|
||||
Ok(()) => {
|
||||
this.commit_editor
|
||||
.update(cx, |editor, cx| editor.clear(window, cx));
|
||||
this.original_commit_message = None;
|
||||
}
|
||||
Err(e) => this.show_error_toast("commit", e, cx),
|
||||
}
|
||||
@@ -1738,7 +1723,7 @@ impl GitPanel {
|
||||
self.pending_commit = Some(task);
|
||||
}
|
||||
|
||||
pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo) = self.active_repository.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -1872,17 +1857,13 @@ impl GitPanel {
|
||||
|
||||
/// Generates a commit message using an LLM.
|
||||
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.can_commit()
|
||||
|| DisableAiSettings::get_global(cx).disable_ai
|
||||
|| !agent_settings::AgentSettings::get_global(cx).enabled
|
||||
{
|
||||
if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ConfiguredModel { provider, model }) =
|
||||
LanguageModelRegistry::read_global(cx).commit_message_model()
|
||||
else {
|
||||
return;
|
||||
let model = match current_language_model(cx) {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let Some(repo) = self.active_repository.as_ref() else {
|
||||
@@ -1907,16 +1888,6 @@ impl GitPanel {
|
||||
this.generate_commit_message_task.take();
|
||||
});
|
||||
|
||||
if let Some(task) = cx.update(|cx| {
|
||||
if !provider.is_authenticated(cx) {
|
||||
Some(provider.authenticate(cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})? {
|
||||
task.await.log_err();
|
||||
};
|
||||
|
||||
let mut diff_text = match diff.await {
|
||||
Ok(result) => match result {
|
||||
Ok(text) => text,
|
||||
@@ -2560,24 +2531,6 @@ impl GitPanel {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn toggle_sort_by_path(
|
||||
&mut self,
|
||||
_: &ToggleSortByPath,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let current_setting = GitPanelSettings::get_global(cx).sort_by_path;
|
||||
if let Some(workspace) = self.workspace.upgrade() {
|
||||
let workspace = workspace.read(cx);
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
cx.update_global::<SettingsStore, _>(|store, _cx| {
|
||||
store.update_settings_file::<GitPanelSettings>(fs, move |settings, _cx| {
|
||||
settings.sort_by_path = Some(!current_setting);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
|
||||
const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
|
||||
|
||||
@@ -3112,7 +3065,6 @@ impl GitPanel {
|
||||
has_staged_changes,
|
||||
has_unstaged_changes,
|
||||
has_new_changes,
|
||||
sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
@@ -3125,18 +3077,9 @@ impl GitPanel {
|
||||
&self,
|
||||
cx: &Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
if !agent_settings::AgentSettings::get_global(cx).enabled
|
||||
|| DisableAiSettings::get_global(cx).disable_ai
|
||||
|| LanguageModelRegistry::read_global(cx)
|
||||
.commit_message_model()
|
||||
.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.generate_commit_message_task.is_some() {
|
||||
return Some(
|
||||
h_flex()
|
||||
current_language_model(cx).is_some().then(|| {
|
||||
if self.generate_commit_message_task.is_some() {
|
||||
return h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
@@ -3149,13 +3092,11 @@ impl GitPanel {
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let can_commit = self.can_commit();
|
||||
let editor_focus_handle = self.commit_editor.focus_handle(cx);
|
||||
Some(
|
||||
let can_commit = self.can_commit();
|
||||
let editor_focus_handle = self.commit_editor.focus_handle(cx);
|
||||
IconButton::new("generate-commit-message", IconName::AiEdit)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_color(Color::Muted)
|
||||
@@ -3176,8 +3117,8 @@ impl GitPanel {
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.generate_commit_message(cx);
|
||||
}))
|
||||
.into_any_element(),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
@@ -4165,7 +4106,6 @@ impl GitPanel {
|
||||
has_staged_changes: self.has_staged_changes(),
|
||||
has_unstaged_changes: self.has_unstaged_changes(),
|
||||
has_new_changes: self.new_count > 0,
|
||||
sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
@@ -4405,22 +4345,6 @@ impl GitPanel {
|
||||
}
|
||||
|
||||
pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
|
||||
if value && !self.amend_pending {
|
||||
let current_message = self.commit_message_buffer(cx).read(cx).text();
|
||||
self.original_commit_message = if current_message.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_message)
|
||||
};
|
||||
} else if !value && self.amend_pending {
|
||||
let message = self.original_commit_message.take().unwrap_or_default();
|
||||
self.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
||||
let start = buffer.anchor_before(0);
|
||||
let end = buffer.anchor_after(buffer.len());
|
||||
buffer.edit([(start..end, message)], None, cx);
|
||||
});
|
||||
}
|
||||
|
||||
self.amend_pending = value;
|
||||
self.serialize(cx);
|
||||
cx.notify();
|
||||
@@ -4526,6 +4450,20 @@ impl GitPanel {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
|
||||
let is_enabled = agent_settings::AgentSettings::get_global(cx).enabled
|
||||
&& !DisableAiSettings::get_global(cx).disable_ai;
|
||||
|
||||
is_enabled
|
||||
.then(|| {
|
||||
let ConfiguredModel { provider, model } =
|
||||
LanguageModelRegistry::read_global(cx).commit_message_model()?;
|
||||
|
||||
provider.is_authenticated(cx).then(|| model)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
impl Render for GitPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let project = self.project.read(cx);
|
||||
@@ -4579,7 +4517,6 @@ impl Render for GitPanel {
|
||||
.when(has_write_access && has_co_authors, |git_panel| {
|
||||
git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
|
||||
})
|
||||
.on_action(cx.listener(Self::toggle_sort_by_path))
|
||||
.on_hover(cx.listener(move |this, hovered, window, cx| {
|
||||
if *hovered {
|
||||
this.horizontal_scrollbar.show(cx);
|
||||
@@ -5017,7 +4954,6 @@ impl Component for PanelRepoFooter {
|
||||
sha: "abc123".into(),
|
||||
subject: "Modify stuff".into(),
|
||||
commit_timestamp: 1710932954,
|
||||
author_name: "John Doe".into(),
|
||||
has_parent: true,
|
||||
}),
|
||||
}
|
||||
@@ -5035,7 +4971,6 @@ impl Component for PanelRepoFooter {
|
||||
sha: "abc123".into(),
|
||||
subject: "Modify stuff".into(),
|
||||
commit_timestamp: 1710932954,
|
||||
author_name: "John Doe".into(),
|
||||
has_parent: true,
|
||||
}),
|
||||
}
|
||||
@@ -5555,73 +5490,4 @@ mod tests {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"project": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"main.rs": "fn main() {}"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.set_status_for_repo(
|
||||
Path::new(path!("/root/project/.git")),
|
||||
&[(Path::new("src/main.rs"), StatusCode::Modified.worktree())],
|
||||
);
|
||||
|
||||
let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
|
||||
let workspace =
|
||||
cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
|
||||
let panel = workspace.update(cx, GitPanel::new).unwrap();
|
||||
|
||||
// Test: User has commit message, enables amend (saves message), then disables (restores message)
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
||||
let start = buffer.anchor_before(0);
|
||||
let end = buffer.anchor_after(buffer.len());
|
||||
buffer.edit([(start..end, "Initial commit message")], None, cx);
|
||||
});
|
||||
|
||||
panel.set_amend_pending(true, cx);
|
||||
assert!(panel.original_commit_message.is_some());
|
||||
|
||||
panel.set_amend_pending(false, cx);
|
||||
let current_message = panel.commit_message_buffer(cx).read(cx).text();
|
||||
assert_eq!(current_message, "Initial commit message");
|
||||
assert!(panel.original_commit_message.is_none());
|
||||
});
|
||||
|
||||
// Test: User has empty commit message, enables amend, then disables (clears message)
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
||||
let start = buffer.anchor_before(0);
|
||||
let end = buffer.anchor_after(buffer.len());
|
||||
buffer.edit([(start..end, "")], None, cx);
|
||||
});
|
||||
|
||||
panel.set_amend_pending(true, cx);
|
||||
assert!(panel.original_commit_message.is_none());
|
||||
|
||||
panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
|
||||
let start = buffer.anchor_before(0);
|
||||
let end = buffer.anchor_after(buffer.len());
|
||||
buffer.edit([(start..end, "Previous commit message")], None, cx);
|
||||
});
|
||||
|
||||
panel.set_amend_pending(false, cx);
|
||||
let current_message = panel.commit_message_buffer(cx).read(cx).text();
|
||||
assert_eq!(current_message, "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +149,6 @@ pub fn init(cx: &mut App) {
|
||||
panel.unstage_all(action, window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.uncommit(window, cx);
|
||||
})
|
||||
});
|
||||
CommandPaletteFilter::update_global(cx, |filter, _cx| {
|
||||
filter.hide_action_types(&[
|
||||
zed_actions::OpenGitIntegrationOnboarding.type_id(),
|
||||
|
||||
@@ -1220,7 +1220,6 @@ mod preview {
|
||||
sha: "abc123".into(),
|
||||
subject: "Modify stuff".into(),
|
||||
commit_timestamp: 1710932954,
|
||||
author_name: "John Doe".into(),
|
||||
has_parent: true,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ impl StatusItemView for CursorPosition {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug, JsonSchema, Deserialize, Serialize)]
|
||||
#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum LineIndicatorFormat {
|
||||
Short,
|
||||
@@ -301,13 +301,10 @@ pub(crate) enum LineIndicatorFormat {
|
||||
Long,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Default, Debug, JsonSchema, Deserialize, Serialize, SettingsUi, SettingsKey,
|
||||
)]
|
||||
#[settings_key(None)]
|
||||
pub(crate) struct LineIndicatorFormatContent {
|
||||
line_indicator_format: Option<LineIndicatorFormat>,
|
||||
}
|
||||
#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize, SettingsUi, SettingsKey)]
|
||||
#[serde(transparent)]
|
||||
#[settings_key(key = "line_indicator_format")]
|
||||
pub(crate) struct LineIndicatorFormatContent(LineIndicatorFormat);
|
||||
|
||||
impl Settings for LineIndicatorFormat {
|
||||
type FileContent = LineIndicatorFormatContent;
|
||||
@@ -315,18 +312,14 @@ impl Settings for LineIndicatorFormat {
|
||||
fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
|
||||
let format = [
|
||||
sources.release_channel,
|
||||
sources.profile,
|
||||
sources.user,
|
||||
sources.operating_system,
|
||||
Some(sources.default),
|
||||
sources.user,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|val| val.line_indicator_format)
|
||||
.next()
|
||||
.ok_or_else(Self::missing_default)?;
|
||||
.find_map(|value| value.copied())
|
||||
.unwrap_or(*sources.default);
|
||||
|
||||
Ok(format)
|
||||
Ok(format.0)
|
||||
}
|
||||
|
||||
fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
|
||||
|
||||
@@ -82,10 +82,6 @@ unsafe fn build_classes() {
|
||||
APP_DELEGATE_CLASS = unsafe {
|
||||
let mut decl = ClassDecl::new("GPUIApplicationDelegate", class!(NSResponder)).unwrap();
|
||||
decl.add_ivar::<*mut c_void>(MAC_PLATFORM_IVAR);
|
||||
decl.add_method(
|
||||
sel!(applicationWillFinishLaunching:),
|
||||
will_finish_launching as extern "C" fn(&mut Object, Sel, id),
|
||||
);
|
||||
decl.add_method(
|
||||
sel!(applicationDidFinishLaunching:),
|
||||
did_finish_launching as extern "C" fn(&mut Object, Sel, id),
|
||||
@@ -1360,25 +1356,6 @@ unsafe fn get_mac_platform(object: &mut Object) -> &MacPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn will_finish_launching(_this: &mut Object, _: Sel, _: id) {
|
||||
unsafe {
|
||||
let user_defaults: id = msg_send![class!(NSUserDefaults), standardUserDefaults];
|
||||
let defaults_dict: id = msg_send![class!(NSMutableDictionary), dictionary];
|
||||
|
||||
// The autofill heuristic controller causes slowdown and high CPU usage.
|
||||
// We don't know exactly why. This disables the full heuristic controller.
|
||||
//
|
||||
// Adapted from: https://github.com/ghostty-org/ghostty/pull/8625
|
||||
let false_value: id = msg_send![class!(NSNumber), numberWithBool:false];
|
||||
let _: () = msg_send![defaults_dict,
|
||||
setObject: false_value
|
||||
forKey: ns_string("NSAutoFillHeuristicControllerEnabled")
|
||||
];
|
||||
|
||||
let _: () = msg_send![user_defaults, registerDefaults:defaults_dict];
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) {
|
||||
unsafe {
|
||||
let app: id = msg_send![APP_CLASS, sharedApplication];
|
||||
|
||||
@@ -16,7 +16,7 @@ use core_foundation::{
|
||||
use core_graphics::{
|
||||
base::{CGGlyph, kCGImageAlphaPremultipliedLast},
|
||||
color_space::CGColorSpace,
|
||||
context::{CGContext, CGTextDrawingMode},
|
||||
context::CGContext,
|
||||
display::CGPoint,
|
||||
};
|
||||
use core_text::{
|
||||
@@ -396,12 +396,6 @@ impl MacTextSystemState {
|
||||
let subpixel_shift = params
|
||||
.subpixel_variant
|
||||
.map(|v| v as f32 / SUBPIXEL_VARIANTS as f32);
|
||||
cx.set_allows_font_smoothing(true);
|
||||
cx.set_should_smooth_fonts(true);
|
||||
cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFill);
|
||||
cx.set_gray_fill_color(0.0, 1.0);
|
||||
cx.set_allows_antialiasing(true);
|
||||
cx.set_should_antialias(true);
|
||||
cx.set_allows_font_subpixel_positioning(true);
|
||||
cx.set_should_subpixel_position_fonts(true);
|
||||
cx.set_allows_font_subpixel_quantization(false);
|
||||
|
||||
@@ -2578,7 +2578,7 @@ impl Window {
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
cx: &mut App,
|
||||
init: impl FnOnce(&mut Self, &mut Context<S>) -> S,
|
||||
init: impl FnOnce(&mut Self, &mut App) -> S,
|
||||
) -> Entity<S> {
|
||||
let current_view = self.current_view();
|
||||
self.with_global_id(key.into(), |global_id, window| {
|
||||
@@ -2611,7 +2611,7 @@ impl Window {
|
||||
pub fn use_state<S: 'static>(
|
||||
&mut self,
|
||||
cx: &mut App,
|
||||
init: impl FnOnce(&mut Self, &mut Context<S>) -> S,
|
||||
init: impl FnOnce(&mut Self, &mut App) -> S,
|
||||
) -> Entity<S> {
|
||||
self.use_keyed_state(
|
||||
ElementId::CodeLocation(*core::panic::Location::caller()),
|
||||
|
||||
@@ -318,12 +318,6 @@ pub fn read_proxy_from_env() -> Option<Url> {
|
||||
.and_then(|env| env.parse().ok())
|
||||
}
|
||||
|
||||
pub fn read_no_proxy_from_env() -> Option<String> {
|
||||
const ENV_VARS: &[&str] = &["NO_PROXY", "no_proxy"];
|
||||
|
||||
ENV_VARS.iter().find_map(|var| std::env::var(var).ok())
|
||||
}
|
||||
|
||||
pub struct BlockedHttpClient;
|
||||
|
||||
impl BlockedHttpClient {
|
||||
|
||||
@@ -1,7 +1,112 @@
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod install_cli_binary;
|
||||
mod register_zed_scheme;
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::ZED_URL_SCHEME;
|
||||
use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions};
|
||||
use release_channel::ReleaseChannel;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use util::ResultExt;
|
||||
use workspace::notifications::{DetachAndPromptErr, NotificationId};
|
||||
use workspace::{Toast, Workspace};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub use install_cli_binary::{InstallCliBinary, install_cli_binary};
|
||||
pub use register_zed_scheme::{RegisterZedScheme, register_zed_scheme};
|
||||
actions!(
|
||||
cli,
|
||||
[
|
||||
/// Installs the Zed CLI tool to the system PATH.
|
||||
Install,
|
||||
/// Registers the zed:// URL scheme handler.
|
||||
RegisterZedScheme
|
||||
]
|
||||
);
|
||||
|
||||
async fn install_script(cx: &AsyncApp) -> Result<PathBuf> {
|
||||
let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))??;
|
||||
let link_path = Path::new("/usr/local/bin/zed");
|
||||
let bin_dir_path = link_path.parent().unwrap();
|
||||
|
||||
// Don't re-create symlink if it points to the same CLI binary.
|
||||
if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) {
|
||||
return Ok(link_path.into());
|
||||
}
|
||||
|
||||
// If the symlink is not there or is outdated, first try replacing it
|
||||
// without escalating.
|
||||
smol::fs::remove_file(link_path).await.log_err();
|
||||
// todo("windows")
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if smol::fs::unix::symlink(&cli_path, link_path)
|
||||
.await
|
||||
.log_err()
|
||||
.is_some()
|
||||
{
|
||||
return Ok(link_path.into());
|
||||
}
|
||||
}
|
||||
|
||||
// The symlink could not be created, so use osascript with admin privileges
|
||||
// to create it.
|
||||
let status = smol::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
&format!(
|
||||
"do shell script \" \
|
||||
mkdir -p \'{}\' && \
|
||||
ln -sf \'{}\' \'{}\' \
|
||||
\" with administrator privileges",
|
||||
bin_dir_path.to_string_lossy(),
|
||||
cli_path.to_string_lossy(),
|
||||
link_path.to_string_lossy(),
|
||||
),
|
||||
])
|
||||
.stdout(smol::process::Stdio::inherit())
|
||||
.stderr(smol::process::Stdio::inherit())
|
||||
.output()
|
||||
.await?
|
||||
.status;
|
||||
anyhow::ensure!(status.success(), "error running osascript");
|
||||
Ok(link_path.into())
|
||||
}
|
||||
|
||||
pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> {
|
||||
cx.update(|cx| cx.register_url_scheme(ZED_URL_SCHEME))?
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn install_cli(window: &mut Window, cx: &mut Context<Workspace>) {
|
||||
const LINUX_PROMPT_DETAIL: &str = "If you installed Zed from our official release add ~/.local/bin to your PATH.\n\nIf you installed Zed from a different source like your package manager, then you may need to create an alias/symlink manually.\n\nDepending on your package manager, the CLI might be named zeditor, zedit, zed-editor or something else.";
|
||||
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
let prompt = cx.prompt(
|
||||
PromptLevel::Warning,
|
||||
"CLI should already be installed",
|
||||
Some(LINUX_PROMPT_DETAIL),
|
||||
&["Ok"],
|
||||
);
|
||||
cx.background_spawn(prompt).detach();
|
||||
return Ok(());
|
||||
}
|
||||
let path = install_script(cx.deref())
|
||||
.await
|
||||
.context("error creating CLI symlink")?;
|
||||
|
||||
workspace.update_in(cx, |workspace, _, cx| {
|
||||
struct InstalledZedCli;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<InstalledZedCli>(),
|
||||
format!(
|
||||
"Installed `zed` to {}. You can launch {} from your terminal.",
|
||||
path.to_string_lossy(),
|
||||
ReleaseChannel::global(cx).display_name()
|
||||
),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
register_zed_scheme(cx).await.log_err();
|
||||
Ok(())
|
||||
})
|
||||
.detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None);
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
use super::register_zed_scheme;
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AppContext as _, AsyncApp, Context, PromptLevel, Window, actions};
|
||||
use release_channel::ReleaseChannel;
|
||||
use std::ops::Deref;
|
||||
use std::path::{Path, PathBuf};
|
||||
use util::ResultExt;
|
||||
use workspace::notifications::{DetachAndPromptErr, NotificationId};
|
||||
use workspace::{Toast, Workspace};
|
||||
|
||||
actions!(
|
||||
cli,
|
||||
[
|
||||
/// Installs the Zed CLI tool to the system PATH.
|
||||
InstallCliBinary,
|
||||
]
|
||||
);
|
||||
|
||||
async fn install_script(cx: &AsyncApp) -> Result<PathBuf> {
|
||||
let cli_path = cx.update(|cx| cx.path_for_auxiliary_executable("cli"))??;
|
||||
let link_path = Path::new("/usr/local/bin/zed");
|
||||
let bin_dir_path = link_path.parent().unwrap();
|
||||
|
||||
// Don't re-create symlink if it points to the same CLI binary.
|
||||
if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) {
|
||||
return Ok(link_path.into());
|
||||
}
|
||||
|
||||
// If the symlink is not there or is outdated, first try replacing it
|
||||
// without escalating.
|
||||
smol::fs::remove_file(link_path).await.log_err();
|
||||
if smol::fs::unix::symlink(&cli_path, link_path)
|
||||
.await
|
||||
.log_err()
|
||||
.is_some()
|
||||
{
|
||||
return Ok(link_path.into());
|
||||
}
|
||||
|
||||
// The symlink could not be created, so use osascript with admin privileges
|
||||
// to create it.
|
||||
let status = smol::process::Command::new("/usr/bin/osascript")
|
||||
.args([
|
||||
"-e",
|
||||
&format!(
|
||||
"do shell script \" \
|
||||
mkdir -p \'{}\' && \
|
||||
ln -sf \'{}\' \'{}\' \
|
||||
\" with administrator privileges",
|
||||
bin_dir_path.to_string_lossy(),
|
||||
cli_path.to_string_lossy(),
|
||||
link_path.to_string_lossy(),
|
||||
),
|
||||
])
|
||||
.stdout(smol::process::Stdio::inherit())
|
||||
.stderr(smol::process::Stdio::inherit())
|
||||
.output()
|
||||
.await?
|
||||
.status;
|
||||
anyhow::ensure!(status.success(), "error running osascript");
|
||||
Ok(link_path.into())
|
||||
}
|
||||
|
||||
pub fn install_cli_binary(window: &mut Window, cx: &mut Context<Workspace>) {
|
||||
const LINUX_PROMPT_DETAIL: &str = "If you installed Zed from our official release add ~/.local/bin to your PATH.\n\nIf you installed Zed from a different source like your package manager, then you may need to create an alias/symlink manually.\n\nDepending on your package manager, the CLI might be named zeditor, zedit, zed-editor or something else.";
|
||||
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
let prompt = cx.prompt(
|
||||
PromptLevel::Warning,
|
||||
"CLI should already be installed",
|
||||
Some(LINUX_PROMPT_DETAIL),
|
||||
&["Ok"],
|
||||
);
|
||||
cx.background_spawn(prompt).detach();
|
||||
return Ok(());
|
||||
}
|
||||
let path = install_script(cx.deref())
|
||||
.await
|
||||
.context("error creating CLI symlink")?;
|
||||
|
||||
workspace.update_in(cx, |workspace, _, cx| {
|
||||
struct InstalledZedCli;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<InstalledZedCli>(),
|
||||
format!(
|
||||
"Installed `zed` to {}. You can launch {} from your terminal.",
|
||||
path.to_string_lossy(),
|
||||
ReleaseChannel::global(cx).display_name()
|
||||
),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
register_zed_scheme(cx).await.log_err();
|
||||
Ok(())
|
||||
})
|
||||
.detach_and_prompt_err("Error installing zed cli", window, cx, |_, _, _| None);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use client::ZED_URL_SCHEME;
|
||||
use gpui::{AsyncApp, actions};
|
||||
|
||||
actions!(
|
||||
cli,
|
||||
[
|
||||
/// Registers the zed:// URL scheme handler.
|
||||
RegisterZedScheme
|
||||
]
|
||||
);
|
||||
|
||||
pub async fn register_zed_scheme(cx: &AsyncApp) -> anyhow::Result<()> {
|
||||
cx.update(|cx| cx.register_url_scheme(ZED_URL_SCHEME))?
|
||||
.await
|
||||
}
|
||||
@@ -1576,6 +1576,33 @@ impl Render for KeymapEditor {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
right_click_menu("open-keymap-menu")
|
||||
.menu(|window, cx| {
|
||||
ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.header("Open Keymap JSON")
|
||||
.action("User", zed_actions::OpenKeymap.boxed_clone())
|
||||
.action("Zed Default", zed_actions::OpenDefaultKeymap.boxed_clone())
|
||||
.action("Vim Default", vim::OpenDefaultKeymap.boxed_clone())
|
||||
})
|
||||
})
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.trigger(|open, _, _|
|
||||
IconButton::new(
|
||||
"OpenKeymapJsonButton",
|
||||
IconName::Json
|
||||
)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.when(!open, |this|
|
||||
this.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta("Open Keymap JSON", Some(&zed_actions::OpenKeymap),"Right click to view more options", window, cx)
|
||||
})
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(zed_actions::OpenKeymap.boxed_clone(), cx);
|
||||
})
|
||||
)
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.key_context({
|
||||
@@ -1590,139 +1617,73 @@ impl Render for KeymapEditor {
|
||||
.py_1()
|
||||
.border_1()
|
||||
.border_color(theme.colors().border)
|
||||
.rounded_md()
|
||||
.rounded_lg()
|
||||
.child(self.filter_editor.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.min_w_64()
|
||||
.child(
|
||||
IconButton::new(
|
||||
"KeymapEditorToggleFiltersIcon",
|
||||
IconName::Keyboard,
|
||||
IconButton::new(
|
||||
"KeymapEditorToggleFiltersIcon",
|
||||
IconName::Keyboard,
|
||||
)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Search by Keystroke",
|
||||
&ToggleKeystrokeSearch,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Search by Keystroke",
|
||||
&ToggleKeystrokeSearch,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.toggle_state(matches!(
|
||||
self.search_mode,
|
||||
SearchMode::KeyStroke { .. }
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleKeystrokeSearch.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
}
|
||||
})
|
||||
.toggle_state(matches!(
|
||||
self.search_mode,
|
||||
SearchMode::KeyStroke { .. }
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.when(
|
||||
self.keybinding_conflict_state.any_user_binding_conflicts(),
|
||||
|this| {
|
||||
this.indicator(Indicator::dot().color(Color::Warning))
|
||||
},
|
||||
)
|
||||
.child(
|
||||
IconButton::new("KeymapEditorConflictIcon", IconName::Warning)
|
||||
.icon_size(IconSize::Small)
|
||||
.when(
|
||||
self.keybinding_conflict_state
|
||||
.any_user_binding_conflicts(),
|
||||
|this| {
|
||||
this.indicator(
|
||||
Indicator::dot().color(Color::Warning),
|
||||
)
|
||||
.tooltip({
|
||||
let filter_state = self.filter_state;
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
match filter_state {
|
||||
FilterState::All => "Show Conflicts",
|
||||
FilterState::Conflicts => "Hide Conflicts",
|
||||
},
|
||||
&ToggleConflictFilter,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.tooltip({
|
||||
let filter_state = self.filter_state;
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
match filter_state {
|
||||
FilterState::All => "Show Conflicts",
|
||||
FilterState::Conflicts => {
|
||||
"Hide Conflicts"
|
||||
}
|
||||
},
|
||||
&ToggleConflictFilter,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.selected_icon_color(Color::Warning)
|
||||
.toggle_state(matches!(
|
||||
self.filter_state,
|
||||
FilterState::Conflicts
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleConflictFilter.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.ml_1()
|
||||
.pl_2()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
right_click_menu("open-keymap-menu")
|
||||
.menu(|window, cx| {
|
||||
ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.header("Open Keymap JSON")
|
||||
.action(
|
||||
"User",
|
||||
zed_actions::OpenKeymap.boxed_clone(),
|
||||
)
|
||||
.action(
|
||||
"Zed Default",
|
||||
zed_actions::OpenDefaultKeymap
|
||||
.boxed_clone(),
|
||||
)
|
||||
.action(
|
||||
"Vim Default",
|
||||
vim::OpenDefaultKeymap.boxed_clone(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.anchor(gpui::Corner::TopLeft)
|
||||
.trigger(|open, _, _| {
|
||||
IconButton::new(
|
||||
"OpenKeymapJsonButton",
|
||||
IconName::Json,
|
||||
)
|
||||
.icon_size(IconSize::Small)
|
||||
.when(!open, |this| {
|
||||
this.tooltip(move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Open keymap.json",
|
||||
Some(&zed_actions::OpenKeymap),
|
||||
"Right click to view more options",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
zed_actions::OpenKeymap.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.selected_icon_color(Color::Warning)
|
||||
.toggle_state(matches!(
|
||||
self.filter_state,
|
||||
FilterState::Conflicts
|
||||
))
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleConflictFilter.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(
|
||||
@@ -1733,42 +1694,48 @@ impl Render for KeymapEditor {
|
||||
|this, exact_match| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.map(|this| {
|
||||
if self
|
||||
.keybinding_conflict_state
|
||||
.any_user_binding_conflicts()
|
||||
{
|
||||
this.pr(rems_from_px(54.))
|
||||
} else {
|
||||
this.pr_7()
|
||||
}
|
||||
})
|
||||
.gap_2()
|
||||
.child(self.keystroke_editor.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_64()
|
||||
.child(
|
||||
IconButton::new(
|
||||
"keystrokes-exact-match",
|
||||
IconName::CaseSensitive,
|
||||
)
|
||||
.tooltip({
|
||||
let keystroke_focus_handle =
|
||||
self.keystroke_editor.read(cx).focus_handle(cx);
|
||||
IconButton::new(
|
||||
"keystrokes-exact-match",
|
||||
IconName::CaseSensitive,
|
||||
)
|
||||
.tooltip({
|
||||
let keystroke_focus_handle =
|
||||
self.keystroke_editor.read(cx).focus_handle(cx);
|
||||
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Exact Match Mode",
|
||||
&ToggleExactKeystrokeMatching,
|
||||
&keystroke_focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(exact_match)
|
||||
.on_click(
|
||||
cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleExactKeystrokeMatching.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Exact Match Mode",
|
||||
&ToggleExactKeystrokeMatching,
|
||||
&keystroke_focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(exact_match)
|
||||
.on_click(
|
||||
cx.listener(|_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
ToggleExactKeystrokeMatching.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user