Detailed changes
@@ -14028,6 +14028,7 @@ dependencies = [
"clap",
"client",
"clock",
+ "collections",
"crash-handler",
"crashes",
"dap",
@@ -14056,6 +14057,7 @@ dependencies = [
"minidumper",
"node_runtime",
"paths",
+ "pretty_assertions",
"project",
"proto",
"release_channel",
@@ -442,9 +442,8 @@
">": "vim::Indent",
"<": "vim::Outdent",
"=": "vim::AutoIndent",
- "g u": "vim::PushLowercase",
- "g shift-u": "vim::PushUppercase",
- "g ~": "vim::PushOppositeCase",
+ "`": "vim::ConvertToLowerCase",
+ "alt-`": "vim::ConvertToUpperCase",
"g q": "vim::PushRewrap",
"g w": "vim::PushRewrap",
"insert": "vim::InsertBefore",
@@ -1781,6 +1781,9 @@ impl AcpThread {
reuse_shared_snapshot: bool,
cx: &mut Context<Self>,
) -> Task<Result<String>> {
+ // Args are 1-based, move to 0-based
+ let line = line.unwrap_or_default().saturating_sub(1);
+ let limit = limit.unwrap_or(u32::MAX);
let project = self.project.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |this, cx| {
@@ -1808,44 +1811,37 @@ impl AcpThread {
action_log.update(cx, |action_log, cx| {
action_log.buffer_read(buffer.clone(), cx);
})?;
- project.update(cx, |project, cx| {
- let position = buffer
- .read(cx)
- .snapshot()
- .anchor_before(Point::new(line.unwrap_or_default(), 0));
- project.set_agent_location(
- Some(AgentLocation {
- buffer: buffer.downgrade(),
- position,
- }),
- cx,
- );
- })?;
- buffer.update(cx, |buffer, _| buffer.snapshot())?
+ let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
+ this.update(cx, |this, _| {
+ this.shared_buffers.insert(buffer.clone(), snapshot.clone());
+ })?;
+ snapshot
};
- this.update(cx, |this, _| {
- let text = snapshot.text();
- this.shared_buffers.insert(buffer.clone(), snapshot);
- if line.is_none() && limit.is_none() {
- return Ok(text);
- }
- let limit = limit.unwrap_or(u32::MAX) as usize;
- let Some(line) = line else {
- return Ok(text.lines().take(limit).collect::<String>());
- };
+ let max_point = snapshot.max_point();
+ if line >= max_point.row {
+ anyhow::bail!(
+ "Attempting to read beyond the end of the file, line {}:{}",
+ max_point.row + 1,
+ max_point.column
+ );
+ }
- let count = text.lines().count();
- if count < line as usize {
- anyhow::bail!("There are only {} lines", count);
- }
- Ok(text
- .lines()
- .skip(line as usize + 1)
- .take(limit)
- .collect::<String>())
- })?
+ let start = snapshot.anchor_before(Point::new(line, 0));
+ let end = snapshot.anchor_before(Point::new(line.saturating_add(limit), 0));
+
+ project.update(cx, |project, cx| {
+ project.set_agent_location(
+ Some(AgentLocation {
+ buffer: buffer.downgrade(),
+ position: start,
+ }),
+ cx,
+ );
+ })?;
+
+ Ok(snapshot.text_for_range(start..end).collect::<String>())
})
}
@@ -2391,6 +2387,82 @@ mod tests {
request.await.unwrap();
}
+ #[gpui::test]
+ async fn test_reading_from_line(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(path!("/tmp"), json!({"foo": "one\ntwo\nthree\nfour\n"}))
+ .await;
+ let project = Project::test(fs.clone(), [], cx).await;
+ project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree(path!("/tmp/foo"), true, cx)
+ })
+ .await
+ .unwrap();
+
+ let connection = Rc::new(FakeAgentConnection::new());
+
+ let thread = cx
+ .update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
+ .await
+ .unwrap();
+
+ // Whole file
+ let content = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(path!("/tmp/foo").into(), None, None, false, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(content, "one\ntwo\nthree\nfour\n");
+
+ // Only start line
+ let content = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(path!("/tmp/foo").into(), Some(3), None, false, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(content, "three\nfour\n");
+
+ // Only limit
+ let content = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(path!("/tmp/foo").into(), None, Some(2), false, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(content, "one\ntwo\n");
+
+ // Range
+ let content = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(path!("/tmp/foo").into(), Some(2), Some(2), false, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_eq!(content, "two\nthree\n");
+
+ // Invalid
+ let err = thread
+ .update(cx, |thread, cx| {
+ thread.read_text_file(path!("/tmp/foo").into(), Some(5), Some(2), false, cx)
+ })
+ .await
+ .unwrap_err();
+
+ assert_eq!(
+ err.to_string(),
+ "Attempting to read beyond the end of the file, line 5:0"
+ );
+ }
+
#[gpui::test]
async fn test_succeeding_canceled_toolcall(cx: &mut TestAppContext) {
init_test(cx);
@@ -201,7 +201,6 @@ impl AgentTool for ReadFileTool {
// Check if specific line ranges are provided
let result = if input.start_line.is_some() || input.end_line.is_some() {
let result = buffer.read_with(cx, |buffer, _cx| {
- let text = buffer.text();
// .max(1) because despite instructions to be 1-indexed, sometimes the model passes 0.
let start = input.start_line.unwrap_or(1).max(1);
let start_row = start - 1;
@@ -210,13 +209,13 @@ impl AgentTool for ReadFileTool {
anchor = Some(buffer.anchor_before(Point::new(start_row, column)));
}
- let lines = text.split('\n').skip(start_row as usize);
- if let Some(end) = input.end_line {
- let count = end.saturating_sub(start).saturating_add(1); // Ensure at least 1 line
- itertools::intersperse(lines.take(count as usize), "\n").collect::<String>()
- } else {
- itertools::intersperse(lines, "\n").collect::<String>()
+ let mut end_row = input.end_line.unwrap_or(u32::MAX);
+ if end_row <= start_row {
+ end_row = start_row + 1; // read at least one lines
}
+ let start = buffer.anchor_before(Point::new(start_row, 0));
+ let end = buffer.anchor_before(Point::new(end_row, 0));
+ buffer.text_for_range(start..end).collect::<String>()
})?;
action_log.update(cx, |log, cx| {
@@ -445,7 +444,7 @@ mod test {
tool.run(input, ToolCallEventStream::test().0, cx)
})
.await;
- assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4".into());
+ assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into());
}
#[gpui::test]
@@ -475,7 +474,7 @@ mod test {
tool.clone().run(input, ToolCallEventStream::test().0, cx)
})
.await;
- assert_eq!(result.unwrap(), "Line 1\nLine 2".into());
+ assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into());
// end_line of 0 should result in at least 1 line
let result = cx
@@ -488,7 +487,7 @@ mod test {
tool.clone().run(input, ToolCallEventStream::test().0, cx)
})
.await;
- assert_eq!(result.unwrap(), "Line 1".into());
+ assert_eq!(result.unwrap(), "Line 1\n".into());
// when start_line > end_line, should still return at least 1 line
let result = cx
@@ -501,7 +500,7 @@ mod test {
tool.clone().run(input, ToolCallEventStream::test().0, cx)
})
.await;
- assert_eq!(result.unwrap(), "Line 3".into());
+ assert_eq!(result.unwrap(), "Line 3\n".into());
}
fn init_test(cx: &mut TestAppContext) {
@@ -1591,7 +1591,7 @@ impl AcpThreadView {
task.shell = shell;
let terminal = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
- terminal_panel.spawn_task(login.clone(), window, cx)
+ terminal_panel.spawn_task(&login, window, cx)
})?;
let terminal = terminal.await?;
@@ -41,7 +41,10 @@ use project::{
project_settings::LspSettings,
};
use serde_json::{self, json};
-use settings::{AllLanguageSettingsContent, ProjectSettingsContent};
+use settings::{
+ AllLanguageSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring,
+ ProjectSettingsContent,
+};
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
use std::{
iter,
@@ -19965,7 +19968,8 @@ fn indent_guide(buffer_id: BufferId, start_row: u32, end_row: u32, depth: u32) -
enabled: true,
line_width: 1,
active_line_width: 1,
- ..Default::default()
+ coloring: IndentGuideColoring::default(),
+ background_coloring: IndentGuideBackgroundColoring::default(),
},
}
}
@@ -469,13 +469,24 @@ impl<'a> MutableSelectionsCollection<'a> {
}
pub(crate) fn set_pending_anchor_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
+ let buffer = self.buffer.read(self.cx).snapshot(self.cx);
self.collection.pending = Some(PendingSelection {
- selection: Selection {
- id: post_inc(&mut self.collection.next_selection_id),
- start: range.start,
- end: range.end,
- reversed: false,
- goal: SelectionGoal::None,
+ selection: {
+ let mut start = range.start;
+ let mut end = range.end;
+ let reversed = if start.cmp(&end, &buffer).is_gt() {
+ mem::swap(&mut start, &mut end);
+ true
+ } else {
+ false
+ };
+ Selection {
+ id: post_inc(&mut self.collection.next_selection_id),
+ start,
+ end,
+ reversed,
+ goal: SelectionGoal::None,
+ }
},
mode,
});
@@ -47,75 +47,84 @@ impl BlameRenderer for GitBlameRenderer {
let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
Some(
- h_flex()
- .w_full()
- .justify_between()
- .font_family(style.font().family)
- .line_height(style.line_height)
- .id(("blame", ix))
- .text_color(cx.theme().status().hint)
- .pr_2()
- .gap_2()
+ div()
+ .mr_2()
.child(
h_flex()
- .items_center()
+ .w_full()
+ .justify_between()
+ .font_family(style.font().family)
+ .line_height(style.line_height)
+ .id(("blame", ix))
+ .text_color(cx.theme().status().hint)
.gap_2()
- .child(div().text_color(sha_color).child(short_commit_id))
- .child(name),
- )
- .child(relative_timestamp)
- .hover(|style| style.bg(cx.theme().colors().element_hover))
- .cursor_pointer()
- .on_mouse_down(MouseButton::Right, {
- let blame_entry = blame_entry.clone();
- let details = details.clone();
- move |event, window, cx| {
- deploy_blame_entry_context_menu(
- &blame_entry,
- details.as_ref(),
- editor.clone(),
- event.position,
- window,
- cx,
- );
- }
- })
- .on_click({
- let blame_entry = blame_entry.clone();
- let repository = repository.clone();
- let workspace = workspace.clone();
- move |_, window, cx| {
- CommitView::open(
- CommitSummary {
- 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(),
- workspace.clone(),
- window,
- cx,
- )
- }
- })
- .hoverable_tooltip(move |_window, cx| {
- cx.new(|cx| {
- CommitTooltip::blame_entry(
- &blame_entry,
- details.clone(),
- repository.clone(),
- workspace.clone(),
- cx,
+ .child(
+ h_flex()
+ .items_center()
+ .gap_2()
+ .child(div().text_color(sha_color).child(short_commit_id))
+ .child(name),
)
- })
- .into()
- })
+ .child(relative_timestamp)
+ .hover(|style| style.bg(cx.theme().colors().element_hover))
+ .cursor_pointer()
+ .on_mouse_down(MouseButton::Right, {
+ let blame_entry = blame_entry.clone();
+ let details = details.clone();
+ move |event, window, cx| {
+ deploy_blame_entry_context_menu(
+ &blame_entry,
+ details.as_ref(),
+ editor.clone(),
+ event.position,
+ window,
+ cx,
+ );
+ }
+ })
+ .on_click({
+ let blame_entry = blame_entry.clone();
+ let repository = repository.clone();
+ let workspace = workspace.clone();
+ move |_, window, cx| {
+ CommitView::open(
+ CommitSummary {
+ 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(),
+ workspace.clone(),
+ window,
+ cx,
+ )
+ }
+ })
+ .hoverable_tooltip(move |_window, cx| {
+ cx.new(|cx| {
+ CommitTooltip::blame_entry(
+ &blame_entry,
+ details.clone(),
+ repository.clone(),
+ workspace.clone(),
+ cx,
+ )
+ })
+ .into()
+ }),
+ )
.into_any(),
)
}
@@ -13,13 +13,13 @@ use schemars::json_schema;
pub use settings::{
CompletionSettingsContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave,
- Formatter, FormatterList, InlayHintKind, LspInsertMode, RewrapBehavior, SelectedFormatter,
- ShowWhitespaceSetting, SoftWrap, WordsCompletionMode,
+ Formatter, FormatterList, InlayHintKind, LanguageSettingsContent, LspInsertMode,
+ RewrapBehavior, SelectedFormatter, ShowWhitespaceSetting, SoftWrap, WordsCompletionMode,
};
use settings::{
- IndentGuideSettingsContent, LanguageSettingsContent, LanguageTaskSettingsContent,
- ParameterizedJsonSchema, PrettierSettingsContent, Settings, SettingsContent, SettingsLocation,
- SettingsStore, SettingsUi,
+ IndentGuideSettingsContent, LanguageTaskSettingsContent, ParameterizedJsonSchema,
+ PrettierSettingsContent, Settings, SettingsContent, SettingsLocation, SettingsStore,
+ SettingsUi,
};
use shellexpand;
use std::{borrow::Cow, num::NonZeroU32, path::Path, sync::Arc};
@@ -77,6 +77,7 @@ assistant_tool.workspace = true
assistant_tools.workspace = true
client = { workspace = true, features = ["test-support"] }
clock = { workspace = true, features = ["test-support"] }
+collections.workspace = true
dap = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
@@ -85,6 +86,7 @@ gpui = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
node_runtime = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
remote = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
@@ -6,6 +6,7 @@ use assistant_tool::{Tool as _, ToolResultContent};
use assistant_tools::{ReadFileTool, ReadFileToolInput};
use client::{Client, UserStore};
use clock::FakeSystemClock;
+use collections::{HashMap, HashSet};
use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModel};
use extension::ExtensionHostProxy;
@@ -20,6 +21,7 @@ use lsp::{CompletionContext, CompletionResponse, CompletionTriggerKind, Language
use node_runtime::NodeRuntime;
use project::{
Project, ProjectPath,
+ agent_server_store::AgentServerCommand,
search::{SearchQuery, SearchResult},
};
use remote::RemoteClient;
@@ -27,7 +29,6 @@ use serde_json::json;
use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content};
use smol::stream::StreamExt;
use std::{
- collections::HashSet,
path::{Path, PathBuf},
sync::Arc,
};
@@ -1770,6 +1771,91 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu
does_not_exist_result.output.await.unwrap_err();
}
+#[gpui::test]
+async fn test_remote_external_agent_server(
+ cx: &mut TestAppContext,
+ server_cx: &mut TestAppContext,
+) {
+ let fs = FakeFs::new(server_cx.executor());
+ fs.insert_tree(path!("/project"), json!({})).await;
+
+ let (project, _headless_project) = init_test(&fs, cx, server_cx).await;
+ project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree(path!("/project"), true, cx)
+ })
+ .await
+ .unwrap();
+ let names = project.update(cx, |project, cx| {
+ project
+ .agent_server_store()
+ .read(cx)
+ .external_agents()
+ .map(|name| name.to_string())
+ .collect::<Vec<_>>()
+ });
+ pretty_assertions::assert_eq!(names, ["gemini", "claude"]);
+ server_cx.update_global::<SettingsStore, _>(|settings_store, cx| {
+ settings_store
+ .set_raw_server_settings(
+ Some(json!({
+ "agent_servers": {
+ "foo": {
+ "command": "foo-cli",
+ "args": ["--flag"],
+ "env": {
+ "VAR": "val"
+ }
+ }
+ }
+ })),
+ cx,
+ )
+ .unwrap();
+ });
+ server_cx.run_until_parked();
+ cx.run_until_parked();
+ let names = project.update(cx, |project, cx| {
+ project
+ .agent_server_store()
+ .read(cx)
+ .external_agents()
+ .map(|name| name.to_string())
+ .collect::<Vec<_>>()
+ });
+ pretty_assertions::assert_eq!(names, ["gemini", "foo", "claude"]);
+ let (command, root, login) = project
+ .update(cx, |project, cx| {
+ project.agent_server_store().update(cx, |store, cx| {
+ store
+ .get_external_agent(&"foo".into())
+ .unwrap()
+ .get_command(
+ None,
+ HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]),
+ None,
+ None,
+ &mut cx.to_async(),
+ )
+ })
+ })
+ .await
+ .unwrap();
+ assert_eq!(
+ command,
+ AgentServerCommand {
+ path: "ssh".into(),
+ args: vec!["foo-cli".into(), "--flag".into()],
+ env: Some(HashMap::from_iter([
+ ("VAR".into(), "val".into()),
+ ("OTHER_VAR".into(), "other-val".into())
+ ]))
+ }
+ );
+ assert_eq!(&PathBuf::from(root), paths::home_dir());
+ assert!(login.is_none());
+}
+
pub async fn init_test(
server_fs: &Arc<FakeFs>,
cx: &mut TestAppContext,
@@ -432,7 +432,7 @@ impl InlayHintKind {
}
}
-/// Controls how completions are processedfor this anguage.
+/// Controls how completions are processed for this language.
#[skip_serializing_none]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
@@ -362,6 +362,22 @@ impl SettingsStore {
Ok(())
}
+ /// Replaces current settings with the values from the given JSON.
+ pub fn set_raw_server_settings(
+ &mut self,
+ new_settings: Option<Value>,
+ cx: &mut App,
+ ) -> Result<()> {
+ // Rewrite the server settings into a content type
+ self.server_settings = new_settings
+ .map(|settings| settings.to_string())
+ .and_then(|str| parse_json_with_comments::<SettingsContent>(&str).ok())
+ .map(Box::new);
+
+ self.recompute_values(None, cx)?;
+ Ok(())
+ }
+
/// Get the configured settings profile names.
pub fn configured_settings_profiles(&self) -> impl Iterator<Item = &str> {
self.user_settings
@@ -364,6 +364,7 @@ impl TerminalBuilder {
env.insert("ZED_TERM".to_string(), "true".to_string());
env.insert("TERM_PROGRAM".to_string(), "zed".to_string());
env.insert("TERM".to_string(), "xterm-256color".to_string());
+ env.insert("COLORTERM".to_string(), "truecolor".to_string());
env.insert(
"TERM_PROGRAM_VERSION".to_string(),
release_channel::AppVersion::global(cx).to_string(),
@@ -532,14 +533,10 @@ impl TerminalBuilder {
child_exited: None,
};
- if !activation_script.is_empty() && no_task {
+ if cfg!(not(target_os = "windows")) && !activation_script.is_empty() && no_task {
for activation_script in activation_script {
terminal.input(activation_script.into_bytes());
- terminal.write_to_pty(if cfg!(windows) {
- &b"\r\n"[..]
- } else {
- &b"\n"[..]
- });
+ terminal.write_to_pty(b"\n");
}
terminal.clear();
}
@@ -19,7 +19,7 @@ use itertools::Itertools;
use project::{Fs, Project, ProjectEntryId};
use search::{BufferSearchBar, buffer_search::DivRegistrar};
use settings::{Settings, TerminalDockPosition};
-use task::{RevealStrategy, RevealTarget, SpawnInTerminal, TaskId};
+use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId};
use terminal::{Terminal, terminal_settings::TerminalSettings};
use ui::{
ButtonCommon, Clickable, ContextMenu, FluentBuilder, PopoverMenu, Toggleable, Tooltip,
@@ -518,10 +518,42 @@ impl TerminalPanel {
pub fn spawn_task(
&mut self,
- task: SpawnInTerminal,
+ task: &SpawnInTerminal,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
+ let remote_client = self
+ .workspace
+ .update(cx, |workspace, cx| {
+ let project = workspace.project().read(cx);
+ if project.is_via_collab() {
+ Err(anyhow!("cannot spawn tasks as a guest"))
+ } else {
+ Ok(project.remote_client())
+ }
+ })
+ .flatten();
+
+ let remote_client = match remote_client {
+ Ok(remote_client) => remote_client,
+ Err(e) => return Task::ready(Err(e)),
+ };
+
+ let remote_shell = remote_client
+ .as_ref()
+ .and_then(|remote_client| remote_client.read(cx).shell());
+
+ let builder = ShellBuilder::new(remote_shell.as_deref(), &task.shell);
+ let command_label = builder.command_label(&task.command_label);
+ let (command, args) = builder.build(task.command.clone(), &task.args);
+
+ let task = SpawnInTerminal {
+ command_label,
+ command: Some(command),
+ args,
+ ..task.clone()
+ };
+
if task.allow_concurrent_runs && task.use_new_terminal {
return self.spawn_in_new_terminal(task, window, cx);
}
@@ -1551,7 +1583,7 @@ impl workspace::TerminalProvider for TerminalProvider {
window.spawn(cx, async move |cx| {
let terminal = terminal_panel
.update_in(cx, |terminal_panel, window, cx| {
- terminal_panel.spawn_task(task, window, cx)
+ terminal_panel.spawn_task(&task, window, cx)
})
.ok()?
.await;
@@ -214,11 +214,10 @@ impl Vim {
Mode::HelixNormal | Mode::HelixSelect => {
if selection.is_empty() {
- // Handle empty selection by operating on the whole word
- let (word_range, _) = snapshot.surrounding_word(selection.start, false);
- let word_start = snapshot.offset_to_point(word_range.start);
- let word_end = snapshot.offset_to_point(word_range.end);
- ranges.push(word_start..word_end);
+ // Handle empty selection by operating on single character
+ let start = selection.start;
+ let end = snapshot.clip_point(start + Point::new(0, 1), Bias::Right);
+ ranges.push(start..end);
cursor_positions.push(selection.start..selection.start);
} else {
ranges.push(selection.start..selection.end);
@@ -445,15 +444,26 @@ mod test {
cx.simulate_keystrokes("~");
cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal);
- // Cursor-only (empty) selection
+ // Cursor-only (empty) selection - switch case
cx.set_state("The ˇquick brown", Mode::HelixNormal);
cx.simulate_keystrokes("~");
- cx.assert_state("The ˇQUICK brown", Mode::HelixNormal);
+ cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
+ cx.simulate_keystrokes("~");
+ cx.assert_state("The ˇquick brown", Mode::HelixNormal);
+
+ // Cursor-only (empty) selection - switch to uppercase and lowercase explicitly
+ cx.set_state("The ˇquick brown", Mode::HelixNormal);
+ cx.simulate_keystrokes("alt-`");
+ cx.assert_state("The ˇQuick brown", Mode::HelixNormal);
+ cx.simulate_keystrokes("`");
+ cx.assert_state("The ˇquick brown", Mode::HelixNormal);
// With `e` motion (which extends selection to end of word in Helix)
cx.set_state("The ˇquick brown fox", Mode::HelixNormal);
cx.simulate_keystrokes("e");
cx.simulate_keystrokes("~");
cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal);
+
+ // Cursor-only
}
}
@@ -8,7 +8,7 @@ use remote::ConnectionState;
use task::{DebugScenario, ResolvedTask, SpawnInTerminal, TaskContext, TaskTemplate};
use ui::Window;
-use crate::Workspace;
+use crate::{Toast, Workspace, notifications::NotificationId};
impl Workspace {
pub fn schedule_task(
@@ -73,8 +73,10 @@ impl Workspace {
if let Some(terminal_provider) = self.terminal_provider.as_ref() {
let task_status = terminal_provider.spawn(spawn_in_terminal, window, cx);
- let task = cx.background_spawn(async move {
- match task_status.await {
+
+ let task = cx.spawn(async |w, cx| {
+ let res = cx.background_spawn(task_status).await;
+ match res {
Some(Ok(status)) => {
if status.success() {
log::debug!("Task spawn succeeded");
@@ -82,9 +84,15 @@ impl Workspace {
log::debug!("Task spawn failed, code: {:?}", status.code());
}
}
- Some(Err(e)) => log::error!("Task spawn failed: {e:#}"),
+ Some(Err(e)) => {
+ log::error!("Task spawn failed: {e:#}");
+ _ = w.update(cx, |w, cx| {
+ let id = NotificationId::unique::<ResolvedTask>();
+ w.show_toast(Toast::new(id, format!("Task spawn failed: {e}")), cx);
+ })
+ }
None => log::debug!("Task spawn got cancelled"),
- }
+ };
});
self.scheduled_tasks.push(task);
}