mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Compare commits
7 commits
432be43635
...
c4de1299b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4de1299b4 | ||
|
|
09165c15dc | ||
|
|
e2e7a6769e | ||
|
|
5d3b9e467e | ||
|
|
06826ef10f | ||
|
|
7f4a99aa95 | ||
|
|
a1907cc7cf |
16 changed files with 1103 additions and 106 deletions
|
|
@ -74,9 +74,10 @@ pub use crate::agent_panel::{
|
|||
};
|
||||
use crate::agent_registry_ui::AgentRegistryPage;
|
||||
pub use crate::inline_assistant::InlineAssistant;
|
||||
pub use crate::message_editor::MessageEditorEvent;
|
||||
pub use crate::thread_metadata_store::ThreadId;
|
||||
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
|
||||
pub use conversation_view::ConversationView;
|
||||
pub use conversation_view::{ConversationView, StateChange};
|
||||
pub use external_source_prompt::ExternalSourcePrompt;
|
||||
pub(crate) use mode_selector::ModeSelector;
|
||||
pub(crate) use model_selector::ModelSelector;
|
||||
|
|
|
|||
|
|
@ -453,6 +453,10 @@ pub(crate) struct RootThreadUpdated;
|
|||
|
||||
impl EventEmitter<RootThreadUpdated> for ConversationView {}
|
||||
|
||||
pub struct StateChange;
|
||||
|
||||
impl EventEmitter<StateChange> for ConversationView {}
|
||||
|
||||
fn resolve_outcome_from_selection(
|
||||
options: &PermissionOptions,
|
||||
selection: Option<&thread_view::PermissionSelection>,
|
||||
|
|
@ -823,6 +827,7 @@ impl ConversationView {
|
|||
}
|
||||
|
||||
self.server_state = state;
|
||||
cx.emit(StateChange);
|
||||
cx.emit(AcpServerViewEvent::ActiveThreadChanged);
|
||||
if matches!(&self.server_state, ServerState::Connected(_)) {
|
||||
cx.emit(RootThreadUpdated);
|
||||
|
|
@ -1330,6 +1335,7 @@ impl ConversationView {
|
|||
};
|
||||
if let Some(connected) = this.as_connected_mut() {
|
||||
connected.auth_state = auth_state;
|
||||
cx.emit(StateChange);
|
||||
if let Some(view) = connected.active_view()
|
||||
&& view
|
||||
.read(cx)
|
||||
|
|
@ -1824,6 +1830,7 @@ impl ConversationView {
|
|||
pending_auth_method.replace(method.clone());
|
||||
|
||||
let project = self.project.clone();
|
||||
cx.emit(StateChange);
|
||||
cx.notify();
|
||||
self.auth_task = Some(cx.spawn_in(window, {
|
||||
async move |this, cx| {
|
||||
|
|
@ -1869,6 +1876,7 @@ impl ConversationView {
|
|||
}) = this.as_connected_mut()
|
||||
{
|
||||
pending_auth_method.take();
|
||||
cx.emit(StateChange);
|
||||
}
|
||||
if let Some(active) = this.root_thread_view() {
|
||||
active.update(cx, |active, cx| {
|
||||
|
|
@ -1890,6 +1898,7 @@ impl ConversationView {
|
|||
pending_auth_method.replace(method.clone());
|
||||
|
||||
let authenticate = connection.authenticate(method, cx);
|
||||
cx.emit(StateChange);
|
||||
cx.notify();
|
||||
self.auth_task = Some(cx.spawn_in(window, {
|
||||
async move |this, cx| {
|
||||
|
|
@ -1917,6 +1926,7 @@ impl ConversationView {
|
|||
}) = this.as_connected_mut()
|
||||
{
|
||||
pending_auth_method.take();
|
||||
cx.emit(StateChange);
|
||||
}
|
||||
if let Some(active) = this.root_thread_view() {
|
||||
active.update(cx, |active, cx| active.handle_thread_error(err, cx));
|
||||
|
|
@ -3000,6 +3010,7 @@ impl ConversationView {
|
|||
pending_auth_method: None,
|
||||
_subscription: None,
|
||||
};
|
||||
cx.emit(StateChange);
|
||||
if let Some(view) = connected.active_view()
|
||||
&& view
|
||||
.read(cx)
|
||||
|
|
|
|||
|
|
@ -1008,6 +1008,7 @@ impl ThreadView {
|
|||
MessageEditorEvent::LostFocus => {}
|
||||
MessageEditorEvent::SlashAutocompleteOpened => {}
|
||||
MessageEditorEvent::InputAttempted { .. } => {}
|
||||
MessageEditorEvent::Edited => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1148,6 +1149,7 @@ impl ThreadView {
|
|||
}
|
||||
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => {
|
||||
}
|
||||
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Edited) => {}
|
||||
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {}
|
||||
ViewEvent::OpenDiffLocation {
|
||||
path,
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ pub enum MessageEditorEvent {
|
|||
Cancel,
|
||||
Focus,
|
||||
LostFocus,
|
||||
Edited,
|
||||
/// Emitted when the user opens slash-command autocomplete in this
|
||||
/// editor. Used by `ThreadView` to fire the global-skills scan
|
||||
/// trigger; see `NativeAgent::ensure_skills_scan_started`.
|
||||
|
|
@ -556,6 +557,7 @@ impl MessageEditor {
|
|||
if let EditorEvent::Edited { .. } = event
|
||||
&& !editor.read(cx).read_only(cx)
|
||||
{
|
||||
cx.emit(MessageEditorEvent::Edited);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
this.mention_set
|
||||
|
|
|
|||
|
|
@ -11474,8 +11474,11 @@ pub enum EditorEvent {
|
|||
RestoreRequested {
|
||||
hunks: Vec<MultiBufferDiffHunk>,
|
||||
},
|
||||
/// Emitted when an underlying buffer changes, including edits made through another editor.
|
||||
BufferEdited,
|
||||
/// Emitted when this editor creates, undoes, or redoes an edit transaction.
|
||||
Edited {
|
||||
/// The transaction that changed the editor's buffer.
|
||||
transaction_id: clock::Lamport,
|
||||
},
|
||||
Reparsed(BufferId),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use agent_ui::terminal_thread_metadata_store::{
|
|||
use agent_ui::thread_metadata_store::{
|
||||
ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths,
|
||||
};
|
||||
use agent_ui::thread_worktree_archive;
|
||||
use agent_ui::threads_archive_view::{
|
||||
ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp,
|
||||
fuzzy_match_positions,
|
||||
|
|
@ -21,6 +20,7 @@ use agent_ui::{
|
|||
NewThread, RenameSelectedThread, TerminalId, ThreadId, ThreadImportModal,
|
||||
channels_with_threads, import_threads_from_other_channels,
|
||||
};
|
||||
use agent_ui::{MessageEditorEvent, StateChange, thread_worktree_archive};
|
||||
use chrono::{DateTime, Utc};
|
||||
use editor::Editor;
|
||||
use feature_flags::{
|
||||
|
|
@ -686,6 +686,7 @@ pub struct Sidebar {
|
|||
project_header_menu_ix: Option<usize>,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
_draft_editor_observations: Vec<gpui::Subscription>,
|
||||
update_task: Option<Task<()>>,
|
||||
/// For the thread import banners, if there is just one we show "Import
|
||||
/// Threads" but if we are showing both the external agents and other
|
||||
/// channels import banners then we change the text to disambiguate the
|
||||
|
|
@ -720,15 +721,15 @@ impl Sidebar {
|
|||
MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => {
|
||||
this.sync_active_entry_from_active_workspace(cx);
|
||||
this.replace_archived_panel_thread(window, cx);
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
|
||||
this.subscribe_to_workspace(workspace, window, cx);
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
MultiWorkspaceEvent::WorkspaceRemoved(_)
|
||||
| MultiWorkspaceEvent::ProjectGroupsChanged => {
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -740,10 +741,7 @@ impl Sidebar {
|
|||
if !query.is_empty() {
|
||||
this.selection.take();
|
||||
}
|
||||
this.update_entries(cx);
|
||||
if !query.is_empty() {
|
||||
this.select_first_entry();
|
||||
}
|
||||
this.schedule_update_entries(!query.is_empty(), cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
|
@ -758,14 +756,14 @@ impl Sidebar {
|
|||
.detach();
|
||||
|
||||
cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| {
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.observe(
|
||||
&TerminalThreadMetadataStore::global(cx),
|
||||
|this, _store, cx| {
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
|
@ -778,7 +776,7 @@ impl Sidebar {
|
|||
this.subscribe_to_workspace(workspace, window, cx);
|
||||
}
|
||||
}
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
|
|
@ -808,6 +806,7 @@ impl Sidebar {
|
|||
project_header_menu_ix: None,
|
||||
_subscriptions: Vec::new(),
|
||||
_draft_editor_observations: Vec::new(),
|
||||
update_task: None,
|
||||
import_banners_use_verbose_labels: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -862,11 +861,11 @@ impl Sidebar {
|
|||
ProjectEvent::WorktreeAdded(_)
|
||||
| ProjectEvent::WorktreeRemoved(_)
|
||||
| ProjectEvent::WorktreeOrderChanged => {
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
|
||||
this.move_entry_paths(project, old_worktree_paths, cx);
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
|
@ -887,7 +886,7 @@ impl Sidebar {
|
|||
_,
|
||||
)
|
||||
) {
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -900,7 +899,7 @@ impl Sidebar {
|
|||
if let workspace::Event::PanelAdded(view) = event {
|
||||
if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
|
||||
this.subscribe_to_agent_panel(workspace, &agent_panel, window, cx);
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -992,7 +991,7 @@ impl Sidebar {
|
|||
| AgentPanelEvent::ActiveViewFocused
|
||||
| AgentPanelEvent::EntryChanged => {
|
||||
this.sync_active_entry_from_panel(agent_panel, cx);
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
AgentPanelEvent::TerminalClosed { metadata } => {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
|
|
@ -1002,7 +1001,7 @@ impl Sidebar {
|
|||
}
|
||||
AgentPanelEvent::ThreadInteracted { thread_id } => {
|
||||
this.record_thread_interacted(thread_id, cx);
|
||||
this.update_entries(cx);
|
||||
this.schedule_update_entries(false, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -1859,6 +1858,24 @@ impl Sidebar {
|
|||
};
|
||||
}
|
||||
|
||||
fn schedule_update_entries(&mut self, select_first_after_update: bool, cx: &mut Context<Self>) {
|
||||
if self.update_task.is_some() && !select_first_after_update {
|
||||
return;
|
||||
}
|
||||
|
||||
self.update_task = Some(cx.spawn(async move |this, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_task = None;
|
||||
this.update_entries(cx);
|
||||
if select_first_after_update {
|
||||
this.select_first_entry();
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Rebuilds the sidebar's visible entries from already-cached state.
|
||||
fn update_entries(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
|
|
@ -1940,8 +1957,8 @@ impl Sidebar {
|
|||
/// Re-establishes subscriptions to each visible draft's message editor
|
||||
/// so we rebuild entries (and their displayed titles) as the user types.
|
||||
fn refresh_draft_editor_observations(&mut self, cx: &mut Context<Self>) {
|
||||
self._draft_editor_observations.clear();
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
self._draft_editor_observations.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
@ -1952,23 +1969,27 @@ impl Sidebar {
|
|||
.flat_map(|panel| panel.read(cx).conversation_views())
|
||||
.collect();
|
||||
|
||||
let mut subscriptions = Vec::with_capacity(draft_conversation_views.len());
|
||||
for cv in draft_conversation_views {
|
||||
if let Some(thread_view) = cv.read(cx).active_thread() {
|
||||
let editor = thread_view.read(cx).message_editor.clone();
|
||||
subscriptions.push(cx.observe(&editor, |this, _editor, cx| {
|
||||
this.update_entries(cx);
|
||||
}));
|
||||
self._draft_editor_observations.push(cx.subscribe(
|
||||
&editor,
|
||||
|this, _editor, event, cx| match event {
|
||||
MessageEditorEvent::Edited => this.schedule_update_entries(false, cx),
|
||||
_ => (),
|
||||
},
|
||||
));
|
||||
}
|
||||
// Also observe the ConversationView itself so that editor
|
||||
// Also subscribe to the ConversationView itself so that editor
|
||||
// replacements during lifecycle transitions (Loading →
|
||||
// Connected) re-wire the editor observation above.
|
||||
subscriptions.push(cx.observe(&cv, |this, _cv, cx| {
|
||||
this.refresh_draft_editor_observations(cx);
|
||||
this.update_entries(cx);
|
||||
}));
|
||||
self._draft_editor_observations.push(cx.subscribe(
|
||||
&cv,
|
||||
|this, _cv, _event: &StateChange, cx| {
|
||||
this.schedule_update_entries(false, cx);
|
||||
},
|
||||
));
|
||||
}
|
||||
self._draft_editor_observations = subscriptions;
|
||||
}
|
||||
|
||||
fn select_first_entry(&mut self) {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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