mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Compare commits
7 commits
3d54fb70bd
...
5e2a56754c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e2a56754c | ||
|
|
09165c15dc | ||
|
|
e2e7a6769e | ||
|
|
5d3b9e467e | ||
|
|
06826ef10f | ||
|
|
7f4a99aa95 | ||
|
|
602ae328fa |
15 changed files with 1159 additions and 134 deletions
|
|
@ -456,7 +456,7 @@
|
|||
"alt-.": "vim::RepeatFind",
|
||||
|
||||
// Changes
|
||||
"shift-r": "editor::Paste",
|
||||
"shift-r": "vim::HelixReplaceWithYanked",
|
||||
"`": "vim::ConvertToLowerCase",
|
||||
"alt-`": "vim::ConvertToUpperCase",
|
||||
"insert": "vim::InsertBefore", // not a helix default
|
||||
|
|
|
|||
|
|
@ -164,10 +164,12 @@ pub fn lsp_tasks(
|
|||
},
|
||||
));
|
||||
}
|
||||
lsp_tasks
|
||||
.entry(source_kind)
|
||||
.or_insert_with(Vec::new)
|
||||
.append(&mut new_lsp_tasks);
|
||||
if !new_lsp_tasks.is_empty() {
|
||||
lsp_tasks
|
||||
.entry(source_kind)
|
||||
.or_insert_with(Vec::new)
|
||||
.append(&mut new_lsp_tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
lsp_tasks.into_iter().collect()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::commit_view::CommitView;
|
|||
use crate::git_panel_settings::GitPanelScrollbarAccessor;
|
||||
use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff};
|
||||
use crate::remote_output::{self, RemoteAction, SuccessMessage};
|
||||
use crate::solo_diff_view::SoloDiffView;
|
||||
use crate::{branch_picker, picker_prompt, render_remote_button};
|
||||
use crate::{
|
||||
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
|
||||
|
|
@ -15,10 +16,7 @@ use anyhow::Context as _;
|
|||
use askpass::AskPassDelegate;
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use db::kvp::KeyValueStore;
|
||||
use editor::{
|
||||
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior,
|
||||
actions::ExpandAllDiffHunks,
|
||||
};
|
||||
use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior};
|
||||
use editor::{EditorStyle, RewrapOptions};
|
||||
use file_icons::FileIcons;
|
||||
use futures::StreamExt as _;
|
||||
|
|
@ -85,7 +83,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME;
|
|||
use workspace::{
|
||||
Item, Workspace,
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
|
||||
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt},
|
||||
};
|
||||
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
|
||||
|
||||
|
|
@ -1385,63 +1383,22 @@ impl GitPanel {
|
|||
});
|
||||
}
|
||||
|
||||
fn open_file(
|
||||
fn open_solo_diff(
|
||||
&mut self,
|
||||
_: &menu::SecondaryConfirm,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
maybe!({
|
||||
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
|
||||
let active_repo = self.active_repository.as_ref()?;
|
||||
let path = active_repo
|
||||
.read(cx)
|
||||
.repo_path_to_project_path(&entry.repo_path, cx)?;
|
||||
if entry.status.is_deleted() {
|
||||
return None;
|
||||
}
|
||||
let entry = self
|
||||
.entries
|
||||
.get(self.selected_entry?)?
|
||||
.status_entry()?
|
||||
.clone();
|
||||
let repository = self.active_repository.clone()?;
|
||||
|
||||
let open_task = self
|
||||
.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path_preview(path, None, false, false, true, window, cx)
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let workspace = self.workspace.clone();
|
||||
cx.spawn_in(window, async move |_, mut cx| {
|
||||
let item = open_task
|
||||
.await
|
||||
.notify_workspace_async_err(workspace, &mut cx)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
|
||||
if let Some(active_editor) = item.downcast::<Editor>() {
|
||||
if let Some(diff_task) =
|
||||
active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())
|
||||
{
|
||||
diff_task.await;
|
||||
}
|
||||
|
||||
cx.update(|window, cx| {
|
||||
active_editor.update(cx, |editor, cx| {
|
||||
editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
|
||||
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
editor.go_to_hunk_before_or_after_position(
|
||||
&snapshot,
|
||||
language::Point::new(0, 0),
|
||||
Direction::Next,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
SoloDiffView::open_or_focus(entry, repository, self.workspace.clone(), window, cx)
|
||||
.detach_and_notify_err(self.workspace.clone(), window, cx);
|
||||
|
||||
Some(())
|
||||
});
|
||||
|
|
@ -5970,7 +5927,7 @@ impl GitPanel {
|
|||
)
|
||||
.separator()
|
||||
.action("Open Diff", menu::Confirm.boxed_clone())
|
||||
.action("Open File", menu::SecondaryConfirm.boxed_clone())
|
||||
.action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone())
|
||||
.when(!is_created, |context_menu| {
|
||||
context_menu
|
||||
.separator()
|
||||
|
|
@ -6249,7 +6206,7 @@ impl GitPanel {
|
|||
this.selected_entry = Some(ix);
|
||||
cx.notify();
|
||||
if event.click_count() > 1 || event.modifiers().secondary() {
|
||||
this.open_file(&Default::default(), window, cx)
|
||||
this.open_solo_diff(&Default::default(), window, cx)
|
||||
} else {
|
||||
this.open_diff(&Default::default(), window, cx);
|
||||
this.focus_handle.focus(window, cx);
|
||||
|
|
@ -6699,7 +6656,7 @@ impl Render for GitPanel {
|
|||
.on_action(cx.listener(Self::last_entry))
|
||||
.on_action(cx.listener(Self::close_panel))
|
||||
.on_action(cx.listener(Self::open_diff))
|
||||
.on_action(cx.listener(Self::open_file))
|
||||
.on_action(cx.listener(Self::open_solo_diff))
|
||||
.on_action(cx.listener(Self::focus_changes_list))
|
||||
.on_action(cx.listener(Self::focus_editor))
|
||||
.on_action(cx.listener(Self::expand_commit_editor))
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub mod picker_prompt;
|
|||
pub mod project_diff;
|
||||
pub(crate) mod remote_output;
|
||||
pub mod repository_selector;
|
||||
pub mod solo_diff_view;
|
||||
pub mod stash_picker;
|
||||
pub mod text_diff_view;
|
||||
pub mod worktree_names;
|
||||
|
|
|
|||
787
crates/git_ui/src/solo_diff_view.rs
Normal file
787
crates/git_ui/src/solo_diff_view.rs
Normal file
|
|
@ -0,0 +1,787 @@
|
|||
use crate::{git_panel::GitStatusEntry, git_status_icon};
|
||||
use anyhow::{Context as _, Result};
|
||||
use buffer_diff::DiffHunkSecondaryStatus;
|
||||
use editor::{
|
||||
Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff,
|
||||
actions::{GoToHunk, GoToPreviousHunk},
|
||||
};
|
||||
use fs::Fs;
|
||||
use git::{
|
||||
Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile,
|
||||
repository::RepoPath, status::StageStatus,
|
||||
};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use language::{Buffer, HighlightedText};
|
||||
use multi_buffer::MultiBuffer;
|
||||
use project::{
|
||||
Project,
|
||||
git_store::{Repository, RepositoryId},
|
||||
};
|
||||
use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file};
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
sync::Arc,
|
||||
};
|
||||
use ui::{
|
||||
Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _,
|
||||
SharedString, Tooltip, prelude::*, vertical_divider,
|
||||
};
|
||||
use util::paths::{PathExt as _, PathStyle};
|
||||
use workspace::{
|
||||
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
|
||||
Workspace,
|
||||
item::{ItemEvent, SaveOptions, TabContentParams},
|
||||
notifications::NotifyTaskExt,
|
||||
searchable::SearchableItemHandle,
|
||||
};
|
||||
|
||||
pub struct SoloDiffView {
|
||||
repository: Entity<Repository>,
|
||||
repository_id: RepositoryId,
|
||||
repo_path: RepoPath,
|
||||
buffer: Entity<Buffer>,
|
||||
editor: Entity<SplittableEditor>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
_settings_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl SoloDiffView {
|
||||
pub fn open_or_focus(
|
||||
entry: GitStatusEntry,
|
||||
repository: Entity<Repository>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<Self>>> {
|
||||
let Some(workspace_entity) = workspace.upgrade() else {
|
||||
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
|
||||
};
|
||||
|
||||
let existing = workspace_entity
|
||||
.read(cx)
|
||||
.items_of_type::<SoloDiffView>(cx)
|
||||
.find(|item| item.read(cx).matches(&repository, &entry.repo_path, cx));
|
||||
if let Some(existing) = existing {
|
||||
workspace_entity.update(cx, |workspace, cx| {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
});
|
||||
existing.focus_handle(cx).focus(window, cx);
|
||||
return Task::ready(Ok(existing));
|
||||
}
|
||||
|
||||
let Some(project_path) = repository
|
||||
.read(cx)
|
||||
.repo_path_to_project_path(&entry.repo_path, cx)
|
||||
else {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"could not resolve repository path {:?}",
|
||||
entry.repo_path
|
||||
)));
|
||||
};
|
||||
|
||||
let project = workspace_entity.read(cx).project().clone();
|
||||
let repo_path = entry.repo_path;
|
||||
window.spawn(cx, async move |cx| {
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer(project_path.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
let diff = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_uncommitted_diff(buffer.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
|
||||
workspace_entity.update_in(cx, |workspace, window, cx| {
|
||||
let workspace_handle = cx.entity();
|
||||
let view = cx.new(|cx| {
|
||||
Self::new(
|
||||
project,
|
||||
repository,
|
||||
repo_path,
|
||||
buffer,
|
||||
diff,
|
||||
workspace_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, window, cx);
|
||||
view
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn new(
|
||||
project: Entity<Project>,
|
||||
repository: Entity<Repository>,
|
||||
repo_path: RepoPath,
|
||||
buffer: Entity<Buffer>,
|
||||
diff: Entity<buffer_diff::BufferDiff>,
|
||||
workspace: Entity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repository_id = repository.read(cx).id;
|
||||
let multibuffer = cx.new(|cx| {
|
||||
let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
|
||||
multibuffer.add_diff(diff, cx);
|
||||
multibuffer.set_all_diff_hunks_expanded(cx);
|
||||
multibuffer
|
||||
});
|
||||
let editor = cx.new(|cx| {
|
||||
let editor = SplittableEditor::new(
|
||||
EditorSettings::get_global(cx).diff_view_style,
|
||||
multibuffer,
|
||||
project.clone(),
|
||||
workspace.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.rhs_editor().update(cx, |editor, cx| {
|
||||
editor.set_should_serialize(false, cx);
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
editor.go_to_hunk_before_or_after_position(
|
||||
&snapshot,
|
||||
language::Point::new(0, 0),
|
||||
Direction::Next,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor
|
||||
});
|
||||
|
||||
let mut previous_diff_view_style = EditorSettings::get_global(cx).diff_view_style;
|
||||
let settings_subscription =
|
||||
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
|
||||
let diff_view_style = EditorSettings::get_global(cx).diff_view_style;
|
||||
if diff_view_style != previous_diff_view_style {
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
if editor.diff_view_style() != diff_view_style {
|
||||
editor.toggle_split(&ToggleSplitDiff, window, cx);
|
||||
}
|
||||
});
|
||||
previous_diff_view_style = diff_view_style;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
repository,
|
||||
repository_id,
|
||||
repo_path,
|
||||
buffer,
|
||||
editor,
|
||||
workspace: workspace.downgrade(),
|
||||
_settings_subscription: settings_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, repository: &Entity<Repository>, repo_path: &RepoPath, cx: &App) -> bool {
|
||||
self.repository_id == repository.read(cx).id && &self.repo_path == repo_path
|
||||
}
|
||||
|
||||
fn button_states(&self, cx: &App) -> SoloDiffButtonStates {
|
||||
let editor = self.editor.read(cx).rhs_editor().read(cx);
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
let snapshot = multibuffer.snapshot(cx);
|
||||
let prev_next = snapshot.diff_hunks().nth(1).is_some();
|
||||
let mut selection = true;
|
||||
|
||||
let mut ranges = editor
|
||||
.selections
|
||||
.disjoint_anchor_ranges()
|
||||
.collect::<Vec<_>>();
|
||||
if !ranges.iter().any(|range| range.start != range.end) {
|
||||
selection = false;
|
||||
let anchor = editor.selections.newest_anchor().head();
|
||||
if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
|
||||
&& let Some(range) = snapshot
|
||||
.anchor_in_buffer(excerpt_range.context.start)
|
||||
.zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
|
||||
.map(|(start, end)| start..end)
|
||||
{
|
||||
ranges = vec![range];
|
||||
} else {
|
||||
ranges = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
let mut stage = false;
|
||||
let mut unstage = false;
|
||||
for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
|
||||
match hunk.status.secondary {
|
||||
DiffHunkSecondaryStatus::HasSecondaryHunk
|
||||
| DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
|
||||
stage = true;
|
||||
}
|
||||
DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
|
||||
stage = true;
|
||||
unstage = true;
|
||||
}
|
||||
DiffHunkSecondaryStatus::NoSecondaryHunk
|
||||
| DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
|
||||
unstage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stage_status = self
|
||||
.repository
|
||||
.read(cx)
|
||||
.status_for_path(&self.repo_path)
|
||||
.map(|entry| entry.status.staging())
|
||||
.unwrap_or(StageStatus::Unstaged);
|
||||
|
||||
SoloDiffButtonStates {
|
||||
stage,
|
||||
unstage,
|
||||
restore: stage || unstage,
|
||||
prev_next,
|
||||
selection,
|
||||
stage_file: stage_status.has_unstaged(),
|
||||
unstage_file: stage_status.has_staged(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut App) {
|
||||
self.focus_handle(cx).focus(window, cx);
|
||||
let action = action.boxed_clone();
|
||||
cx.defer(move |cx| {
|
||||
cx.dispatch_action(action.as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
fn change_file_stage(&self, stage: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let repository = self.repository.clone();
|
||||
let repo_path = self.repo_path.clone();
|
||||
let workspace = self.workspace.clone();
|
||||
let task = cx.spawn(async move |_, cx| {
|
||||
repository
|
||||
.update(cx, |repository, cx| {
|
||||
if stage {
|
||||
repository.stage_entries(vec![repo_path], cx)
|
||||
} else {
|
||||
repository.unstage_entries(vec![repo_path], cx)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.with_context(|| {
|
||||
if stage {
|
||||
"failed to stage file"
|
||||
} else {
|
||||
"failed to unstage file"
|
||||
}
|
||||
})
|
||||
});
|
||||
task.detach_and_notify_err(workspace, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<EditorEvent> for SoloDiffView {}
|
||||
|
||||
impl Focusable for SoloDiffView {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.editor.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Item for SoloDiffView {
|
||||
type Event = EditorEvent;
|
||||
|
||||
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
|
||||
Some(Icon::new(IconName::Diff).color(Color::Muted))
|
||||
}
|
||||
|
||||
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
|
||||
Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
|
||||
.color(if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
|
||||
self.buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.and_then(|file| {
|
||||
Some(
|
||||
file.full_path(cx)
|
||||
.file_name()?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.repo_path
|
||||
.as_ref()
|
||||
.display(PathStyle::local())
|
||||
.into_owned()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
|
||||
Some(
|
||||
self.buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| {
|
||||
self.repo_path
|
||||
.as_ref()
|
||||
.display(PathStyle::local())
|
||||
.into_owned()
|
||||
})
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
|
||||
Editor::to_item_events(event, f)
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
Some("Solo Diff View Opened")
|
||||
}
|
||||
|
||||
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor.deactivated(window, cx);
|
||||
}
|
||||
|
||||
fn act_as_type<'a>(
|
||||
&'a self,
|
||||
type_id: TypeId,
|
||||
self_handle: &'a Entity<Self>,
|
||||
cx: &'a App,
|
||||
) -> Option<gpui::AnyEntity> {
|
||||
if type_id == TypeId::of::<Self>() {
|
||||
Some(self_handle.clone().into())
|
||||
} else {
|
||||
self.editor.act_as_type(type_id, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
|
||||
Some(Box::new(self.editor.clone()))
|
||||
}
|
||||
|
||||
fn for_each_project_item(
|
||||
&self,
|
||||
cx: &App,
|
||||
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
|
||||
) {
|
||||
self.editor.for_each_project_item(cx, f)
|
||||
}
|
||||
|
||||
fn set_nav_history(
|
||||
&mut self,
|
||||
nav_history: ItemNavHistory,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.rhs_editor().update(cx, |editor, _| {
|
||||
editor.set_nav_history(Some(nav_history));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate(
|
||||
&mut self,
|
||||
data: Arc<dyn Any + Send>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor
|
||||
.rhs_editor()
|
||||
.update(cx, |editor, cx| editor.navigate(data, window, cx))
|
||||
})
|
||||
}
|
||||
|
||||
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
}
|
||||
|
||||
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<gpui::Font>)> {
|
||||
self.editor.breadcrumbs(cx)
|
||||
}
|
||||
|
||||
fn added_to_workspace(
|
||||
&mut self,
|
||||
workspace: &mut Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.rhs_editor().update(cx, |editor, cx| {
|
||||
editor.added_to_workspace(workspace, window, cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn can_save(&self, cx: &App) -> bool {
|
||||
self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
|
||||
}
|
||||
|
||||
fn save(
|
||||
&mut self,
|
||||
options: SaveOptions,
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.editor.save(options, project, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SoloDiffView {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
self.editor.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SoloDiffStyleToolbar {
|
||||
solo_diff: Option<WeakEntity<SoloDiffView>>,
|
||||
}
|
||||
|
||||
pub struct SoloDiffGitToolbar {
|
||||
solo_diff: Option<WeakEntity<SoloDiffView>>,
|
||||
}
|
||||
|
||||
impl SoloDiffStyleToolbar {
|
||||
pub fn new(_: &mut Context<Self>) -> Self {
|
||||
Self { solo_diff: None }
|
||||
}
|
||||
|
||||
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
|
||||
self.solo_diff.as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
fn set_diff_view_style(
|
||||
&mut self,
|
||||
diff_view_style: DiffViewStyle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(solo_diff) = self.solo_diff() else {
|
||||
return;
|
||||
};
|
||||
let workspace = solo_diff.read(cx).workspace.clone();
|
||||
|
||||
update_settings_file(<dyn Fs>::global(cx), cx, move |settings, _| {
|
||||
settings.editor.diff_view_style = Some(diff_view_style);
|
||||
});
|
||||
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
let splittable_editors = {
|
||||
workspace
|
||||
.read(cx)
|
||||
.items(cx)
|
||||
.filter_map(|item| item.act_as_type(TypeId::of::<SplittableEditor>(), cx))
|
||||
.filter_map(|item| item.downcast::<SplittableEditor>().ok())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for editor in splittable_editors {
|
||||
editor.update(cx, |editor, cx| {
|
||||
if editor.diff_view_style() != diff_view_style {
|
||||
editor.toggle_split(&ToggleSplitDiff, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for SoloDiffStyleToolbar {}
|
||||
|
||||
impl ToolbarItemView for SoloDiffStyleToolbar {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
self.solo_diff = active_pane_item
|
||||
.and_then(|item| item.act_as::<SoloDiffView>(cx))
|
||||
.map(|entity| entity.downgrade());
|
||||
if self.solo_diff.is_some() {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SoloDiffStyleToolbar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(solo_diff) = self.solo_diff() else {
|
||||
return div();
|
||||
};
|
||||
let editor_entity = solo_diff.read(cx).editor.clone();
|
||||
let editor = editor_entity.read(cx);
|
||||
let diff_view_style = editor.diff_view_style();
|
||||
let is_split_set = diff_view_style == DiffViewStyle::Split;
|
||||
let split_icon = if is_split_set && !editor.is_split() {
|
||||
IconName::DiffSplitAuto
|
||||
} else {
|
||||
IconName::DiffSplit
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.h_8()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("solo-diff-unified", IconName::DiffUnified)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(diff_view_style == DiffViewStyle::Unified)
|
||||
.tooltip(Tooltip::text("Unified"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.set_diff_view_style(DiffViewStyle::Unified, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("solo-diff-split", split_icon)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(diff_view_style == DiffViewStyle::Split)
|
||||
.tooltip(Tooltip::text("Split"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.set_diff_view_style(DiffViewStyle::Split, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(vertical_divider())
|
||||
.child(div().w_1())
|
||||
}
|
||||
}
|
||||
|
||||
impl SoloDiffGitToolbar {
|
||||
pub fn new(_: &mut Context<Self>) -> Self {
|
||||
Self { solo_diff: None }
|
||||
}
|
||||
|
||||
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
|
||||
self.solo_diff.as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(solo_diff) = self.solo_diff() {
|
||||
solo_diff.update(cx, |solo_diff, cx| {
|
||||
solo_diff.dispatch_action(action, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(solo_diff) = self.solo_diff() {
|
||||
solo_diff.update(cx, |solo_diff, cx| {
|
||||
solo_diff.change_file_stage(true, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn unstage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(solo_diff) = self.solo_diff() {
|
||||
solo_diff.update(cx, |solo_diff, cx| {
|
||||
solo_diff.change_file_stage(false, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for SoloDiffGitToolbar {}
|
||||
|
||||
impl ToolbarItemView for SoloDiffGitToolbar {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
self.solo_diff = active_pane_item
|
||||
.and_then(|item| item.act_as::<SoloDiffView>(cx))
|
||||
.map(|entity| entity.downgrade());
|
||||
if self.solo_diff.is_some() {
|
||||
ToolbarItemLocation::PrimaryRight
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SoloDiffButtonStates {
|
||||
stage: bool,
|
||||
unstage: bool,
|
||||
restore: bool,
|
||||
prev_next: bool,
|
||||
selection: bool,
|
||||
stage_file: bool,
|
||||
unstage_file: bool,
|
||||
}
|
||||
|
||||
impl Render for SoloDiffGitToolbar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(solo_diff) = self.solo_diff() else {
|
||||
return div();
|
||||
};
|
||||
let focus_handle = solo_diff.focus_handle(cx);
|
||||
let solo_diff = solo_diff.read(cx);
|
||||
let button_states = solo_diff.button_states(cx);
|
||||
let status_entry = solo_diff
|
||||
.repository
|
||||
.read(cx)
|
||||
.status_for_path(&solo_diff.repo_path);
|
||||
let status = status_entry.as_ref().map(|entry| entry.status);
|
||||
let diff_stat = status_entry.and_then(|entry| entry.diff_stat);
|
||||
|
||||
h_group_xl()
|
||||
.my_neg_1()
|
||||
.py_1()
|
||||
.items_center()
|
||||
.flex_wrap()
|
||||
.justify_between()
|
||||
.children(status.map(|status| git_status_icon(status).into_any_element()))
|
||||
.children(diff_stat.map(|stat| {
|
||||
DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize)
|
||||
.into_any_element()
|
||||
}))
|
||||
.child(
|
||||
h_group_sm()
|
||||
.when(button_states.selection, |el| {
|
||||
el.child(
|
||||
Button::new("stage", "Toggle Staged")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Toggle Staged",
|
||||
&ToggleStaged,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.stage && !button_states.unstage)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&ToggleStaged, window, cx)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when(!button_states.selection, |el| {
|
||||
el.child(
|
||||
Button::new("stage", "Stage")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Stage and go to next hunk",
|
||||
&StageAndNext,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.stage)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&StageAndNext, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("unstage", "Unstage")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Unstage and go to next hunk",
|
||||
&UnstageAndNext,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.unstage)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&UnstageAndNext, window, cx)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("restore", "Restore")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Restore selected hunk",
|
||||
&Restore,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.restore)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&Restore, window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_group_sm()
|
||||
.child(
|
||||
IconButton::new("up", IconName::ArrowUp)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Go to previous hunk",
|
||||
&GoToPreviousHunk,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.prev_next)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&GoToPreviousHunk, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("down", IconName::ArrowDown)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Go to next hunk",
|
||||
&GoToHunk,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.prev_next)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&GoToHunk, window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(vertical_divider())
|
||||
.child(
|
||||
h_group_sm()
|
||||
.child(
|
||||
Button::new("stage-file", "Stage File")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Stage file",
|
||||
&StageFile,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.stage_file)
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.stage_file(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("unstage-file", "Unstage File")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Unstage file",
|
||||
&UnstageFile,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.unstage_file)
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.unstage_file(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(Divider::vertical())
|
||||
.child(
|
||||
Button::new("commit", "Commit")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Commit",
|
||||
&Commit,
|
||||
&focus_handle,
|
||||
))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&Commit, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -336,6 +336,20 @@ impl TestAppContext {
|
|||
self.test_platform.simulate_new_path_selection(select_path);
|
||||
}
|
||||
|
||||
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
|
||||
pub fn simulate_path_prompt_response(
|
||||
&self,
|
||||
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
|
||||
) {
|
||||
self.test_platform
|
||||
.simulate_path_prompt_response(select_paths);
|
||||
}
|
||||
|
||||
/// Returns true if there's a path selection dialog pending.
|
||||
pub fn did_prompt_for_paths(&self) -> bool {
|
||||
self.test_platform.did_prompt_for_paths()
|
||||
}
|
||||
|
||||
/// Simulates clicking a button in an platform-level alert dialog.
|
||||
#[track_caller]
|
||||
pub fn simulate_prompt_answer(&self, button: &str) {
|
||||
|
|
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
|
|||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{PathPromptOptions, TestAppContext};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
|
||||
assert!(!cx.did_prompt_for_paths());
|
||||
|
||||
let receiver = cx.update(|cx| {
|
||||
cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: true,
|
||||
prompt: None,
|
||||
})
|
||||
});
|
||||
assert!(cx.did_prompt_for_paths());
|
||||
|
||||
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
|
||||
cx.simulate_path_prompt_response({
|
||||
let selected = selected.clone();
|
||||
move |options| {
|
||||
assert!(options.multiple);
|
||||
Some(selected)
|
||||
}
|
||||
});
|
||||
assert!(!cx.did_prompt_for_paths());
|
||||
|
||||
let response = receiver.await.unwrap().unwrap();
|
||||
assert_eq!(response, Some(selected));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
|
||||
let receiver = cx.update(|cx| {
|
||||
cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
})
|
||||
});
|
||||
|
||||
cx.simulate_path_prompt_response(|_options| None);
|
||||
|
||||
let response = receiver.await.unwrap().unwrap();
|
||||
assert_eq!(response, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use crate::{
|
||||
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
|
||||
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
|
||||
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
|
||||
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
|
||||
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
|
||||
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
|
||||
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
|
||||
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
|
||||
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
|
||||
size,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use collections::VecDeque;
|
||||
|
|
@ -85,6 +86,10 @@ struct TestPrompt {
|
|||
pub(crate) struct TestPrompts {
|
||||
multiple_choice: VecDeque<TestPrompt>,
|
||||
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
|
||||
paths: VecDeque<(
|
||||
PathPromptOptions,
|
||||
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl TestPlatform {
|
||||
|
|
@ -147,6 +152,33 @@ impl TestPlatform {
|
|||
tx.send(Ok(select_path(&path))).ok();
|
||||
}
|
||||
|
||||
pub(crate) fn simulate_path_prompt_response(
|
||||
&self,
|
||||
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
|
||||
) {
|
||||
let (options, tx) = self
|
||||
.prompts
|
||||
.borrow_mut()
|
||||
.paths
|
||||
.pop_front()
|
||||
.expect("no pending paths prompt");
|
||||
let selection = select_paths(&options);
|
||||
if let Some(paths) = &selection
|
||||
&& !options.multiple
|
||||
&& paths.len() > 1
|
||||
{
|
||||
panic!(
|
||||
"selected {} paths for a prompt that does not allow multiple selection",
|
||||
paths.len()
|
||||
);
|
||||
}
|
||||
tx.send(Ok(selection)).ok();
|
||||
}
|
||||
|
||||
pub(crate) fn did_prompt_for_paths(&self) -> bool {
|
||||
!self.prompts.borrow().paths.is_empty()
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
|
||||
let prompt = self
|
||||
|
|
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
|
|||
|
||||
fn prompt_for_paths(
|
||||
&self,
|
||||
_options: crate::PathPromptOptions,
|
||||
options: crate::PathPromptOptions,
|
||||
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
|
||||
unimplemented!()
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.prompts.borrow_mut().paths.push_back((options, tx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn prompt_for_new_path(
|
||||
|
|
|
|||
|
|
@ -734,11 +734,14 @@ mod tests {
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use editor::{Editor, SelectionEffects};
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
use language::{Language, LanguageConfig, LanguageMatcher, Point};
|
||||
use gpui::{App, Entity, Task, TestAppContext, VisualTestContext};
|
||||
use language::{
|
||||
Buffer, ContextProvider, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
|
||||
LanguageServerName, Point,
|
||||
};
|
||||
use project::{ContextProviderWithTasks, FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use task::TaskTemplates;
|
||||
use task::{TaskTemplate, TaskTemplates};
|
||||
use util::path;
|
||||
use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible};
|
||||
|
||||
|
|
@ -1033,6 +1036,80 @@ mod tests {
|
|||
cx.executor().run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_empty_lsp_task_response_keeps_language_tasks_in_modal(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/dir"), json!({ "main.test": "test" }))
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
|
||||
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
|
||||
language_registry.add(Arc::new(
|
||||
Language::new(
|
||||
LanguageConfig {
|
||||
name: "Test".into(),
|
||||
matcher: LanguageMatcher {
|
||||
path_suffixes: vec!["test".to_string()],
|
||||
..LanguageMatcher::default()
|
||||
},
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.with_context_provider(Some(Arc::new(
|
||||
ContextProviderWithLspTaskSource::new(ContextProviderWithTasks::new(
|
||||
TaskTemplates(vec![TaskTemplate {
|
||||
label: "Run language task".to_string(),
|
||||
command: "echo".to_string(),
|
||||
args: vec!["language task".to_string()],
|
||||
..TaskTemplate::default()
|
||||
}]),
|
||||
)),
|
||||
))),
|
||||
));
|
||||
let mut fake_servers = language_registry.register_fake_lsp(
|
||||
"Test",
|
||||
FakeLspAdapter {
|
||||
name: TEST_LSP_NAME,
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let workspace =
|
||||
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
|
||||
let _item = workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.open_abs_path(
|
||||
PathBuf::from(path!("/dir/main.test")),
|
||||
OpenOptions {
|
||||
visible: Some(OpenVisible::All),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
let fake_server = fake_servers
|
||||
.try_recv()
|
||||
.expect("fake LSP server should have started");
|
||||
use project::lsp_store::lsp_ext_command::Runnables;
|
||||
fake_server
|
||||
.set_request_handler::<Runnables, _, _>(move |_, _| async move { Ok(Vec::new()) });
|
||||
|
||||
let tasks_picker = open_spawn_tasks(&workspace, cx);
|
||||
assert_eq!(
|
||||
task_names(&tasks_picker, cx),
|
||||
vec!["Run language task"],
|
||||
"An empty LSP task response should not suppress language tasks in the modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_language_task_filtering(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
@ -1238,6 +1315,32 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
const TEST_LSP_NAME: &str = "test-lsp";
|
||||
|
||||
struct ContextProviderWithLspTaskSource {
|
||||
tasks: ContextProviderWithTasks,
|
||||
}
|
||||
|
||||
impl ContextProviderWithLspTaskSource {
|
||||
fn new(tasks: ContextProviderWithTasks) -> Self {
|
||||
Self { tasks }
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextProvider for ContextProviderWithLspTaskSource {
|
||||
fn associated_tasks(
|
||||
&self,
|
||||
buffer: Option<Entity<Buffer>>,
|
||||
cx: &App,
|
||||
) -> Task<Option<TaskTemplates>> {
|
||||
self.tasks.associated_tasks(buffer, cx)
|
||||
}
|
||||
|
||||
fn lsp_task_source(&self) -> Option<LanguageServerName> {
|
||||
Some(LanguageServerName::new_static(TEST_LSP_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
fn emulate_task_schedule(
|
||||
tasks_picker: Entity<Picker<TasksModalDelegate>>,
|
||||
project: &Entity<Project>,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ mod paste;
|
|||
mod select;
|
||||
mod surround;
|
||||
|
||||
use editor::display_map::{DisplayRow, DisplaySnapshot};
|
||||
use editor::display_map::{DisplayRow, DisplaySnapshot, ToDisplayPoint};
|
||||
use editor::{
|
||||
DisplayPoint, Editor, EditorSettings, MultiBufferOffset, NavigationOverlayLabel,
|
||||
NavigationTargetOverlay, SelectionEffects, ToOffset, ToPoint, movement,
|
||||
|
|
@ -57,6 +57,8 @@ actions!(
|
|||
HelixSubstitute,
|
||||
/// Delete the selection and enter edit mode, without yanking the selection.
|
||||
HelixSubstituteNoYank,
|
||||
/// Replace the selection with the contents of the default register (`R`).
|
||||
HelixReplaceWithYanked,
|
||||
/// Activate Helix-style word jump labels.
|
||||
HelixJumpToWord,
|
||||
/// Select the next match for the current search query.
|
||||
|
|
@ -74,6 +76,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|||
Vim::action(editor, cx, Vim::helix_yank);
|
||||
Vim::action(editor, cx, Vim::helix_goto_last_modification);
|
||||
Vim::action(editor, cx, Vim::helix_paste);
|
||||
Vim::action(editor, cx, Vim::helix_replace_with_yanked);
|
||||
Vim::action(editor, cx, Vim::helix_select_regex);
|
||||
Vim::action(editor, cx, Vim::helix_keep_newest_selection);
|
||||
Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| {
|
||||
|
|
@ -571,6 +574,11 @@ impl Vim {
|
|||
.iter()
|
||||
.any(|selection| !selection.is_empty());
|
||||
|
||||
// Plain `y` writes to register `"` only, never the system clipboard
|
||||
if vim.selected_register.is_none() {
|
||||
vim.selected_register = Some('"');
|
||||
}
|
||||
|
||||
if !has_selection {
|
||||
// If no selection, expand to current character (like 'v' does)
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
|
|
@ -727,6 +735,62 @@ impl Vim {
|
|||
});
|
||||
}
|
||||
|
||||
/// Replace each selection with the contents of the default
|
||||
/// register (or an explicitly selected register)
|
||||
pub fn helix_replace_with_yanked(
|
||||
&mut self,
|
||||
_: &HelixReplaceWithYanked,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.record_current_action(cx);
|
||||
self.update_editor(cx, |vim, editor, cx| {
|
||||
let selected_register = vim.selected_register.take();
|
||||
let Some(register) = Vim::update_globals(cx, |globals, cx| {
|
||||
if selected_register.is_some() {
|
||||
globals.read_register(selected_register, Some(editor), cx)
|
||||
} else {
|
||||
globals.read_default_register()
|
||||
}
|
||||
})
|
||||
.filter(|reg| !reg.text.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
let text = register.text;
|
||||
|
||||
editor.transact(window, cx, |editor, window, cx| {
|
||||
let display_map = editor.display_snapshot(cx);
|
||||
let selections = editor.selections.all_adjusted(&display_map);
|
||||
|
||||
let snapshot = display_map.buffer_snapshot();
|
||||
let mut edits = Vec::with_capacity(selections.len());
|
||||
let mut new_anchors = Vec::with_capacity(selections.len());
|
||||
for selection in &selections {
|
||||
let mut range = selection.start..selection.end;
|
||||
if range.is_empty() {
|
||||
range.end = movement::saturating_right(
|
||||
&display_map,
|
||||
range.end.to_display_point(&display_map),
|
||||
)
|
||||
.to_point(&display_map);
|
||||
}
|
||||
new_anchors.push(snapshot.anchor_before(range.start));
|
||||
edits.push((range, text.to_string()));
|
||||
}
|
||||
editor.edit(edits, cx);
|
||||
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.select_ranges(new_anchors.into_iter().map(|anchor| {
|
||||
let start = anchor.to_offset(&snapshot);
|
||||
start..start + text.len()
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
self.switch_mode(Mode::HelixNormal, true, window, cx);
|
||||
}
|
||||
|
||||
pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.update_editor(cx, |_, editor, cx| {
|
||||
editor.transact(window, cx, |editor, window, cx| {
|
||||
|
|
@ -877,6 +941,9 @@ impl Vim {
|
|||
})
|
||||
});
|
||||
if yank {
|
||||
if vim.selected_register.is_none() {
|
||||
vim.selected_register = Some('"');
|
||||
}
|
||||
vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
|
||||
}
|
||||
let selections = editor
|
||||
|
|
@ -2495,54 +2562,66 @@ mod test {
|
|||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.enable_helix();
|
||||
|
||||
// Test yanking current character with no selection
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("external".into()));
|
||||
|
||||
// Test yanking a single character (no explicit selection) should not clobber the system clipboard
|
||||
cx.set_state("hello ˇworld", Mode::HelixNormal);
|
||||
cx.simulate_keystrokes("y");
|
||||
|
||||
// Test cursor remains at the same position after yanking single character
|
||||
cx.assert_state("hello ˇworld", Mode::HelixNormal);
|
||||
cx.shared_clipboard().assert_eq("w");
|
||||
cx.shared_clipboard().assert_eq("external");
|
||||
cx.simulate_keystrokes("l p");
|
||||
cx.assert_state("hello wo«wˇ»rld", Mode::HelixNormal);
|
||||
|
||||
// Move cursor and yank another character
|
||||
cx.simulate_keystrokes("l");
|
||||
cx.simulate_keystrokes("y");
|
||||
cx.shared_clipboard().assert_eq("o");
|
||||
|
||||
// Test yanking with existing selection
|
||||
// Test yanking a multi-char selection
|
||||
cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
|
||||
cx.simulate_keystrokes("y");
|
||||
cx.shared_clipboard().assert_eq("worl");
|
||||
cx.shared_clipboard().assert_eq("external");
|
||||
cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
|
||||
|
||||
// Test yanking in select mode character by character
|
||||
cx.set_state("hello ˇworld", Mode::HelixNormal);
|
||||
cx.simulate_keystroke("v");
|
||||
cx.assert_state("hello «wˇ»orld", Mode::HelixSelect);
|
||||
cx.simulate_keystroke("y");
|
||||
cx.assert_state("hello «wˇ»orld", Mode::HelixNormal);
|
||||
cx.shared_clipboard().assert_eq("w");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
|
||||
async fn test_helix_delete_preserves_system_clipboard(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.enable_helix();
|
||||
|
||||
// First copy some text to clipboard
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("external".into()));
|
||||
cx.shared_clipboard().assert_eq("external");
|
||||
|
||||
cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
|
||||
cx.simulate_keystrokes("d");
|
||||
cx.shared_clipboard().assert_eq("external");
|
||||
|
||||
cx.set_state(
|
||||
indoc! {"
|
||||
ˇ
|
||||
second line"},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
cx.simulate_keystrokes("d");
|
||||
cx.shared_clipboard().assert_eq("external");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[gpui::test]
|
||||
async fn test_shift_r_replace_with_yanked(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.enable_helix();
|
||||
|
||||
// External clipboard content is irrelevant
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("external".into()));
|
||||
|
||||
cx.set_state("«hello worldˇ»", Mode::HelixNormal);
|
||||
cx.simulate_keystrokes("y");
|
||||
|
||||
// Test paste with shift-r on single cursor
|
||||
// Test bare cursor
|
||||
cx.set_state("foo ˇbar", Mode::HelixNormal);
|
||||
cx.simulate_keystrokes("shift-r");
|
||||
cx.assert_state("foo «hello worldˇ»ar", Mode::HelixNormal);
|
||||
|
||||
cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
|
||||
|
||||
// Test paste with shift-r on selection
|
||||
// Test multi-char selection
|
||||
cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
|
||||
cx.simulate_keystrokes("shift-r");
|
||||
|
||||
cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
|
||||
cx.assert_state("foo «hello worldˇ» baz", Mode::HelixNormal);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
|
|||
|
|
@ -37,8 +37,13 @@ impl Vim {
|
|||
|
||||
let selected_register = vim.selected_register.take();
|
||||
|
||||
// With an explicit register, defer to read_register so `+`/`*` still hit the clipboard
|
||||
let Some(register) = Vim::update_globals(cx, |globals, cx| {
|
||||
globals.read_register(selected_register, Some(editor), cx)
|
||||
if selected_register.is_some() {
|
||||
globals.read_register(selected_register, Some(editor), cx)
|
||||
} else {
|
||||
globals.read_default_register()
|
||||
}
|
||||
})
|
||||
.filter(|reg| !reg.text.is_empty()) else {
|
||||
return;
|
||||
|
|
@ -158,7 +163,7 @@ mod test {
|
|||
use crate::{state::Mode, test::VimTestContext};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_system_clipboard_paste(cx: &mut gpui::TestAppContext) {
|
||||
async fn test_helix_paste_ignores_system_clipboard(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.enable_helix();
|
||||
cx.set_state(
|
||||
|
|
@ -173,36 +178,11 @@ mod test {
|
|||
cx.simulate_keystrokes("p");
|
||||
cx.assert_state(
|
||||
indoc! {"
|
||||
The quic«clipboardˇ»k brown
|
||||
The quiˇck brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
|
||||
// Multiple cursors with system clipboard (no metadata) pastes
|
||||
// the same text at each cursor.
|
||||
cx.set_state(
|
||||
indoc! {"
|
||||
ˇThe quick brown
|
||||
fox ˇjumps over
|
||||
the lazy dog."},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("hi".to_string()));
|
||||
cx.simulate_keystrokes("p");
|
||||
cx.assert_state(
|
||||
indoc! {"
|
||||
T«hiˇ»he quick brown
|
||||
fox j«hiˇ»umps over
|
||||
the lazy dog."},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
|
||||
// Multiple cursors on empty lines should paste on those same lines.
|
||||
cx.set_state("ˇ\nˇ\nˇ\nend", Mode::HelixNormal);
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("X".to_string()));
|
||||
cx.simulate_keystrokes("p");
|
||||
cx.assert_state("«Xˇ»\n«Xˇ»\n«Xˇ»\nend", Mode::HelixNormal);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
|
|||
|
|
@ -155,6 +155,10 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|||
})
|
||||
})
|
||||
});
|
||||
// Plain `d` yanks to register `"` only, never the system clipboard
|
||||
if vim.selected_register.is_none() {
|
||||
vim.selected_register = Some('"');
|
||||
}
|
||||
vim.visual_delete(false, window, cx);
|
||||
vim.switch_mode(Mode::HelixNormal, true, window, cx);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -905,6 +905,11 @@ impl VimGlobals {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reads the default register `"` literally, ignoring `use_system_clipboard`
|
||||
pub(crate) fn read_default_register(&self) -> Option<Register> {
|
||||
self.registers.get(&'"').cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn read_register(
|
||||
&self,
|
||||
register: Option<char>,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ use futures::{StreamExt, channel::mpsc, select_biased};
|
|||
use git_ui::commit_view::CommitViewToolbar;
|
||||
use git_ui::git_panel::GitPanel;
|
||||
use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar};
|
||||
use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
|
||||
use gpui::{
|
||||
Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
|
||||
Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
|
||||
|
|
@ -1305,6 +1306,8 @@ fn initialize_pane(
|
|||
pane.toolbar().update(cx, |toolbar, cx| {
|
||||
let multibuffer_hint = cx.new(|_| MultibufferHint::new());
|
||||
toolbar.add_item(multibuffer_hint, window, cx);
|
||||
let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
|
||||
toolbar.add_item(solo_diff_style_toolbar, window, cx);
|
||||
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
|
||||
toolbar.add_item(breadcrumbs, window, cx);
|
||||
let buffer_search_bar = cx.new(|cx| {
|
||||
|
|
@ -1343,6 +1346,8 @@ fn initialize_pane(
|
|||
toolbar.add_item(project_diff_toolbar, window, cx);
|
||||
let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
|
||||
toolbar.add_item(branch_diff_toolbar, window, cx);
|
||||
let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
|
||||
toolbar.add_item(solo_diff_git_toolbar, window, cx);
|
||||
let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
|
||||
toolbar.add_item(commit_view_toolbar, window, cx);
|
||||
let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ dependencies = [
|
|||
"typer>=0.15.1",
|
||||
"types-pytz>=2025.1.0.20250204",
|
||||
"types-requests>=2.32.0",
|
||||
"urllib3>=2.7.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
|
|
@ -142,9 +142,9 @@ dependencies = [
|
|||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -252,6 +252,7 @@ dependencies = [
|
|||
{ name = "typer" },
|
||||
{ name = "types-pytz" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
|
@ -263,13 +264,14 @@ requires-dist = [
|
|||
{ name = "typer", specifier = ">=0.15.1" },
|
||||
{ name = "types-pytz", specifier = ">=2025.1.0.20250204" },
|
||||
{ name = "types-requests", specifier = ">=2.32.0" },
|
||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.2.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue