Compare commits
12 Commits
debugger-c
...
soft-wrap-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8eaabf3bb | ||
|
|
81f8e2ed4a | ||
|
|
b9256dd469 | ||
|
|
27d3da678c | ||
|
|
03357f3f7b | ||
|
|
4aabba6cf6 | ||
|
|
8c46a4f594 | ||
|
|
522abe8e59 | ||
|
|
5ae8c4cf09 | ||
|
|
d8195a8fd7 | ||
|
|
2645591cd5 | ||
|
|
526a7c0702 |
1
assets/icons/list_todo.svg
Normal file
1
assets/icons/list_todo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-todo-icon lucide-list-todo"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -278,7 +278,9 @@
|
||||
"enter": "agent::Chat",
|
||||
"ctrl-enter": "agent::ChatWithFollow",
|
||||
"ctrl-i": "agent::ToggleProfileSelector",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff"
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -315,7 +315,9 @@
|
||||
"enter": "agent::Chat",
|
||||
"cmd-enter": "agent::ChatWithFollow",
|
||||
"cmd-i": "agent::ToggleProfileSelector",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff"
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1497,11 +1497,11 @@
|
||||
}
|
||||
},
|
||||
"LaTeX": {
|
||||
"format_on_save": "on",
|
||||
"formatter": "language_server",
|
||||
"language_servers": ["texlab", "..."],
|
||||
"prettier": {
|
||||
"allowed": false
|
||||
"allowed": true,
|
||||
"plugins": ["prettier-plugin-latex"]
|
||||
}
|
||||
},
|
||||
"Markdown": {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::context::{AgentContextHandle, RULES_ICON};
|
||||
use crate::context_picker::{ContextPicker, MentionLink};
|
||||
use crate::context_store::ContextStore;
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::message_editor::insert_message_creases;
|
||||
use crate::message_editor::{extract_message_creases, insert_message_creases};
|
||||
use crate::thread::{
|
||||
LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
|
||||
ThreadEvent, ThreadFeedback, ThreadSummary,
|
||||
@@ -1586,6 +1586,8 @@ impl ActiveThread {
|
||||
|
||||
let edited_text = state.editor.read(cx).text(cx);
|
||||
|
||||
let creases = state.editor.update(cx, extract_message_creases);
|
||||
|
||||
let new_context = self
|
||||
.context_store
|
||||
.read(cx)
|
||||
@@ -1610,6 +1612,7 @@ impl ActiveThread {
|
||||
message_id,
|
||||
Role::User,
|
||||
vec![MessageSegment::Text(edited_text)],
|
||||
creases,
|
||||
Some(context.loaded_context),
|
||||
checkpoint.ok(),
|
||||
cx,
|
||||
@@ -3677,10 +3680,13 @@ fn open_editor_at_position(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use assistant_tool::{ToolRegistry, ToolWorkingSet};
|
||||
use editor::EditorSettings;
|
||||
use editor::{EditorSettings, display_map::CreaseMetadata};
|
||||
use fs::FakeFs;
|
||||
use gpui::{AppContext, TestAppContext, VisualTestContext};
|
||||
use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModel, LanguageModelRegistry,
|
||||
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
|
||||
};
|
||||
use project::Project;
|
||||
use prompt_store::PromptBuilder;
|
||||
use serde_json::json;
|
||||
@@ -3741,6 +3747,87 @@ mod tests {
|
||||
assert!(!cx.read(|cx| workspace.read(cx).is_being_followed(CollaboratorId::Agent)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reinserting_creases_for_edited_message(cx: &mut TestAppContext) {
|
||||
init_test_settings(cx);
|
||||
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
|
||||
let (cx, active_thread, _, thread, model) =
|
||||
setup_test_environment(cx, project.clone()).await;
|
||||
cx.update(|_, cx| {
|
||||
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.set_default_model(
|
||||
Some(ConfiguredModel {
|
||||
provider: Arc::new(FakeLanguageModelProvider),
|
||||
model,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
let creases = vec![MessageCrease {
|
||||
range: 14..22,
|
||||
metadata: CreaseMetadata {
|
||||
icon_path: "icon".into(),
|
||||
label: "foo.txt".into(),
|
||||
},
|
||||
context: None,
|
||||
}];
|
||||
|
||||
let message = thread.update(cx, |thread, cx| {
|
||||
let message_id = thread.insert_user_message(
|
||||
"Tell me about @foo.txt",
|
||||
ContextLoadResult::default(),
|
||||
None,
|
||||
creases,
|
||||
cx,
|
||||
);
|
||||
thread.message(message_id).cloned().unwrap()
|
||||
});
|
||||
|
||||
active_thread.update_in(cx, |active_thread, window, cx| {
|
||||
active_thread.start_editing_message(
|
||||
message.id,
|
||||
message.segments.as_slice(),
|
||||
message.creases.as_slice(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let editor = active_thread
|
||||
.editing_message
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.1
|
||||
.editor
|
||||
.clone();
|
||||
editor.update(cx, |editor, cx| editor.edit([(0..13, "modified")], cx));
|
||||
active_thread.confirm_editing_message(&Default::default(), window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let message = thread.update(cx, |thread, _| thread.message(message.id).cloned().unwrap());
|
||||
active_thread.update_in(cx, |active_thread, window, cx| {
|
||||
active_thread.start_editing_message(
|
||||
message.id,
|
||||
message.segments.as_slice(),
|
||||
message.creases.as_slice(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let editor = active_thread
|
||||
.editing_message
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.1
|
||||
.editor
|
||||
.clone();
|
||||
let text = editor.update(cx, |editor, cx| editor.text(cx));
|
||||
assert_eq!(text, "modified @foo.txt");
|
||||
});
|
||||
}
|
||||
|
||||
fn init_test_settings(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
|
||||
@@ -926,8 +926,9 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
&self,
|
||||
buffer: &Entity<language::Buffer>,
|
||||
position: language::Anchor,
|
||||
_: &str,
|
||||
_: bool,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
let buffer = buffer.read(cx);
|
||||
|
||||
@@ -51,6 +51,10 @@ impl Tool for ContextServerTool {
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||
let mut schema = self.tool.input_schema.clone();
|
||||
assistant_tool::adapt_schema_to_format(&mut schema, format)?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::agent_model_selector::{AgentModelSelector, ModelType};
|
||||
use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context};
|
||||
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
|
||||
use crate::ui::{
|
||||
AnimatedLabel, MaxModeTooltip,
|
||||
MaxModeTooltip,
|
||||
preview::{AgentPreview, UsageCallout},
|
||||
};
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
@@ -27,7 +27,7 @@ use gpui::{
|
||||
Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription,
|
||||
Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
||||
};
|
||||
use language::{Buffer, Language};
|
||||
use language::{Buffer, Language, Point};
|
||||
use language_model::{
|
||||
ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage,
|
||||
ZED_CLOUD_PROVIDER_ID,
|
||||
@@ -51,9 +51,9 @@ use crate::profile_selector::ProfileSelector;
|
||||
use crate::thread::{MessageCrease, Thread, TokenUsageRatio};
|
||||
use crate::thread_store::{TextThreadStore, ThreadStore};
|
||||
use crate::{
|
||||
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread,
|
||||
OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector,
|
||||
register_agent_preview,
|
||||
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
|
||||
NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker,
|
||||
ToggleProfileSelector, register_agent_preview,
|
||||
};
|
||||
|
||||
#[derive(RegisterComponent)]
|
||||
@@ -459,11 +459,20 @@ impl MessageEditor {
|
||||
}
|
||||
|
||||
fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.thread.read(cx).has_pending_edit_tool_uses() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.edits_expanded = true;
|
||||
AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_edit_bar_expand(&mut self, cx: &mut Context<Self>) {
|
||||
self.edits_expanded = !self.edits_expanded;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_file_click(
|
||||
&self,
|
||||
buffer: Entity<Buffer>,
|
||||
@@ -494,6 +503,40 @@ impl MessageEditor {
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_accept_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.thread.read(cx).has_pending_edit_tool_uses() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.thread.update(cx, |thread, cx| {
|
||||
thread.keep_all_edits(cx);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_reject_all(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.thread.read(cx).has_pending_edit_tool_uses() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since there's no reject_all_edits method in the thread API,
|
||||
// we need to iterate through all buffers and reject their edits
|
||||
let action_log = self.thread.read(cx).action_log().clone();
|
||||
let changed_buffers = action_log.read(cx).changed_buffers(cx);
|
||||
|
||||
for (buffer, _) in changed_buffers {
|
||||
self.thread.update(cx, |thread, cx| {
|
||||
let buffer_snapshot = buffer.read(cx);
|
||||
let start = buffer_snapshot.anchor_before(Point::new(0, 0));
|
||||
let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point());
|
||||
thread
|
||||
.reject_edits_in_ranges(buffer, vec![start..end], cx)
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_max_mode_toggle(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let thread = self.thread.read(cx);
|
||||
let model = thread.configured_model();
|
||||
@@ -615,6 +658,12 @@ impl MessageEditor {
|
||||
.on_action(cx.listener(Self::move_up))
|
||||
.on_action(cx.listener(Self::expand_message_editor))
|
||||
.on_action(cx.listener(Self::toggle_burn_mode))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &KeepAll, window, cx| this.handle_accept_all(window, cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)),
|
||||
)
|
||||
.capture_action(cx.listener(Self::paste))
|
||||
.gap_2()
|
||||
.p_2()
|
||||
@@ -870,7 +919,10 @@ impl MessageEditor {
|
||||
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
|
||||
|
||||
let is_edit_changes_expanded = self.edits_expanded;
|
||||
let is_generating = self.thread.read(cx).is_generating();
|
||||
let thread = self.thread.read(cx);
|
||||
let pending_edits = thread.has_pending_edit_tool_uses();
|
||||
|
||||
const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete.";
|
||||
|
||||
v_flex()
|
||||
.mt_1()
|
||||
@@ -888,31 +940,28 @@ impl MessageEditor {
|
||||
}])
|
||||
.child(
|
||||
h_flex()
|
||||
.id("edits-container")
|
||||
.cursor_pointer()
|
||||
.p_1p5()
|
||||
.p_1()
|
||||
.justify_between()
|
||||
.when(is_edit_changes_expanded, |this| {
|
||||
this.border_b_1().border_color(border_color)
|
||||
})
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.id("edits-container")
|
||||
.cursor_pointer()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.child(
|
||||
Disclosure::new("edits-disclosure", is_edit_changes_expanded)
|
||||
.on_click(cx.listener(|this, _ev, _window, cx| {
|
||||
this.edits_expanded = !this.edits_expanded;
|
||||
cx.notify();
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.handle_edit_bar_expand(cx)
|
||||
})),
|
||||
)
|
||||
.map(|this| {
|
||||
if is_generating {
|
||||
if pending_edits {
|
||||
this.child(
|
||||
AnimatedLabel::new(format!(
|
||||
"Editing {} {}",
|
||||
Label::new(format!(
|
||||
"Editing {} {}…",
|
||||
changed_buffers.len(),
|
||||
if changed_buffers.len() == 1 {
|
||||
"file"
|
||||
@@ -920,7 +969,15 @@ impl MessageEditor {
|
||||
"files"
|
||||
}
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small)
|
||||
.with_animation(
|
||||
"edit-label",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.3, 0.7)),
|
||||
|label, delta| label.alpha(delta),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
@@ -945,23 +1002,74 @@ impl MessageEditor {
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}
|
||||
}),
|
||||
})
|
||||
.on_click(
|
||||
cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("review", "Review Changes")
|
||||
.label_size(LabelSize::Small)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&OpenAgentDiff,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("review-changes", IconName::ListTodo)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Review Changes",
|
||||
&OpenAgentDiff,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.handle_review_click(window, cx)
|
||||
})),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.handle_review_click(window, cx)
|
||||
})),
|
||||
.child(ui::Divider::vertical().color(ui::DividerColor::Border))
|
||||
.child(
|
||||
Button::new("reject-all-changes", "Reject All")
|
||||
.label_size(LabelSize::Small)
|
||||
.disabled(pending_edits)
|
||||
.when(pending_edits, |this| {
|
||||
this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
|
||||
})
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&RejectAll,
|
||||
&focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(10.))),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.handle_reject_all(window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("accept-all-changes", "Accept All")
|
||||
.label_size(LabelSize::Small)
|
||||
.disabled(pending_edits)
|
||||
.when(pending_edits, |this| {
|
||||
this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL))
|
||||
})
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&KeepAll,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(10.))),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.handle_accept_all(window, cx)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(is_edit_changes_expanded, |parent| {
|
||||
|
||||
@@ -871,7 +871,16 @@ impl Thread {
|
||||
self.tool_use
|
||||
.pending_tool_uses()
|
||||
.iter()
|
||||
.all(|tool_use| tool_use.status.is_error())
|
||||
.all(|pending_tool_use| pending_tool_use.status.is_error())
|
||||
}
|
||||
|
||||
/// Returns whether any pending tool uses may perform edits
|
||||
pub fn has_pending_edit_tool_uses(&self) -> bool {
|
||||
self.tool_use
|
||||
.pending_tool_uses()
|
||||
.iter()
|
||||
.filter(|pending_tool_use| !pending_tool_use.status.is_error())
|
||||
.any(|pending_tool_use| pending_tool_use.may_perform_edits)
|
||||
}
|
||||
|
||||
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
|
||||
@@ -1023,6 +1032,7 @@ impl Thread {
|
||||
id: MessageId,
|
||||
new_role: Role,
|
||||
new_segments: Vec<MessageSegment>,
|
||||
creases: Vec<MessageCrease>,
|
||||
loaded_context: Option<LoadedContext>,
|
||||
checkpoint: Option<GitStoreCheckpoint>,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -1032,6 +1042,7 @@ impl Thread {
|
||||
};
|
||||
message.role = new_role;
|
||||
message.segments = new_segments;
|
||||
message.creases = creases;
|
||||
if let Some(context) = loaded_context {
|
||||
message.loaded_context = context;
|
||||
}
|
||||
|
||||
@@ -70,13 +70,15 @@ impl Column for DataType {
|
||||
}
|
||||
}
|
||||
|
||||
const RULES_FILE_NAMES: [&'static str; 6] = [
|
||||
const RULES_FILE_NAMES: [&'static str; 8] = [
|
||||
".rules",
|
||||
".cursorrules",
|
||||
".windsurfrules",
|
||||
".clinerules",
|
||||
".github/copilot-instructions.md",
|
||||
"CLAUDE.md",
|
||||
"AGENT.md",
|
||||
"AGENTS.md",
|
||||
];
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
|
||||
@@ -337,6 +337,12 @@ impl ToolUseState {
|
||||
)
|
||||
.into();
|
||||
|
||||
let may_perform_edits = self
|
||||
.tools
|
||||
.read(cx)
|
||||
.tool(&tool_use.name, cx)
|
||||
.is_some_and(|tool| tool.may_perform_edits());
|
||||
|
||||
self.pending_tool_uses_by_id.insert(
|
||||
tool_use.id.clone(),
|
||||
PendingToolUse {
|
||||
@@ -345,6 +351,7 @@ impl ToolUseState {
|
||||
name: tool_use.name.clone(),
|
||||
ui_text: ui_text.clone(),
|
||||
input: tool_use.input,
|
||||
may_perform_edits,
|
||||
status,
|
||||
},
|
||||
);
|
||||
@@ -518,6 +525,7 @@ pub struct PendingToolUse {
|
||||
pub ui_text: Arc<str>,
|
||||
pub input: serde_json::Value,
|
||||
pub status: PendingToolUseStatus,
|
||||
pub may_perform_edits: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -342,6 +342,7 @@ impl CompletionProvider for SlashCommandCompletionProvider {
|
||||
position: language::Anchor,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
let buffer = buffer.read(cx);
|
||||
|
||||
@@ -218,6 +218,9 @@ pub trait Tool: 'static + Send + Sync {
|
||||
/// before having permission to run.
|
||||
fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
|
||||
|
||||
/// Returns true if the tool may perform edits.
|
||||
fn may_perform_edits(&self) -> bool;
|
||||
|
||||
/// Returns the JSON schema that describes the tool's input.
|
||||
fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::Value::Object(serde_json::Map::default()))
|
||||
|
||||
@@ -48,6 +48,10 @@ impl Tool for CopyPathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./copy_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -33,12 +33,16 @@ impl Tool for CreateDirectoryTool {
|
||||
"create_directory".into()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./create_directory_tool/description.md").into()
|
||||
}
|
||||
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./create_directory_tool/description.md").into()
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
|
||||
@@ -37,6 +37,10 @@ impl Tool for DeletePathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./delete_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ impl Tool for DiagnosticsTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./diagnostics_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -129,6 +129,10 @@ impl Tool for EditFileTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("edit_file_tool/description.md").to_string()
|
||||
}
|
||||
|
||||
@@ -118,7 +118,11 @@ impl Tool for FetchTool {
|
||||
}
|
||||
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
|
||||
@@ -59,6 +59,10 @@ impl Tool for FindPathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./find_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ impl Tool for GrepTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./grep_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ impl Tool for ListDirectoryTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./list_directory_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ impl Tool for MovePathTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./move_path_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ impl Tool for NowTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ impl Tool for OpenTool {
|
||||
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn description(&self) -> String {
|
||||
include_str!("./open_tool/description.md").to_string()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ impl Tool for ReadFileTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./read_file_tool/description.md").into()
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ impl Tool for TerminalTool {
|
||||
true
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./terminal_tool/description.md").to_string()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ impl Tool for ThinkingTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
include_str!("./thinking_tool/description.md").to_string()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ impl Tool for WebSearchTool {
|
||||
false
|
||||
}
|
||||
|
||||
fn may_perform_edits(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into()
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ impl CompletionProvider for MessageEditorCompletionProvider {
|
||||
_position: language::Anchor,
|
||||
text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
text == "@"
|
||||
|
||||
@@ -309,6 +309,7 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
|
||||
_position: language::Anchor,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
_cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
true
|
||||
|
||||
@@ -194,6 +194,7 @@ pub enum ContextMenuOrigin {
|
||||
|
||||
pub struct CompletionsMenu {
|
||||
pub id: CompletionId,
|
||||
pub source: CompletionsMenuSource,
|
||||
sort_completions: bool,
|
||||
pub initial_position: Anchor,
|
||||
pub initial_query: Option<Arc<String>>,
|
||||
@@ -208,7 +209,6 @@ pub struct CompletionsMenu {
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
resolve_completions: bool,
|
||||
show_completion_documentation: bool,
|
||||
pub(super) ignore_completion_provider: bool,
|
||||
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
|
||||
markdown_cache: Rc<RefCell<VecDeque<(MarkdownCacheKey, Entity<Markdown>)>>>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
@@ -227,6 +227,13 @@ enum MarkdownCacheKey {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CompletionsMenuSource {
|
||||
Normal,
|
||||
SnippetChoices,
|
||||
Words,
|
||||
}
|
||||
|
||||
// TODO: There should really be a wrapper around fuzzy match tasks that does this.
|
||||
impl Drop for CompletionsMenu {
|
||||
fn drop(&mut self) {
|
||||
@@ -237,9 +244,9 @@ impl Drop for CompletionsMenu {
|
||||
impl CompletionsMenu {
|
||||
pub fn new(
|
||||
id: CompletionId,
|
||||
source: CompletionsMenuSource,
|
||||
sort_completions: bool,
|
||||
show_completion_documentation: bool,
|
||||
ignore_completion_provider: bool,
|
||||
initial_position: Anchor,
|
||||
initial_query: Option<Arc<String>>,
|
||||
is_incomplete: bool,
|
||||
@@ -258,13 +265,13 @@ impl CompletionsMenu {
|
||||
|
||||
let completions_menu = Self {
|
||||
id,
|
||||
source,
|
||||
sort_completions,
|
||||
initial_position,
|
||||
initial_query,
|
||||
is_incomplete,
|
||||
buffer,
|
||||
show_completion_documentation,
|
||||
ignore_completion_provider,
|
||||
completions: RefCell::new(completions).into(),
|
||||
match_candidates,
|
||||
entries: Rc::new(RefCell::new(Box::new([]))),
|
||||
@@ -328,6 +335,7 @@ impl CompletionsMenu {
|
||||
.collect();
|
||||
Self {
|
||||
id,
|
||||
source: CompletionsMenuSource::SnippetChoices,
|
||||
sort_completions,
|
||||
initial_position: selection.start,
|
||||
initial_query: None,
|
||||
@@ -342,7 +350,6 @@ impl CompletionsMenu {
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
resolve_completions: false,
|
||||
show_completion_documentation: false,
|
||||
ignore_completion_provider: false,
|
||||
last_rendered_range: RefCell::new(None).into(),
|
||||
markdown_cache: RefCell::new(VecDeque::new()).into(),
|
||||
language_registry: None,
|
||||
|
||||
@@ -2512,7 +2512,9 @@ pub mod tests {
|
||||
cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
|
||||
[
|
||||
("fn \n".to_string(), None),
|
||||
("oute\nr".to_string(), Some(Hsla::blue())),
|
||||
("oute".to_string(), Some(Hsla::blue())),
|
||||
("\n".to_string(), None),
|
||||
("r".to_string(), Some(Hsla::blue())),
|
||||
("() \n{}\n\n".to_string(), None),
|
||||
]
|
||||
);
|
||||
@@ -2535,8 +2537,11 @@ pub mod tests {
|
||||
[
|
||||
("out".to_string(), Some(Hsla::blue())),
|
||||
("⋯\n".to_string(), None),
|
||||
(" \nfn ".to_string(), Some(Hsla::red())),
|
||||
("i\n".to_string(), Some(Hsla::blue()))
|
||||
(" ".to_string(), Some(Hsla::red())),
|
||||
("\n".to_string(), None),
|
||||
("fn ".to_string(), Some(Hsla::red())),
|
||||
("i".to_string(), Some(Hsla::blue())),
|
||||
("\n".to_string(), None)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -933,7 +933,7 @@ impl<'a> Iterator for WrapChunks<'a> {
|
||||
self.transforms.next(&());
|
||||
return Some(Chunk {
|
||||
text: &display_text[start_ix..end_ix],
|
||||
..self.input_chunk.clone()
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -211,8 +211,11 @@ use workspace::{
|
||||
searchable::SearchEvent,
|
||||
};
|
||||
|
||||
use crate::hover_links::{find_url, find_url_from_range};
|
||||
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
|
||||
use crate::{
|
||||
code_context_menus::CompletionsMenuSource,
|
||||
hover_links::{find_url, find_url_from_range},
|
||||
};
|
||||
|
||||
pub const FILE_HEADER_HEIGHT: u32 = 2;
|
||||
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
|
||||
@@ -4510,30 +4513,40 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ignore_completion_provider = self
|
||||
let completions_source = self
|
||||
.context_menu
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|menu| match menu {
|
||||
CodeContextMenu::Completions(completions_menu) => {
|
||||
completions_menu.ignore_completion_provider
|
||||
}
|
||||
CodeContextMenu::CodeActions(_) => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
.and_then(|menu| match menu {
|
||||
CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source),
|
||||
CodeContextMenu::CodeActions(_) => None,
|
||||
});
|
||||
|
||||
if ignore_completion_provider {
|
||||
self.show_word_completions(&ShowWordCompletions, window, cx);
|
||||
} else if self.is_completion_trigger(text, trigger_in_words, cx) {
|
||||
self.show_completions(
|
||||
&ShowCompletions {
|
||||
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
self.hide_context_menu(window, cx);
|
||||
match completions_source {
|
||||
Some(CompletionsMenuSource::Words) => {
|
||||
self.show_word_completions(&ShowWordCompletions, window, cx)
|
||||
}
|
||||
Some(CompletionsMenuSource::Normal)
|
||||
| Some(CompletionsMenuSource::SnippetChoices)
|
||||
| None
|
||||
if self.is_completion_trigger(
|
||||
text,
|
||||
trigger_in_words,
|
||||
completions_source.is_some(),
|
||||
cx,
|
||||
) =>
|
||||
{
|
||||
self.show_completions(
|
||||
&ShowCompletions {
|
||||
trigger: Some(text.to_owned()).filter(|x| !x.is_empty()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
self.hide_context_menu(window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4541,6 +4554,7 @@ impl Editor {
|
||||
&self,
|
||||
text: &str,
|
||||
trigger_in_words: bool,
|
||||
menu_is_open: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let position = self.selections.newest_anchor().head();
|
||||
@@ -4558,6 +4572,7 @@ impl Editor {
|
||||
position.text_anchor,
|
||||
text,
|
||||
trigger_in_words,
|
||||
menu_is_open,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
@@ -5008,7 +5023,7 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.open_or_update_completions_menu(true, None, window, cx);
|
||||
self.open_or_update_completions_menu(Some(CompletionsMenuSource::Words), None, window, cx);
|
||||
}
|
||||
|
||||
pub fn show_completions(
|
||||
@@ -5017,12 +5032,12 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.open_or_update_completions_menu(false, options.trigger.as_deref(), window, cx);
|
||||
self.open_or_update_completions_menu(None, options.trigger.as_deref(), window, cx);
|
||||
}
|
||||
|
||||
fn open_or_update_completions_menu(
|
||||
&mut self,
|
||||
ignore_completion_provider: bool,
|
||||
requested_source: Option<CompletionsMenuSource>,
|
||||
trigger: Option<&str>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -5047,10 +5062,13 @@ impl Editor {
|
||||
Self::completion_query(&self.buffer.read(cx).read(cx), position)
|
||||
.map(|query| query.into());
|
||||
|
||||
let provider = if ignore_completion_provider {
|
||||
None
|
||||
} else {
|
||||
self.completion_provider.clone()
|
||||
let provider = match requested_source {
|
||||
Some(CompletionsMenuSource::Normal) | None => self.completion_provider.clone(),
|
||||
Some(CompletionsMenuSource::Words) => None,
|
||||
Some(CompletionsMenuSource::SnippetChoices) => {
|
||||
log::error!("bug: SnippetChoices requested_source is not handled");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let sort_completions = provider
|
||||
@@ -5106,14 +5124,15 @@ impl Editor {
|
||||
trigger_kind,
|
||||
};
|
||||
|
||||
let (replace_range, word_kind) = buffer_snapshot.surrounding_word(buffer_position);
|
||||
let (replace_range, word_to_exclude) = if word_kind == Some(CharKind::Word) {
|
||||
let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) =
|
||||
buffer_snapshot.surrounding_word(buffer_position)
|
||||
{
|
||||
let word_to_exclude = buffer_snapshot
|
||||
.text_for_range(replace_range.clone())
|
||||
.text_for_range(word_range.clone())
|
||||
.collect::<String>();
|
||||
(
|
||||
buffer_snapshot.anchor_before(replace_range.start)
|
||||
..buffer_snapshot.anchor_after(replace_range.end),
|
||||
buffer_snapshot.anchor_before(word_range.start)
|
||||
..buffer_snapshot.anchor_after(buffer_position),
|
||||
Some(word_to_exclude),
|
||||
)
|
||||
} else {
|
||||
@@ -5221,7 +5240,7 @@ impl Editor {
|
||||
words.remove(&lsp_completion.new_text);
|
||||
}
|
||||
completions.extend(words.into_iter().map(|(word, word_range)| Completion {
|
||||
replace_range: replace_range.clone(),
|
||||
replace_range: word_replace_range.clone(),
|
||||
new_text: word.clone(),
|
||||
label: CodeLabel::plain(word, None),
|
||||
icon_path: None,
|
||||
@@ -5245,9 +5264,9 @@ impl Editor {
|
||||
.map(|workspace| workspace.read(cx).app_state().languages.clone());
|
||||
let menu = CompletionsMenu::new(
|
||||
id,
|
||||
requested_source.unwrap_or(CompletionsMenuSource::Normal),
|
||||
sort_completions,
|
||||
show_completion_documentation,
|
||||
ignore_completion_provider,
|
||||
position,
|
||||
query.clone(),
|
||||
is_incomplete,
|
||||
@@ -5531,14 +5550,12 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
let mut common_prefix_len = 0;
|
||||
for (a, b) in old_text.chars().zip(new_text.chars()) {
|
||||
if a == b {
|
||||
common_prefix_len += a.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let common_prefix_len = old_text
|
||||
.chars()
|
||||
.zip(new_text.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(a, _)| a.len_utf8())
|
||||
.sum::<usize>();
|
||||
|
||||
cx.emit(EditorEvent::InputHandled {
|
||||
utf16_range_to_replace: None,
|
||||
@@ -20294,6 +20311,7 @@ pub trait CompletionProvider {
|
||||
position: language::Anchor,
|
||||
text: &str,
|
||||
trigger_in_words: bool,
|
||||
menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool;
|
||||
|
||||
@@ -20611,6 +20629,7 @@ impl CompletionProvider for Entity<Project> {
|
||||
position: language::Anchor,
|
||||
text: &str,
|
||||
trigger_in_words: bool,
|
||||
menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
let mut chars = text.chars();
|
||||
@@ -20625,7 +20644,7 @@ impl CompletionProvider for Entity<Project> {
|
||||
|
||||
let buffer = buffer.read(cx);
|
||||
let snapshot = buffer.snapshot();
|
||||
if !snapshot.settings_at(position, cx).show_completions_on_input {
|
||||
if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input {
|
||||
return false;
|
||||
}
|
||||
let classifier = snapshot.char_classifier_at(position).for_completion(true);
|
||||
|
||||
@@ -7618,10 +7618,7 @@ impl Element for EditorElement {
|
||||
editor.gutter_dimensions = gutter_dimensions;
|
||||
editor.set_visible_line_count(bounds.size.height / line_height, window, cx);
|
||||
|
||||
if matches!(
|
||||
editor.mode,
|
||||
EditorMode::AutoHeight { .. } | EditorMode::Minimap { .. }
|
||||
) {
|
||||
if matches!(editor.mode, EditorMode::Minimap { .. }) {
|
||||
snapshot
|
||||
} else {
|
||||
let wrap_width_for = |column: u32| (column as f32 * em_advance).ceil();
|
||||
@@ -9642,6 +9639,7 @@ fn compute_auto_height_layout(
|
||||
let font_size = style.text.font_size.to_pixels(window.rem_size());
|
||||
let line_height = style.text.line_height_in_pixels(window.rem_size());
|
||||
let em_width = window.text_system().em_width(font_id, font_size).unwrap();
|
||||
let em_advance = window.text_system().em_advance(font_id, font_size).unwrap();
|
||||
|
||||
let mut snapshot = editor.snapshot(window, cx);
|
||||
let gutter_dimensions = snapshot
|
||||
@@ -9658,10 +9656,18 @@ fn compute_auto_height_layout(
|
||||
let overscroll = size(em_width, px(0.));
|
||||
|
||||
let editor_width = text_width - gutter_dimensions.margin - overscroll.width - em_width;
|
||||
if !matches!(editor.soft_wrap_mode(cx), SoftWrap::None) {
|
||||
if editor.set_wrap_width(Some(editor_width), cx) {
|
||||
snapshot = editor.snapshot(window, cx);
|
||||
}
|
||||
let content_offset = point(gutter_dimensions.margin, Pixels::ZERO);
|
||||
let editor_content_width = editor_width - content_offset.x;
|
||||
let wrap_width_for = |column: u32| (column as f32 * em_advance).ceil();
|
||||
let wrap_width = match editor.soft_wrap_mode(cx) {
|
||||
SoftWrap::GitDiff => None,
|
||||
SoftWrap::None => Some(wrap_width_for(MAX_LINE_LEN as u32 / 2)),
|
||||
SoftWrap::EditorWidth => Some(editor_content_width),
|
||||
SoftWrap::Column(column) => Some(wrap_width_for(column)),
|
||||
SoftWrap::Bounded(column) => Some(editor_content_width.min(wrap_width_for(column))),
|
||||
};
|
||||
if editor.set_wrap_width(wrap_width, cx) {
|
||||
snapshot = editor.snapshot(window, cx);
|
||||
}
|
||||
|
||||
let scroll_height = (snapshot.max_point().row().next_row().0 as f32) * line_height;
|
||||
|
||||
@@ -101,7 +101,10 @@ pub fn init(cx: &mut App) {
|
||||
directories: true,
|
||||
multiple: false,
|
||||
},
|
||||
DirectoryLister::Local(workspace.app_state().fs.clone()),
|
||||
DirectoryLister::Local(
|
||||
workspace.project().clone(),
|
||||
workspace.app_state().fs.clone(),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ mod file_finder_tests;
|
||||
mod open_path_prompt_tests;
|
||||
|
||||
pub mod file_finder_settings;
|
||||
mod new_path_prompt;
|
||||
mod open_path_prompt;
|
||||
|
||||
use futures::future::join_all;
|
||||
@@ -20,7 +19,6 @@ use gpui::{
|
||||
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
|
||||
Window, actions,
|
||||
};
|
||||
use new_path_prompt::NewPathPrompt;
|
||||
use open_path_prompt::OpenPathPrompt;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||
@@ -85,8 +83,8 @@ pub fn init_settings(cx: &mut App) {
|
||||
pub fn init(cx: &mut App) {
|
||||
init_settings(cx);
|
||||
cx.observe_new(FileFinder::register).detach();
|
||||
cx.observe_new(NewPathPrompt::register).detach();
|
||||
cx.observe_new(OpenPathPrompt::register).detach();
|
||||
cx.observe_new(OpenPathPrompt::register_new_path).detach();
|
||||
}
|
||||
|
||||
impl FileFinder {
|
||||
|
||||
@@ -1,526 +0,0 @@
|
||||
use futures::channel::oneshot;
|
||||
use fuzzy::PathMatch;
|
||||
use gpui::{Entity, HighlightStyle, StyledText};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{self, AtomicBool},
|
||||
},
|
||||
};
|
||||
use ui::{Context, ListItem, Window};
|
||||
use ui::{LabelLike, ListItemSpacing, highlight_ranges, prelude::*};
|
||||
use util::ResultExt;
|
||||
use workspace::Workspace;
|
||||
|
||||
pub(crate) struct NewPathPrompt;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Match {
|
||||
path_match: Option<PathMatch>,
|
||||
suffix: Option<String>,
|
||||
}
|
||||
|
||||
impl Match {
|
||||
fn entry<'a>(&'a self, project: &'a Project, cx: &'a App) -> Option<&'a Entry> {
|
||||
if let Some(suffix) = &self.suffix {
|
||||
let (worktree, path) = if let Some(path_match) = &self.path_match {
|
||||
(
|
||||
project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
|
||||
path_match.path.join(suffix),
|
||||
)
|
||||
} else {
|
||||
(project.worktrees(cx).next(), PathBuf::from(suffix))
|
||||
};
|
||||
|
||||
worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
|
||||
} else if let Some(path_match) = &self.path_match {
|
||||
let worktree =
|
||||
project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
|
||||
worktree.read(cx).entry_for_path(path_match.path.as_ref())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_dir(&self, project: &Project, cx: &App) -> bool {
|
||||
self.entry(project, cx).is_some_and(|e| e.is_dir())
|
||||
|| self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
|
||||
}
|
||||
|
||||
fn relative_path(&self) -> String {
|
||||
if let Some(path_match) = &self.path_match {
|
||||
if let Some(suffix) = &self.suffix {
|
||||
format!(
|
||||
"{}/{}",
|
||||
path_match.path.to_string_lossy(),
|
||||
suffix.trim_end_matches('/')
|
||||
)
|
||||
} else {
|
||||
path_match.path.to_string_lossy().to_string()
|
||||
}
|
||||
} else if let Some(suffix) = &self.suffix {
|
||||
suffix.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn project_path(&self, project: &Project, cx: &App) -> Option<ProjectPath> {
|
||||
let worktree_id = if let Some(path_match) = &self.path_match {
|
||||
WorktreeId::from_usize(path_match.worktree_id)
|
||||
} else if let Some(worktree) = project.visible_worktrees(cx).find(|worktree| {
|
||||
worktree
|
||||
.read(cx)
|
||||
.root_entry()
|
||||
.is_some_and(|entry| entry.is_dir())
|
||||
}) {
|
||||
worktree.read(cx).id()
|
||||
} else {
|
||||
// todo(): we should find_or_create a workspace.
|
||||
return None;
|
||||
};
|
||||
|
||||
let path = PathBuf::from(self.relative_path());
|
||||
|
||||
Some(ProjectPath {
|
||||
worktree_id,
|
||||
path: Arc::from(path),
|
||||
})
|
||||
}
|
||||
|
||||
fn existing_prefix(&self, project: &Project, cx: &App) -> Option<PathBuf> {
|
||||
let worktree = project.worktrees(cx).next()?.read(cx);
|
||||
let mut prefix = PathBuf::new();
|
||||
let parts = self.suffix.as_ref()?.split('/');
|
||||
for part in parts {
|
||||
if worktree.entry_for_path(prefix.join(&part)).is_none() {
|
||||
return Some(prefix);
|
||||
}
|
||||
prefix = prefix.join(part);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn styled_text(&self, project: &Project, window: &Window, cx: &App) -> StyledText {
|
||||
let mut text = "./".to_string();
|
||||
let mut highlights = Vec::new();
|
||||
let mut offset = text.len();
|
||||
|
||||
let separator = '/';
|
||||
let dir_indicator = "[…]";
|
||||
|
||||
if let Some(path_match) = &self.path_match {
|
||||
text.push_str(&path_match.path.to_string_lossy());
|
||||
let mut whole_path = PathBuf::from(path_match.path_prefix.to_string());
|
||||
whole_path = whole_path.join(path_match.path.clone());
|
||||
for (range, style) in highlight_ranges(
|
||||
&whole_path.to_string_lossy(),
|
||||
&path_match.positions,
|
||||
gpui::HighlightStyle::color(Color::Accent.color(cx)),
|
||||
) {
|
||||
highlights.push((range.start + offset..range.end + offset, style))
|
||||
}
|
||||
text.push(separator);
|
||||
offset = text.len();
|
||||
|
||||
if let Some(suffix) = &self.suffix {
|
||||
text.push_str(suffix);
|
||||
let entry = self.entry(project, cx);
|
||||
let color = if let Some(entry) = entry {
|
||||
if entry.is_dir() {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Conflict
|
||||
}
|
||||
} else {
|
||||
Color::Created
|
||||
};
|
||||
highlights.push((
|
||||
offset..offset + suffix.len(),
|
||||
HighlightStyle::color(color.color(cx)),
|
||||
));
|
||||
offset += suffix.len();
|
||||
if entry.is_some_and(|e| e.is_dir()) {
|
||||
text.push(separator);
|
||||
offset += separator.len_utf8();
|
||||
|
||||
text.push_str(dir_indicator);
|
||||
highlights.push((
|
||||
offset..offset + dir_indicator.len(),
|
||||
HighlightStyle::color(Color::Muted.color(cx)),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
text.push_str(dir_indicator);
|
||||
highlights.push((
|
||||
offset..offset + dir_indicator.len(),
|
||||
HighlightStyle::color(Color::Muted.color(cx)),
|
||||
))
|
||||
}
|
||||
} else if let Some(suffix) = &self.suffix {
|
||||
text.push_str(suffix);
|
||||
let existing_prefix_len = self
|
||||
.existing_prefix(project, cx)
|
||||
.map(|prefix| prefix.to_string_lossy().len())
|
||||
.unwrap_or(0);
|
||||
|
||||
if existing_prefix_len > 0 {
|
||||
highlights.push((
|
||||
offset..offset + existing_prefix_len,
|
||||
HighlightStyle::color(Color::Accent.color(cx)),
|
||||
));
|
||||
}
|
||||
highlights.push((
|
||||
offset + existing_prefix_len..offset + suffix.len(),
|
||||
HighlightStyle::color(if self.entry(project, cx).is_some() {
|
||||
Color::Conflict.color(cx)
|
||||
} else {
|
||||
Color::Created.color(cx)
|
||||
}),
|
||||
));
|
||||
offset += suffix.len();
|
||||
if suffix.ends_with('/') {
|
||||
text.push_str(dir_indicator);
|
||||
highlights.push((
|
||||
offset..offset + dir_indicator.len(),
|
||||
HighlightStyle::color(Color::Muted.color(cx)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
StyledText::new(text).with_default_highlights(&window.text_style().clone(), highlights)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NewPathDelegate {
|
||||
project: Entity<Project>,
|
||||
tx: Option<oneshot::Sender<Option<ProjectPath>>>,
|
||||
selected_index: usize,
|
||||
matches: Vec<Match>,
|
||||
last_selected_dir: Option<String>,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
should_dismiss: bool,
|
||||
}
|
||||
|
||||
impl NewPathPrompt {
|
||||
pub(crate) fn register(
|
||||
workspace: &mut Workspace,
|
||||
_window: Option<&mut Window>,
|
||||
_cx: &mut Context<Workspace>,
|
||||
) {
|
||||
workspace.set_prompt_for_new_path(Box::new(|workspace, window, cx| {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
Self::prompt_for_new_path(workspace, tx, window, cx);
|
||||
rx
|
||||
}));
|
||||
}
|
||||
|
||||
fn prompt_for_new_path(
|
||||
workspace: &mut Workspace,
|
||||
tx: oneshot::Sender<Option<ProjectPath>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let project = workspace.project().clone();
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
let delegate = NewPathDelegate {
|
||||
project,
|
||||
tx: Some(tx),
|
||||
selected_index: 0,
|
||||
matches: vec![],
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
last_selected_dir: None,
|
||||
should_dismiss: true,
|
||||
};
|
||||
|
||||
Picker::uniform_list(delegate, window, cx).width(rems(34.))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PickerDelegate for NewPathDelegate {
|
||||
type ListItem = ui::ListItem;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_index
|
||||
}
|
||||
|
||||
fn set_selected_index(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<picker::Picker<Self>>,
|
||||
) {
|
||||
self.selected_index = ix;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn update_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<picker::Picker<Self>>,
|
||||
) -> gpui::Task<()> {
|
||||
let query = query
|
||||
.trim()
|
||||
.trim_start_matches("./")
|
||||
.trim_start_matches('/');
|
||||
|
||||
let (dir, suffix) = if let Some(index) = query.rfind('/') {
|
||||
let suffix = if index + 1 < query.len() {
|
||||
Some(query[index + 1..].to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(query[0..index].to_string(), suffix)
|
||||
} else {
|
||||
(query.to_string(), None)
|
||||
};
|
||||
|
||||
let worktrees = self
|
||||
.project
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.collect::<Vec<_>>();
|
||||
let include_root_name = worktrees.len() > 1;
|
||||
let candidate_sets = worktrees
|
||||
.into_iter()
|
||||
.map(|worktree| {
|
||||
let worktree = worktree.read(cx);
|
||||
PathMatchCandidateSet {
|
||||
snapshot: worktree.snapshot(),
|
||||
include_ignored: worktree
|
||||
.root_entry()
|
||||
.map_or(false, |entry| entry.is_ignored),
|
||||
include_root_name,
|
||||
candidates: project::Candidates::Directories,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
|
||||
self.cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let cancel_flag = self.cancel_flag.clone();
|
||||
let query = query.to_string();
|
||||
let prefix = dir.clone();
|
||||
cx.spawn_in(window, async move |picker, cx| {
|
||||
let matches = fuzzy::match_path_sets(
|
||||
candidate_sets.as_slice(),
|
||||
&dir,
|
||||
None,
|
||||
false,
|
||||
100,
|
||||
&cancel_flag,
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
|
||||
if did_cancel {
|
||||
return;
|
||||
}
|
||||
picker
|
||||
.update(cx, |picker, cx| {
|
||||
picker
|
||||
.delegate
|
||||
.set_search_matches(query, prefix, suffix, matches, cx)
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm_completion(
|
||||
&mut self,
|
||||
_: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
self.confirm_update_query(window, cx)
|
||||
}
|
||||
|
||||
fn confirm_update_query(
|
||||
&mut self,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
let m = self.matches.get(self.selected_index)?;
|
||||
if m.is_dir(self.project.read(cx), cx) {
|
||||
let path = m.relative_path();
|
||||
let result = format!("{}/", path);
|
||||
self.last_selected_dir = Some(path);
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
|
||||
let Some(m) = self.matches.get(self.selected_index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let exists = m.entry(self.project.read(cx), cx).is_some();
|
||||
if exists {
|
||||
self.should_dismiss = false;
|
||||
let answer = window.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
&format!("{} already exists. Do you want to replace it?", m.relative_path()),
|
||||
Some(
|
||||
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
|
||||
),
|
||||
&["Replace", "Cancel"],
|
||||
cx);
|
||||
let m = m.clone();
|
||||
cx.spawn_in(window, async move |picker, cx| {
|
||||
let answer = answer.await.ok();
|
||||
picker
|
||||
.update(cx, |picker, cx| {
|
||||
picker.delegate.should_dismiss = true;
|
||||
if answer != Some(0) {
|
||||
return;
|
||||
}
|
||||
if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
|
||||
if let Some(tx) = picker.delegate.tx.take() {
|
||||
tx.send(Some(path)).ok();
|
||||
}
|
||||
}
|
||||
cx.emit(gpui::DismissEvent);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(path) = m.project_path(self.project.read(cx), cx) {
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(Some(path)).ok();
|
||||
}
|
||||
}
|
||||
cx.emit(gpui::DismissEvent);
|
||||
}
|
||||
|
||||
fn should_dismiss(&self) -> bool {
|
||||
self.should_dismiss
|
||||
}
|
||||
|
||||
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(None).ok();
|
||||
}
|
||||
cx.emit(gpui::DismissEvent)
|
||||
}
|
||||
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<picker::Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let m = self.matches.get(ix)?;
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.child(LabelLike::new().child(m.styled_text(self.project.read(cx), window, cx))),
|
||||
)
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
Some("Type a path...".into())
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
Arc::from("[directory/]filename.ext")
|
||||
}
|
||||
}
|
||||
|
||||
impl NewPathDelegate {
|
||||
fn set_search_matches(
|
||||
&mut self,
|
||||
query: String,
|
||||
prefix: String,
|
||||
suffix: Option<String>,
|
||||
matches: Vec<PathMatch>,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
cx.notify();
|
||||
if query.is_empty() {
|
||||
self.matches = self
|
||||
.project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.flat_map(|worktree| {
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
worktree
|
||||
.read(cx)
|
||||
.child_entries(Path::new(""))
|
||||
.filter_map(move |entry| {
|
||||
entry.is_dir().then(|| Match {
|
||||
path_match: Some(PathMatch {
|
||||
score: 1.0,
|
||||
positions: Default::default(),
|
||||
worktree_id: worktree_id.to_usize(),
|
||||
path: entry.path.clone(),
|
||||
path_prefix: "".into(),
|
||||
is_dir: entry.is_dir(),
|
||||
distance_to_relative_ancestor: 0,
|
||||
}),
|
||||
suffix: None,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let mut directory_exists = false;
|
||||
|
||||
self.matches = matches
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
if m.path.as_ref().to_string_lossy() == prefix {
|
||||
directory_exists = true
|
||||
}
|
||||
Match {
|
||||
path_match: Some(m),
|
||||
suffix: suffix.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !directory_exists {
|
||||
if suffix.is_none()
|
||||
|| self
|
||||
.last_selected_dir
|
||||
.as_ref()
|
||||
.is_some_and(|d| query.starts_with(d))
|
||||
{
|
||||
self.matches.insert(
|
||||
0,
|
||||
Match {
|
||||
path_match: None,
|
||||
suffix: Some(query.clone()),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
self.matches.push(Match {
|
||||
path_match: None,
|
||||
suffix: Some(query.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::file_finder_settings::FileFinderSettings;
|
||||
use file_icons::FileIcons;
|
||||
use futures::channel::oneshot;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
use gpui::{HighlightStyle, StyledText, Task};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{DirectoryItem, DirectoryLister};
|
||||
use settings::Settings;
|
||||
@@ -12,61 +13,136 @@ use std::{
|
||||
atomic::{self, AtomicBool},
|
||||
},
|
||||
};
|
||||
use ui::{Context, ListItem, Window};
|
||||
use ui::{Context, LabelLike, ListItem, Window};
|
||||
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
|
||||
use util::{maybe, paths::compare_paths};
|
||||
use workspace::Workspace;
|
||||
|
||||
pub(crate) struct OpenPathPrompt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const PROMPT_ROOT: &str = "C:\\";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const PROMPT_ROOT: &str = "/";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OpenPathDelegate {
|
||||
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
|
||||
lister: DirectoryLister,
|
||||
selected_index: usize,
|
||||
directory_state: Option<DirectoryState>,
|
||||
matches: Vec<usize>,
|
||||
directory_state: DirectoryState,
|
||||
string_matches: Vec<StringMatch>,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
should_dismiss: bool,
|
||||
replace_prompt: Task<()>,
|
||||
}
|
||||
|
||||
impl OpenPathDelegate {
|
||||
pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
|
||||
pub fn new(
|
||||
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
||||
lister: DirectoryLister,
|
||||
creating_path: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
tx: Some(tx),
|
||||
lister,
|
||||
selected_index: 0,
|
||||
directory_state: None,
|
||||
matches: Vec::new(),
|
||||
directory_state: DirectoryState::None {
|
||||
create: creating_path,
|
||||
},
|
||||
string_matches: Vec::new(),
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
should_dismiss: true,
|
||||
replace_prompt: Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
|
||||
match &self.directory_state {
|
||||
DirectoryState::List { entries, .. } => {
|
||||
let id = self.string_matches.get(selected_match_index)?.candidate_id;
|
||||
entries.iter().find(|entry| entry.path.id == id).cloned()
|
||||
}
|
||||
DirectoryState::Create {
|
||||
user_input,
|
||||
entries,
|
||||
..
|
||||
} => {
|
||||
let mut i = selected_match_index;
|
||||
if let Some(user_input) = user_input {
|
||||
if !user_input.exists || !user_input.is_dir {
|
||||
if i == 0 {
|
||||
return Some(CandidateInfo {
|
||||
path: user_input.file.clone(),
|
||||
is_dir: false,
|
||||
});
|
||||
} else {
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let id = self.string_matches.get(i)?.candidate_id;
|
||||
entries.iter().find(|entry| entry.path.id == id).cloned()
|
||||
}
|
||||
DirectoryState::None { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn collect_match_candidates(&self) -> Vec<String> {
|
||||
if let Some(state) = self.directory_state.as_ref() {
|
||||
self.matches
|
||||
match &self.directory_state {
|
||||
DirectoryState::List { entries, .. } => self
|
||||
.string_matches
|
||||
.iter()
|
||||
.filter_map(|&index| {
|
||||
state
|
||||
.match_candidates
|
||||
.get(index)
|
||||
.filter_map(|string_match| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.path.id == string_match.candidate_id)
|
||||
.map(|candidate| candidate.path.string.clone())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
.collect(),
|
||||
DirectoryState::Create {
|
||||
user_input,
|
||||
entries,
|
||||
..
|
||||
} => user_input
|
||||
.into_iter()
|
||||
.filter(|user_input| !user_input.exists || !user_input.is_dir)
|
||||
.map(|user_input| user_input.file.string.clone())
|
||||
.chain(self.string_matches.iter().filter_map(|string_match| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.path.id == string_match.candidate_id)
|
||||
.map(|candidate| candidate.path.string.clone())
|
||||
}))
|
||||
.collect(),
|
||||
DirectoryState::None { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DirectoryState {
|
||||
path: String,
|
||||
match_candidates: Vec<CandidateInfo>,
|
||||
error: Option<SharedString>,
|
||||
enum DirectoryState {
|
||||
List {
|
||||
parent_path: String,
|
||||
entries: Vec<CandidateInfo>,
|
||||
error: Option<SharedString>,
|
||||
},
|
||||
Create {
|
||||
parent_path: String,
|
||||
user_input: Option<UserInput>,
|
||||
entries: Vec<CandidateInfo>,
|
||||
},
|
||||
None {
|
||||
create: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UserInput {
|
||||
file: StringMatchCandidate,
|
||||
exists: bool,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -83,7 +159,19 @@ impl OpenPathPrompt {
|
||||
) {
|
||||
workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
Self::prompt_for_open_path(workspace, lister, tx, window, cx);
|
||||
Self::prompt_for_open_path(workspace, lister, false, tx, window, cx);
|
||||
rx
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn register_new_path(
|
||||
workspace: &mut Workspace,
|
||||
_window: Option<&mut Window>,
|
||||
_: &mut Context<Workspace>,
|
||||
) {
|
||||
workspace.set_prompt_for_new_path(Box::new(|workspace, lister, window, cx| {
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
Self::prompt_for_open_path(workspace, lister, true, tx, window, cx);
|
||||
rx
|
||||
}));
|
||||
}
|
||||
@@ -91,13 +179,13 @@ impl OpenPathPrompt {
|
||||
fn prompt_for_open_path(
|
||||
workspace: &mut Workspace,
|
||||
lister: DirectoryLister,
|
||||
creating_path: bool,
|
||||
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
let delegate = OpenPathDelegate::new(tx, lister.clone());
|
||||
|
||||
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
|
||||
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
|
||||
let query = lister.default_query(cx);
|
||||
picker.set_query(query, window, cx);
|
||||
@@ -110,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
type ListItem = ui::ListItem;
|
||||
|
||||
fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state {
|
||||
user_input
|
||||
.as_ref()
|
||||
.filter(|input| !input.exists || !input.is_dir)
|
||||
.into_iter()
|
||||
.count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.string_matches.len() + user_input
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
@@ -127,127 +224,196 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
query: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> gpui::Task<()> {
|
||||
let lister = self.lister.clone();
|
||||
let query_path = Path::new(&query);
|
||||
let last_item = query_path
|
||||
) -> Task<()> {
|
||||
let lister = &self.lister;
|
||||
let last_item = Path::new(&query)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
|
||||
(dir.to_string(), last_item)
|
||||
.to_string_lossy();
|
||||
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
|
||||
(dir.to_string(), last_item.into_owned())
|
||||
} else {
|
||||
(query, String::new())
|
||||
};
|
||||
|
||||
if dir == "" {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
dir = "/".to_string();
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dir = "C:\\".to_string();
|
||||
}
|
||||
dir = PROMPT_ROOT.to_string();
|
||||
}
|
||||
|
||||
let query = if self
|
||||
.directory_state
|
||||
.as_ref()
|
||||
.map_or(false, |s| s.path == dir)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(lister.list_directory(dir.clone(), cx))
|
||||
let query = match &self.directory_state {
|
||||
DirectoryState::List { parent_path, .. } => {
|
||||
if parent_path == &dir {
|
||||
None
|
||||
} else {
|
||||
Some(lister.list_directory(dir.clone(), cx))
|
||||
}
|
||||
}
|
||||
DirectoryState::Create {
|
||||
parent_path,
|
||||
user_input,
|
||||
..
|
||||
} => {
|
||||
if parent_path == &dir
|
||||
&& user_input.as_ref().map(|input| &input.file.string) == Some(&suffix)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(lister.list_directory(dir.clone(), cx))
|
||||
}
|
||||
}
|
||||
DirectoryState::None { .. } => Some(lister.list_directory(dir.clone(), cx)),
|
||||
};
|
||||
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
|
||||
self.cancel_flag.store(true, atomic::Ordering::Release);
|
||||
self.cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
let cancel_flag = self.cancel_flag.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some(query) = query {
|
||||
let paths = query.await;
|
||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
||||
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.update(cx, |this, _| {
|
||||
this.delegate.directory_state = Some(match paths {
|
||||
Ok(mut paths) => {
|
||||
if dir == "/" {
|
||||
paths.push(DirectoryItem {
|
||||
is_dir: true,
|
||||
path: Default::default(),
|
||||
});
|
||||
}
|
||||
if this
|
||||
.update(cx, |this, _| {
|
||||
let new_state = match &this.delegate.directory_state {
|
||||
DirectoryState::None { create: false }
|
||||
| DirectoryState::List { .. } => match paths {
|
||||
Ok(paths) => DirectoryState::List {
|
||||
entries: path_candidates(&dir, paths),
|
||||
parent_path: dir.clone(),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => DirectoryState::List {
|
||||
entries: Vec::new(),
|
||||
parent_path: dir.clone(),
|
||||
error: Some(SharedString::from(e.to_string())),
|
||||
},
|
||||
},
|
||||
DirectoryState::None { create: true }
|
||||
| DirectoryState::Create { .. } => match paths {
|
||||
Ok(paths) => {
|
||||
let mut entries = path_candidates(&dir, paths);
|
||||
let mut exists = false;
|
||||
let mut is_dir = false;
|
||||
let mut new_id = None;
|
||||
entries.retain(|entry| {
|
||||
new_id = new_id.max(Some(entry.path.id));
|
||||
if entry.path.string == suffix {
|
||||
exists = true;
|
||||
is_dir = entry.is_dir;
|
||||
}
|
||||
!exists || is_dir
|
||||
});
|
||||
|
||||
paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
|
||||
let match_candidates = paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, item)| CandidateInfo {
|
||||
path: StringMatchCandidate::new(
|
||||
ix,
|
||||
&item.path.to_string_lossy(),
|
||||
),
|
||||
is_dir: item.is_dir,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
DirectoryState {
|
||||
match_candidates,
|
||||
path: dir,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(err) => DirectoryState {
|
||||
match_candidates: vec![],
|
||||
path: dir,
|
||||
error: Some(err.to_string().into()),
|
||||
},
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
let new_id = new_id.map(|id| id + 1).unwrap_or(0);
|
||||
let user_input = if suffix.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(UserInput {
|
||||
file: StringMatchCandidate::new(new_id, &suffix),
|
||||
exists,
|
||||
is_dir,
|
||||
})
|
||||
};
|
||||
DirectoryState::Create {
|
||||
entries,
|
||||
parent_path: dir.clone(),
|
||||
user_input,
|
||||
}
|
||||
}
|
||||
Err(_) => DirectoryState::Create {
|
||||
entries: Vec::new(),
|
||||
parent_path: dir.clone(),
|
||||
user_input: Some(UserInput {
|
||||
exists: false,
|
||||
is_dir: false,
|
||||
file: StringMatchCandidate::new(0, &suffix),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
this.delegate.directory_state = new_state;
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let match_candidates = this
|
||||
.update(cx, |this, cx| {
|
||||
let directory_state = this.delegate.directory_state.as_ref()?;
|
||||
if directory_state.error.is_some() {
|
||||
this.delegate.matches.clear();
|
||||
this.delegate.selected_index = 0;
|
||||
cx.notify();
|
||||
return None;
|
||||
let Ok(mut new_entries) =
|
||||
this.update(cx, |this, _| match &this.delegate.directory_state {
|
||||
DirectoryState::List {
|
||||
entries,
|
||||
error: None,
|
||||
..
|
||||
}
|
||||
| DirectoryState::Create { entries, .. } => entries.clone(),
|
||||
DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
Some(directory_state.match_candidates.clone())
|
||||
})
|
||||
.unwrap_or(None);
|
||||
|
||||
let Some(mut match_candidates) = match_candidates else {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !suffix.starts_with('.') {
|
||||
match_candidates.retain(|m| !m.path.string.starts_with('.'));
|
||||
new_entries.retain(|entry| !entry.path.string.starts_with('.'));
|
||||
}
|
||||
|
||||
if suffix == "" {
|
||||
if suffix.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.delegate.matches.clear();
|
||||
this.delegate.string_matches.clear();
|
||||
this.delegate
|
||||
.matches
|
||||
.extend(match_candidates.iter().map(|m| m.path.id));
|
||||
|
||||
this.delegate.selected_index = 0;
|
||||
this.delegate.string_matches = new_entries
|
||||
.iter()
|
||||
.map(|m| StringMatch {
|
||||
candidate_id: m.path.id,
|
||||
score: 0.0,
|
||||
positions: Vec::new(),
|
||||
string: m.path.string.clone(),
|
||||
})
|
||||
.collect();
|
||||
this.delegate.directory_state =
|
||||
match &this.delegate.directory_state {
|
||||
DirectoryState::None { create: false }
|
||||
| DirectoryState::List { .. } => DirectoryState::List {
|
||||
parent_path: dir.clone(),
|
||||
entries: new_entries,
|
||||
error: None,
|
||||
},
|
||||
DirectoryState::None { create: true }
|
||||
| DirectoryState::Create { .. } => DirectoryState::Create {
|
||||
parent_path: dir.clone(),
|
||||
user_input: None,
|
||||
entries: new_entries,
|
||||
},
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
|
||||
let Ok(is_create_state) =
|
||||
this.update(cx, |this, _| match &this.delegate.directory_state {
|
||||
DirectoryState::Create { .. } => true,
|
||||
DirectoryState::List { .. } => false,
|
||||
DirectoryState::None { create } => *create,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let candidates = new_entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
if is_create_state && !entry.is_dir && Some(&suffix) == Some(&entry.path.string)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(&entry.path)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let matches = fuzzy::match_strings(
|
||||
candidates.as_slice(),
|
||||
&suffix,
|
||||
@@ -257,27 +423,57 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await;
|
||||
if cancel_flag.load(atomic::Ordering::Relaxed) {
|
||||
if cancel_flag.load(atomic::Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.delegate.matches.clear();
|
||||
this.delegate.selected_index = 0;
|
||||
this.delegate.string_matches = matches.clone();
|
||||
this.delegate
|
||||
.matches
|
||||
.extend(matches.into_iter().map(|m| m.candidate_id));
|
||||
this.delegate.matches.sort_by_key(|m| {
|
||||
this.delegate.string_matches.sort_by_key(|m| {
|
||||
(
|
||||
this.delegate.directory_state.as_ref().and_then(|d| {
|
||||
d.match_candidates
|
||||
.get(*m)
|
||||
.map(|c| !c.path.string.starts_with(&suffix))
|
||||
}),
|
||||
*m,
|
||||
new_entries
|
||||
.iter()
|
||||
.find(|entry| entry.path.id == m.candidate_id)
|
||||
.map(|entry| &entry.path)
|
||||
.map(|candidate| !candidate.string.starts_with(&suffix)),
|
||||
m.candidate_id,
|
||||
)
|
||||
});
|
||||
this.delegate.selected_index = 0;
|
||||
this.delegate.directory_state = match &this.delegate.directory_state {
|
||||
DirectoryState::None { create: false } | DirectoryState::List { .. } => {
|
||||
DirectoryState::List {
|
||||
entries: new_entries,
|
||||
parent_path: dir.clone(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
DirectoryState::None { create: true } => DirectoryState::Create {
|
||||
entries: new_entries,
|
||||
parent_path: dir.clone(),
|
||||
user_input: Some(UserInput {
|
||||
file: StringMatchCandidate::new(0, &suffix),
|
||||
exists: false,
|
||||
is_dir: false,
|
||||
}),
|
||||
},
|
||||
DirectoryState::Create { user_input, .. } => {
|
||||
let (new_id, exists, is_dir) = user_input
|
||||
.as_ref()
|
||||
.map(|input| (input.file.id, input.exists, input.is_dir))
|
||||
.unwrap_or_else(|| (0, false, false));
|
||||
DirectoryState::Create {
|
||||
entries: new_entries,
|
||||
parent_path: dir.clone(),
|
||||
user_input: Some(UserInput {
|
||||
file: StringMatchCandidate::new(new_id, &suffix),
|
||||
exists,
|
||||
is_dir,
|
||||
}),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
@@ -290,49 +486,107 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
_window: &mut Window,
|
||||
_: &mut Context<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
let candidate = self.get_entry(self.selected_index)?;
|
||||
Some(
|
||||
maybe!({
|
||||
let m = self.matches.get(self.selected_index)?;
|
||||
let directory_state = self.directory_state.as_ref()?;
|
||||
let candidate = directory_state.match_candidates.get(*m)?;
|
||||
Some(format!(
|
||||
"{}{}{}",
|
||||
directory_state.path,
|
||||
candidate.path.string,
|
||||
if candidate.is_dir {
|
||||
MAIN_SEPARATOR_STR
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
match &self.directory_state {
|
||||
DirectoryState::Create { parent_path, .. } => Some(format!(
|
||||
"{}{}{}",
|
||||
parent_path,
|
||||
candidate.path.string,
|
||||
if candidate.is_dir {
|
||||
MAIN_SEPARATOR_STR
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)),
|
||||
DirectoryState::List { parent_path, .. } => Some(format!(
|
||||
"{}{}{}",
|
||||
parent_path,
|
||||
candidate.path.string,
|
||||
if candidate.is_dir {
|
||||
MAIN_SEPARATOR_STR
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)),
|
||||
DirectoryState::None { .. } => return None,
|
||||
}
|
||||
})
|
||||
.unwrap_or(query),
|
||||
)
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(m) = self.matches.get(self.selected_index) else {
|
||||
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
||||
let Some(candidate) = self.get_entry(self.selected_index) else {
|
||||
return;
|
||||
};
|
||||
let Some(directory_state) = self.directory_state.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(candidate) = directory_state.match_candidates.get(*m) else {
|
||||
return;
|
||||
};
|
||||
let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
|
||||
PathBuf::from("/")
|
||||
} else {
|
||||
Path::new(
|
||||
self.lister
|
||||
.resolve_tilde(&directory_state.path, cx)
|
||||
.as_ref(),
|
||||
)
|
||||
.join(&candidate.path.string)
|
||||
};
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(Some(vec![result])).ok();
|
||||
|
||||
match &self.directory_state {
|
||||
DirectoryState::None { .. } => return,
|
||||
DirectoryState::List { parent_path, .. } => {
|
||||
let confirmed_path =
|
||||
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
|
||||
PathBuf::from(PROMPT_ROOT)
|
||||
} else {
|
||||
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
|
||||
.join(&candidate.path.string)
|
||||
};
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(Some(vec![confirmed_path])).ok();
|
||||
}
|
||||
}
|
||||
DirectoryState::Create {
|
||||
parent_path,
|
||||
user_input,
|
||||
..
|
||||
} => match user_input {
|
||||
None => return,
|
||||
Some(user_input) => {
|
||||
if user_input.is_dir {
|
||||
return;
|
||||
}
|
||||
let prompted_path =
|
||||
if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
|
||||
PathBuf::from(PROMPT_ROOT)
|
||||
} else {
|
||||
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
|
||||
.join(&user_input.file.string)
|
||||
};
|
||||
if user_input.exists {
|
||||
self.should_dismiss = false;
|
||||
let answer = window.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
&format!("{prompted_path:?} already exists. Do you want to replace it?"),
|
||||
Some(
|
||||
"A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
|
||||
),
|
||||
&["Replace", "Cancel"],
|
||||
cx
|
||||
);
|
||||
self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
|
||||
let answer = answer.await.ok();
|
||||
picker
|
||||
.update(cx, |picker, cx| {
|
||||
picker.delegate.should_dismiss = true;
|
||||
if answer != Some(0) {
|
||||
return;
|
||||
}
|
||||
if let Some(tx) = picker.delegate.tx.take() {
|
||||
tx.send(Some(vec![prompted_path])).ok();
|
||||
}
|
||||
cx.emit(gpui::DismissEvent);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
return;
|
||||
} else if let Some(tx) = self.tx.take() {
|
||||
tx.send(Some(vec![prompted_path])).ok();
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cx.emit(gpui::DismissEvent);
|
||||
}
|
||||
|
||||
@@ -351,19 +605,30 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
let settings = FileFinderSettings::get_global(cx);
|
||||
let m = self.matches.get(ix)?;
|
||||
let directory_state = self.directory_state.as_ref()?;
|
||||
let candidate = directory_state.match_candidates.get(*m)?;
|
||||
let highlight_positions = self
|
||||
.string_matches
|
||||
.iter()
|
||||
.find(|string_match| string_match.candidate_id == *m)
|
||||
.map(|string_match| string_match.positions.clone())
|
||||
.unwrap_or_default();
|
||||
let candidate = self.get_entry(ix)?;
|
||||
let match_positions = match &self.directory_state {
|
||||
DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
|
||||
DirectoryState::Create { user_input, .. } => {
|
||||
if let Some(user_input) = user_input {
|
||||
if !user_input.exists || !user_input.is_dir {
|
||||
if ix == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.string_matches.get(ix - 1)?.positions.clone()
|
||||
}
|
||||
} else {
|
||||
self.string_matches.get(ix)?.positions.clone()
|
||||
}
|
||||
} else {
|
||||
self.string_matches.get(ix)?.positions.clone()
|
||||
}
|
||||
}
|
||||
DirectoryState::None { .. } => Vec::new(),
|
||||
};
|
||||
|
||||
let file_icon = maybe!({
|
||||
if !settings.file_icons {
|
||||
@@ -378,34 +643,128 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
Some(Icon::from_path(icon).color(Color::Muted))
|
||||
});
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.start_slot::<Icon>(file_icon)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.child(HighlightedLabel::new(
|
||||
if directory_state.path == "/" {
|
||||
format!("/{}", candidate.path.string)
|
||||
} else {
|
||||
candidate.path.string.clone()
|
||||
},
|
||||
highlight_positions,
|
||||
)),
|
||||
)
|
||||
match &self.directory_state {
|
||||
DirectoryState::List { parent_path, .. } => Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.start_slot::<Icon>(file_icon)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.child(HighlightedLabel::new(
|
||||
if parent_path == PROMPT_ROOT {
|
||||
format!("{}{}", PROMPT_ROOT, candidate.path.string)
|
||||
} else {
|
||||
candidate.path.string.clone()
|
||||
},
|
||||
match_positions,
|
||||
)),
|
||||
),
|
||||
DirectoryState::Create {
|
||||
parent_path,
|
||||
user_input,
|
||||
..
|
||||
} => {
|
||||
let (label, delta) = if parent_path == PROMPT_ROOT {
|
||||
(
|
||||
format!("{}{}", PROMPT_ROOT, candidate.path.string),
|
||||
PROMPT_ROOT.len(),
|
||||
)
|
||||
} else {
|
||||
(candidate.path.string.clone(), 0)
|
||||
};
|
||||
let label_len = label.len();
|
||||
|
||||
let label_with_highlights = match user_input {
|
||||
Some(user_input) => {
|
||||
if user_input.file.string == candidate.path.string {
|
||||
if user_input.exists {
|
||||
let label = if user_input.is_dir {
|
||||
label
|
||||
} else {
|
||||
format!("{label} (replace)")
|
||||
};
|
||||
StyledText::new(label)
|
||||
.with_default_highlights(
|
||||
&window.text_style().clone(),
|
||||
vec![(
|
||||
delta..delta + label_len,
|
||||
HighlightStyle::color(Color::Conflict.color(cx)),
|
||||
)],
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
StyledText::new(format!("{label} (create)"))
|
||||
.with_default_highlights(
|
||||
&window.text_style().clone(),
|
||||
vec![(
|
||||
delta..delta + label_len,
|
||||
HighlightStyle::color(Color::Created.color(cx)),
|
||||
)],
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
} else {
|
||||
let mut highlight_positions = match_positions;
|
||||
highlight_positions.iter_mut().for_each(|position| {
|
||||
*position += delta;
|
||||
});
|
||||
HighlightedLabel::new(label, highlight_positions).into_any_element()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut highlight_positions = match_positions;
|
||||
highlight_positions.iter_mut().for_each(|position| {
|
||||
*position += delta;
|
||||
});
|
||||
HighlightedLabel::new(label, highlight_positions).into_any_element()
|
||||
}
|
||||
};
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.start_slot::<Icon>(file_icon)
|
||||
.inset(true)
|
||||
.toggle_state(selected)
|
||||
.child(LabelLike::new().child(label_with_highlights)),
|
||||
)
|
||||
}
|
||||
DirectoryState::None { .. } => return None,
|
||||
}
|
||||
}
|
||||
|
||||
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
||||
let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
|
||||
{
|
||||
error
|
||||
} else {
|
||||
"No such file or directory".into()
|
||||
};
|
||||
Some(text)
|
||||
Some(match &self.directory_state {
|
||||
DirectoryState::Create { .. } => SharedString::from("Type a path…"),
|
||||
DirectoryState::List {
|
||||
error: Some(error), ..
|
||||
} => error.clone(),
|
||||
DirectoryState::List { .. } | DirectoryState::None { .. } => {
|
||||
SharedString::from("No such file or directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
||||
Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
|
||||
}
|
||||
}
|
||||
|
||||
fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
|
||||
if *parent_path == PROMPT_ROOT {
|
||||
children.push(DirectoryItem {
|
||||
is_dir: true,
|
||||
path: PathBuf::default(),
|
||||
});
|
||||
}
|
||||
|
||||
children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
|
||||
children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, item)| CandidateInfo {
|
||||
path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
|
||||
is_dir: item.is_dir,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||
|
||||
let (picker, cx) = build_open_path_prompt(project, cx);
|
||||
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||
|
||||
let query = path!("/root");
|
||||
insert_query(query, &picker, cx).await;
|
||||
@@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||
|
||||
let (picker, cx) = build_open_path_prompt(project, cx);
|
||||
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||
|
||||
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
|
||||
let query = path!("/root");
|
||||
@@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||
|
||||
let (picker, cx) = build_open_path_prompt(project, cx);
|
||||
let (picker, cx) = build_open_path_prompt(project, false, cx);
|
||||
|
||||
// Support both forward and backward slashes.
|
||||
let query = "C:/root/";
|
||||
@@ -251,6 +251,54 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_new_path_prompt(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"a1": "A1",
|
||||
"a2": "A2",
|
||||
"a3": "A3",
|
||||
"dir1": {},
|
||||
"dir2": {
|
||||
"c": "C",
|
||||
"d1": "D1",
|
||||
"d2": "D2",
|
||||
"d3": "D3",
|
||||
"dir3": {},
|
||||
"dir4": {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||
|
||||
let (picker, cx) = build_open_path_prompt(project, true, cx);
|
||||
|
||||
insert_query(path!("/root"), &picker, cx).await;
|
||||
assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
|
||||
|
||||
insert_query(path!("/root/d"), &picker, cx).await;
|
||||
assert_eq!(
|
||||
collect_match_candidates(&picker, cx),
|
||||
vec!["d", "dir1", "dir2"]
|
||||
);
|
||||
|
||||
insert_query(path!("/root/dir1"), &picker, cx).await;
|
||||
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
|
||||
|
||||
insert_query(path!("/root/dir12"), &picker, cx).await;
|
||||
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir12"]);
|
||||
|
||||
insert_query(path!("/root/dir1"), &picker, cx).await;
|
||||
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1"]);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||
cx.update(|cx| {
|
||||
let state = AppState::test(cx);
|
||||
@@ -266,11 +314,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
|
||||
|
||||
fn build_open_path_prompt(
|
||||
project: Entity<Project>,
|
||||
creating_path: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
|
||||
let (tx, _) = futures::channel::oneshot::channel();
|
||||
let lister = project::DirectoryLister::Project(project.clone());
|
||||
let delegate = OpenPathDelegate::new(tx, lister.clone());
|
||||
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
|
||||
|
||||
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
|
||||
(
|
||||
|
||||
@@ -155,6 +155,7 @@ pub enum IconName {
|
||||
LineHeight,
|
||||
Link,
|
||||
ListCollapse,
|
||||
ListTodo,
|
||||
ListTree,
|
||||
ListX,
|
||||
LoadCircle,
|
||||
|
||||
@@ -685,8 +685,9 @@ impl CompletionProvider for RustStyleCompletionProvider {
|
||||
&self,
|
||||
buffer: &Entity<language::Buffer>,
|
||||
position: language::Anchor,
|
||||
_: &str,
|
||||
_: bool,
|
||||
_text: &str,
|
||||
_trigger_in_words: bool,
|
||||
_menu_is_open: bool,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> bool {
|
||||
completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some()
|
||||
|
||||
@@ -3283,8 +3283,8 @@ impl BufferSnapshot {
|
||||
pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
|
||||
let mut start = start.to_offset(self);
|
||||
let mut end = start;
|
||||
let mut next_chars = self.chars_at(start).peekable();
|
||||
let mut prev_chars = self.reversed_chars_at(start).peekable();
|
||||
let mut next_chars = self.chars_at(start).take(128).peekable();
|
||||
let mut prev_chars = self.reversed_chars_at(start).take(128).peekable();
|
||||
|
||||
let classifier = self.char_classifier_at(start);
|
||||
let word_kind = cmp::max(
|
||||
|
||||
@@ -343,6 +343,8 @@ impl Prettier {
|
||||
prettier_plugin_dir.join("plugin.js"),
|
||||
// this one is for @prettier/plugin-php
|
||||
prettier_plugin_dir.join("standalone.js"),
|
||||
// this one is for prettier-plugin-latex
|
||||
prettier_plugin_dir.join("dist").join("prettier-plugin-latex.js"),
|
||||
prettier_plugin_dir,
|
||||
]
|
||||
.into_iter()
|
||||
|
||||
@@ -770,13 +770,26 @@ pub struct DirectoryItem {
|
||||
#[derive(Clone)]
|
||||
pub enum DirectoryLister {
|
||||
Project(Entity<Project>),
|
||||
Local(Arc<dyn Fs>),
|
||||
Local(Entity<Project>, Arc<dyn Fs>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DirectoryLister {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DirectoryLister::Project(project) => {
|
||||
write!(f, "DirectoryLister::Project({project:?})")
|
||||
}
|
||||
DirectoryLister::Local(project, _) => {
|
||||
write!(f, "DirectoryLister::Local({project:?})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectoryLister {
|
||||
pub fn is_local(&self, cx: &App) -> bool {
|
||||
match self {
|
||||
DirectoryLister::Local(_) => true,
|
||||
DirectoryLister::Local(..) => true,
|
||||
DirectoryLister::Project(project) => project.read(cx).is_local(),
|
||||
}
|
||||
}
|
||||
@@ -790,12 +803,28 @@ impl DirectoryLister {
|
||||
}
|
||||
|
||||
pub fn default_query(&self, cx: &mut App) -> String {
|
||||
if let DirectoryLister::Project(project) = self {
|
||||
if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() {
|
||||
return worktree.read(cx).abs_path().to_string_lossy().to_string();
|
||||
let separator = std::path::MAIN_SEPARATOR_STR;
|
||||
match self {
|
||||
DirectoryLister::Project(project) => project,
|
||||
DirectoryLister::Local(project, _) => project,
|
||||
}
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.map(|worktree| worktree.read(cx).abs_path())
|
||||
.map(|dir| dir.to_string_lossy().to_string())
|
||||
.or_else(|| std::env::home_dir().map(|dir| dir.to_string_lossy().to_string()))
|
||||
.map(|mut s| {
|
||||
s.push_str(separator);
|
||||
s
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if cfg!(target_os = "windows") {
|
||||
format!("C:{separator}")
|
||||
} else {
|
||||
format!("~{separator}")
|
||||
}
|
||||
};
|
||||
format!("~{}", std::path::MAIN_SEPARATOR_STR)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
|
||||
@@ -803,7 +832,7 @@ impl DirectoryLister {
|
||||
DirectoryLister::Project(project) => {
|
||||
project.update(cx, |project, cx| project.list_directory(path, cx))
|
||||
}
|
||||
DirectoryLister::Local(fs) => {
|
||||
DirectoryLister::Local(_, fs) => {
|
||||
let fs = fs.clone();
|
||||
cx.background_spawn(async move {
|
||||
let mut results = vec![];
|
||||
@@ -4049,7 +4078,7 @@ impl Project {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<DirectoryItem>>> {
|
||||
if self.is_local() {
|
||||
DirectoryLister::Local(self.fs.clone()).list_directory(query, cx)
|
||||
DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
|
||||
} else if let Some(session) = self.ssh_client.as_ref() {
|
||||
let path_buf = PathBuf::from(query);
|
||||
let request = proto::ListRemoteDirectory {
|
||||
|
||||
@@ -22,7 +22,7 @@ use gpui::{
|
||||
Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
|
||||
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
|
||||
Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
|
||||
anchored, deferred, div, impl_actions, point, px, size, uniform_list,
|
||||
anchored, deferred, div, impl_actions, point, px, size, transparent_white, uniform_list,
|
||||
};
|
||||
use indexmap::IndexMap;
|
||||
use language::DiagnosticSeverity;
|
||||
@@ -85,8 +85,7 @@ pub struct ProjectPanel {
|
||||
ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
|
||||
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
|
||||
last_worktree_root_id: Option<ProjectEntryId>,
|
||||
last_selection_drag_over_entry: Option<ProjectEntryId>,
|
||||
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
|
||||
drag_target_entry: Option<DragTargetEntry>,
|
||||
expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
|
||||
unfolded_dir_ids: HashSet<ProjectEntryId>,
|
||||
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
|
||||
@@ -112,6 +111,13 @@ pub struct ProjectPanel {
|
||||
hover_expand_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
struct DragTargetEntry {
|
||||
/// The entry currently under the mouse cursor during a drag operation
|
||||
entry_id: ProjectEntryId,
|
||||
/// Highlight this entry along with all of its children
|
||||
highlight_entry_id: Option<ProjectEntryId>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
struct FoldedDirectoryDragTarget {
|
||||
entry_id: ProjectEntryId,
|
||||
@@ -472,9 +478,8 @@ impl ProjectPanel {
|
||||
visible_entries: Default::default(),
|
||||
ancestors: Default::default(),
|
||||
folded_directory_drag_target: None,
|
||||
drag_target_entry: None,
|
||||
last_worktree_root_id: Default::default(),
|
||||
last_external_paths_drag_over_entry: None,
|
||||
last_selection_drag_over_entry: None,
|
||||
expanded_dir_ids: Default::default(),
|
||||
unfolded_dir_ids: Default::default(),
|
||||
selection: None,
|
||||
@@ -3703,6 +3708,67 @@ impl ProjectPanel {
|
||||
(depth, difference)
|
||||
}
|
||||
|
||||
fn highlight_entry_for_external_drag(
|
||||
&self,
|
||||
target_entry: &Entry,
|
||||
target_worktree: &Worktree,
|
||||
) -> Option<ProjectEntryId> {
|
||||
// Always highlight directory or parent directory if it's file
|
||||
if target_entry.is_dir() {
|
||||
Some(target_entry.id)
|
||||
} else if let Some(parent_entry) = target_entry
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
|
||||
{
|
||||
Some(parent_entry.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_entry_for_selection_drag(
|
||||
&self,
|
||||
target_entry: &Entry,
|
||||
target_worktree: &Worktree,
|
||||
dragged_selection: &DraggedSelection,
|
||||
cx: &Context<Self>,
|
||||
) -> Option<ProjectEntryId> {
|
||||
let target_parent_path = target_entry.path.parent();
|
||||
|
||||
// In case of single item drag, we do not highlight existing
|
||||
// directory which item belongs too
|
||||
if dragged_selection.items().count() == 1 {
|
||||
let active_entry_path = self
|
||||
.project
|
||||
.read(cx)
|
||||
.path_for_entry(dragged_selection.active_selection.entry_id, cx)?;
|
||||
|
||||
if let Some(active_parent_path) = active_entry_path.path.parent() {
|
||||
// Do not highlight active entry parent
|
||||
if active_parent_path == target_entry.path.as_ref() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Do not highlight active entry sibling files
|
||||
if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always highlight directory or parent directory if it's file
|
||||
if target_entry.is_dir() {
|
||||
Some(target_entry.id)
|
||||
} else if let Some(parent_entry) =
|
||||
target_parent_path.and_then(|parent_path| target_worktree.entry_for_path(parent_path))
|
||||
{
|
||||
Some(parent_entry.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_entry(
|
||||
&self,
|
||||
entry_id: ProjectEntryId,
|
||||
@@ -3745,6 +3811,8 @@ impl ProjectPanel {
|
||||
.as_ref()
|
||||
.map(|f| f.to_string_lossy().to_string());
|
||||
let path = details.path.clone();
|
||||
let path_for_external_paths = path.clone();
|
||||
let path_for_dragged_selection = path.clone();
|
||||
|
||||
let depth = details.depth;
|
||||
let worktree_id = details.worktree_id;
|
||||
@@ -3802,6 +3870,27 @@ impl ProjectPanel {
|
||||
};
|
||||
|
||||
let folded_directory_drag_target = self.folded_directory_drag_target;
|
||||
let is_highlighted = {
|
||||
if let Some(highlight_entry_id) = self
|
||||
.drag_target_entry
|
||||
.as_ref()
|
||||
.and_then(|drag_target| drag_target.highlight_entry_id)
|
||||
{
|
||||
// Highlight if same entry or it's children
|
||||
if entry_id == highlight_entry_id {
|
||||
true
|
||||
} else {
|
||||
maybe!({
|
||||
let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
|
||||
let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
|
||||
Some(path.starts_with(&highlight_entry.path))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
div()
|
||||
.id(entry_id.to_proto() as usize)
|
||||
@@ -3815,95 +3904,111 @@ impl ProjectPanel {
|
||||
.hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
|
||||
.on_drag_move::<ExternalPaths>(cx.listener(
|
||||
move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
|
||||
if event.bounds.contains(&event.event.position) {
|
||||
if this.last_external_paths_drag_over_entry == Some(entry_id) {
|
||||
return;
|
||||
let is_current_target = this.drag_target_entry.as_ref()
|
||||
.map(|entry| entry.entry_id) == Some(entry_id);
|
||||
|
||||
if !event.bounds.contains(&event.event.position) {
|
||||
// Entry responsible for setting drag target is also responsible to
|
||||
// clear it up after drag is out of bounds
|
||||
if is_current_target {
|
||||
this.drag_target_entry = None;
|
||||
}
|
||||
this.last_external_paths_drag_over_entry = Some(entry_id);
|
||||
this.marked_entries.clear();
|
||||
|
||||
let Some((worktree, path, entry)) = maybe!({
|
||||
let worktree = this
|
||||
.project
|
||||
.read(cx)
|
||||
.worktree_for_id(selection.worktree_id, cx)?;
|
||||
let worktree = worktree.read(cx);
|
||||
let entry = worktree.entry_for_path(&path)?;
|
||||
let path = if entry.is_dir() {
|
||||
path.as_ref()
|
||||
} else {
|
||||
path.parent()?
|
||||
};
|
||||
Some((worktree, path, entry))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.marked_entries.insert(SelectedEntry {
|
||||
entry_id: entry.id,
|
||||
worktree_id: worktree.id(),
|
||||
});
|
||||
|
||||
for entry in worktree.child_entries(path) {
|
||||
this.marked_entries.insert(SelectedEntry {
|
||||
entry_id: entry.id,
|
||||
worktree_id: worktree.id(),
|
||||
});
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if is_current_target {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((entry_id, highlight_entry_id)) = maybe!({
|
||||
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
|
||||
let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?;
|
||||
let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree);
|
||||
Some((target_entry.id, highlight_entry_id))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.drag_target_entry = Some(DragTargetEntry {
|
||||
entry_id,
|
||||
highlight_entry_id,
|
||||
});
|
||||
this.marked_entries.clear();
|
||||
},
|
||||
))
|
||||
.on_drop(cx.listener(
|
||||
move |this, external_paths: &ExternalPaths, window, cx| {
|
||||
this.drag_target_entry = None;
|
||||
this.hover_scroll_task.take();
|
||||
this.last_external_paths_drag_over_entry = None;
|
||||
this.marked_entries.clear();
|
||||
this.drop_external_files(external_paths.paths(), entry_id, window, cx);
|
||||
cx.stop_propagation();
|
||||
},
|
||||
))
|
||||
.on_drag_move::<DraggedSelection>(cx.listener(
|
||||
move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
|
||||
if event.bounds.contains(&event.event.position) {
|
||||
if this.last_selection_drag_over_entry == Some(entry_id) {
|
||||
return;
|
||||
}
|
||||
this.last_selection_drag_over_entry = Some(entry_id);
|
||||
this.hover_expand_task.take();
|
||||
let is_current_target = this.drag_target_entry.as_ref()
|
||||
.map(|entry| entry.entry_id) == Some(entry_id);
|
||||
|
||||
if !kind.is_dir()
|
||||
|| this
|
||||
.expanded_dir_ids
|
||||
.get(&details.worktree_id)
|
||||
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
|
||||
{
|
||||
return;
|
||||
if !event.bounds.contains(&event.event.position) {
|
||||
// Entry responsible for setting drag target is also responsible to
|
||||
// clear it up after drag is out of bounds
|
||||
if is_current_target {
|
||||
this.drag_target_entry = None;
|
||||
}
|
||||
|
||||
let bounds = event.bounds;
|
||||
this.hover_expand_task =
|
||||
Some(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.hover_expand_task.take();
|
||||
if this.last_selection_drag_over_entry == Some(entry_id)
|
||||
&& bounds.contains(&window.mouse_position())
|
||||
{
|
||||
this.expand_entry(worktree_id, entry_id, cx);
|
||||
this.update_visible_entries(
|
||||
Some((worktree_id, entry_id)),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if is_current_target {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((entry_id, highlight_entry_id)) = maybe!({
|
||||
let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx);
|
||||
let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?;
|
||||
let dragged_selection = event.drag(cx);
|
||||
let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, dragged_selection, cx);
|
||||
Some((target_entry.id, highlight_entry_id))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.drag_target_entry = Some(DragTargetEntry {
|
||||
entry_id,
|
||||
highlight_entry_id,
|
||||
});
|
||||
this.marked_entries.clear();
|
||||
this.hover_expand_task.take();
|
||||
|
||||
if !kind.is_dir()
|
||||
|| this
|
||||
.expanded_dir_ids
|
||||
.get(&details.worktree_id)
|
||||
.map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let bounds = event.bounds;
|
||||
this.hover_expand_task =
|
||||
Some(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.hover_expand_task.take();
|
||||
if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id)
|
||||
&& bounds.contains(&window.mouse_position())
|
||||
{
|
||||
this.expand_entry(worktree_id, entry_id, cx);
|
||||
this.update_visible_entries(
|
||||
Some((worktree_id, entry_id)),
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
},
|
||||
))
|
||||
.on_drag(
|
||||
@@ -3917,14 +4022,10 @@ impl ProjectPanel {
|
||||
})
|
||||
},
|
||||
)
|
||||
.drag_over::<DraggedSelection>(move |style, _, _, _| {
|
||||
if folded_directory_drag_target.is_some() {
|
||||
return style;
|
||||
}
|
||||
style.bg(item_colors.drag_over)
|
||||
})
|
||||
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
|
||||
.on_drop(
|
||||
cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
||||
this.drag_target_entry = None;
|
||||
this.hover_scroll_task.take();
|
||||
this.hover_expand_task.take();
|
||||
if folded_directory_drag_target.is_some() {
|
||||
@@ -4126,6 +4227,7 @@ impl ProjectPanel {
|
||||
div()
|
||||
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
|
||||
this.hover_scroll_task.take();
|
||||
this.drag_target_entry = None;
|
||||
this.folded_directory_drag_target = None;
|
||||
if let Some(target_entry_id) = target_entry_id {
|
||||
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
|
||||
@@ -4208,6 +4310,7 @@ impl ProjectPanel {
|
||||
))
|
||||
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
|
||||
this.hover_scroll_task.take();
|
||||
this.drag_target_entry = None;
|
||||
this.folded_directory_drag_target = None;
|
||||
if let Some(target_entry_id) = target_entry_id {
|
||||
this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
|
||||
@@ -4573,13 +4676,14 @@ impl Render for ProjectPanel {
|
||||
.map(|(_, worktree_entries, _)| worktree_entries.len())
|
||||
.sum();
|
||||
|
||||
fn handle_drag_move_scroll<T: 'static>(
|
||||
fn handle_drag_move<T: 'static>(
|
||||
this: &mut ProjectPanel,
|
||||
e: &DragMoveEvent<T>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<ProjectPanel>,
|
||||
) {
|
||||
if !e.bounds.contains(&e.event.position) {
|
||||
this.drag_target_entry = None;
|
||||
return;
|
||||
}
|
||||
this.hover_scroll_task.take();
|
||||
@@ -4633,8 +4737,8 @@ impl Render for ProjectPanel {
|
||||
h_flex()
|
||||
.id("project-panel")
|
||||
.group("project-panel")
|
||||
.on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
|
||||
.on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
|
||||
.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
|
||||
.on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
|
||||
.size_full()
|
||||
.relative()
|
||||
.on_hover(cx.listener(|this, hovered, window, cx| {
|
||||
@@ -4890,8 +4994,7 @@ impl Render for ProjectPanel {
|
||||
})
|
||||
.on_drop(cx.listener(
|
||||
move |this, external_paths: &ExternalPaths, window, cx| {
|
||||
this.last_external_paths_drag_over_entry = None;
|
||||
this.marked_entries.clear();
|
||||
this.drag_target_entry = None;
|
||||
this.hover_scroll_task.take();
|
||||
if let Some(task) = this
|
||||
.workspace
|
||||
|
||||
@@ -5098,6 +5098,205 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"dir1": {
|
||||
"file1.txt": "",
|
||||
"dir2": {
|
||||
"file2.txt": ""
|
||||
}
|
||||
},
|
||||
"file3.txt": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
let project = panel.project.read(cx);
|
||||
let worktree = project.visible_worktrees(cx).next().unwrap();
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
// Test 1: Target is a directory, should highlight the directory itself
|
||||
let dir_entry = worktree.entry_for_path("dir1").unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(dir_entry.id),
|
||||
"Should highlight directory itself"
|
||||
);
|
||||
|
||||
// Test 2: Target is nested file, should highlight immediate parent
|
||||
let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
|
||||
let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(nested_parent.id),
|
||||
"Should highlight immediate parent"
|
||||
);
|
||||
|
||||
// Test 3: Target is root level file, should highlight root
|
||||
let root_file = worktree.entry_for_path("file3.txt").unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(root_file, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(worktree.root_entry().unwrap().id),
|
||||
"Root level file should return None"
|
||||
);
|
||||
|
||||
// Test 4: Target is root itself, should highlight root
|
||||
let root_entry = worktree.root_entry().unwrap();
|
||||
let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(root_entry.id),
|
||||
"Root level file should return None"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor().clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"parent_dir": {
|
||||
"child_file.txt": "",
|
||||
"sibling_file.txt": "",
|
||||
"child_dir": {
|
||||
"nested_file.txt": ""
|
||||
}
|
||||
},
|
||||
"other_dir": {
|
||||
"other_file.txt": ""
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
let cx = &mut VisualTestContext::from_window(*workspace, cx);
|
||||
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
let project = panel.project.read(cx);
|
||||
let worktree = project.visible_worktrees(cx).next().unwrap();
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
|
||||
let child_file = worktree
|
||||
.entry_for_path("parent_dir/child_file.txt")
|
||||
.unwrap();
|
||||
let sibling_file = worktree
|
||||
.entry_for_path("parent_dir/sibling_file.txt")
|
||||
.unwrap();
|
||||
let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
|
||||
let other_dir = worktree.entry_for_path("other_dir").unwrap();
|
||||
let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
|
||||
|
||||
// Test 1: Single item drag, don't highlight parent directory
|
||||
let dragged_selection = DraggedSelection {
|
||||
active_selection: SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
}])),
|
||||
};
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(result, None, "Should not highlight parent of dragged item");
|
||||
|
||||
// Test 2: Single item drag, don't highlight sibling files
|
||||
let result = panel.highlight_entry_for_selection_drag(
|
||||
sibling_file,
|
||||
worktree,
|
||||
&dragged_selection,
|
||||
cx,
|
||||
);
|
||||
assert_eq!(result, None, "Should not highlight sibling files");
|
||||
|
||||
// Test 3: Single item drag, highlight unrelated directory
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(other_dir.id),
|
||||
"Should highlight unrelated directory"
|
||||
);
|
||||
|
||||
// Test 4: Single item drag, highlight sibling directory
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(child_dir.id),
|
||||
"Should highlight sibling directory"
|
||||
);
|
||||
|
||||
// Test 5: Multiple items drag, highlight parent directory
|
||||
let dragged_selection = DraggedSelection {
|
||||
active_selection: SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
marked_selections: Arc::new(BTreeSet::from([
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: child_file.id,
|
||||
},
|
||||
SelectedEntry {
|
||||
worktree_id,
|
||||
entry_id: sibling_file.id,
|
||||
},
|
||||
])),
|
||||
};
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(parent_dir.id),
|
||||
"Should highlight parent with multiple items"
|
||||
);
|
||||
|
||||
// Test 6: Target is file in different directory, highlight parent
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(other_dir.id),
|
||||
"Should highlight parent of target file"
|
||||
);
|
||||
|
||||
// Test 7: Target is directory, always highlight
|
||||
let result =
|
||||
panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(child_dir.id),
|
||||
"Should always highlight directories"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
|
||||
let path = path.as_ref();
|
||||
panel.update(cx, |panel, cx| {
|
||||
|
||||
@@ -147,7 +147,7 @@ impl ProjectPicker {
|
||||
) -> Entity<Self> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let lister = project::DirectoryLister::Project(project.clone());
|
||||
let delegate = file_finder::OpenPathDelegate::new(tx, lister);
|
||||
let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
|
||||
|
||||
let picker = cx.new(|cx| {
|
||||
let picker = Picker::uniform_list(delegate, window, cx)
|
||||
|
||||
@@ -25,7 +25,7 @@ use gpui::{
|
||||
use itertools::Itertools;
|
||||
use language::DiagnosticSeverity;
|
||||
use parking_lot::Mutex;
|
||||
use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
|
||||
use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, WorktreeId};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use settings::{Settings, SettingsStore};
|
||||
@@ -1921,24 +1921,56 @@ impl Pane {
|
||||
})?
|
||||
.await?;
|
||||
} else if can_save_as && is_singleton {
|
||||
let abs_path = pane.update_in(cx, |pane, window, cx| {
|
||||
let new_path = pane.update_in(cx, |pane, window, cx| {
|
||||
pane.activate_item(item_ix, true, true, window, cx);
|
||||
pane.workspace.update(cx, |workspace, cx| {
|
||||
workspace.prompt_for_new_path(window, cx)
|
||||
let lister = if workspace.project().read(cx).is_local() {
|
||||
DirectoryLister::Local(
|
||||
workspace.project().clone(),
|
||||
workspace.app_state().fs.clone(),
|
||||
)
|
||||
} else {
|
||||
DirectoryLister::Project(workspace.project().clone())
|
||||
};
|
||||
workspace.prompt_for_new_path(lister, window, cx)
|
||||
})
|
||||
})??;
|
||||
if let Some(abs_path) = abs_path.await.ok().flatten() {
|
||||
let Some(new_path) = new_path.await.ok().flatten().into_iter().flatten().next()
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let project_path = pane
|
||||
.update(cx, |pane, cx| {
|
||||
pane.project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_worktree(new_path, true, cx)
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
let save_task = if let Some(project_path) = project_path {
|
||||
let (worktree, path) = project_path.await?;
|
||||
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
|
||||
let new_path = ProjectPath {
|
||||
worktree_id,
|
||||
path: path.into(),
|
||||
};
|
||||
|
||||
pane.update_in(cx, |pane, window, cx| {
|
||||
if let Some(item) = pane.item_for_path(abs_path.clone(), cx) {
|
||||
if let Some(item) = pane.item_for_path(new_path.clone(), cx) {
|
||||
pane.remove_item(item.item_id(), false, false, window, cx);
|
||||
}
|
||||
|
||||
item.save_as(project, abs_path, window, cx)
|
||||
item.save_as(project, new_path, window, cx)
|
||||
})?
|
||||
.await?;
|
||||
} else {
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
save_task.await?;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -899,9 +899,10 @@ pub enum OpenVisible {
|
||||
type PromptForNewPath = Box<
|
||||
dyn Fn(
|
||||
&mut Workspace,
|
||||
DirectoryLister,
|
||||
&mut Window,
|
||||
&mut Context<Workspace>,
|
||||
) -> oneshot::Receiver<Option<ProjectPath>>,
|
||||
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
|
||||
>;
|
||||
|
||||
type PromptForOpenPath = Box<
|
||||
@@ -1874,25 +1875,25 @@ impl Workspace {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let abs_path = cx.prompt_for_paths(path_prompt_options);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
let Ok(result) = abs_path.await else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(result) => {
|
||||
tx.send(result).log_err();
|
||||
tx.send(result).ok();
|
||||
}
|
||||
Err(err) => {
|
||||
let rx = this.update_in(cx, |this, window, cx| {
|
||||
this.show_portal_error(err.to_string(), cx);
|
||||
let prompt = this.on_prompt_for_open_path.take().unwrap();
|
||||
let rx = prompt(this, lister, window, cx);
|
||||
this.on_prompt_for_open_path = Some(prompt);
|
||||
let rx = workspace.update_in(cx, |workspace, window, cx| {
|
||||
workspace.show_portal_error(err.to_string(), cx);
|
||||
let prompt = workspace.on_prompt_for_open_path.take().unwrap();
|
||||
let rx = prompt(workspace, lister, window, cx);
|
||||
workspace.on_prompt_for_open_path = Some(prompt);
|
||||
rx
|
||||
})?;
|
||||
if let Ok(path) = rx.await {
|
||||
tx.send(path).log_err();
|
||||
tx.send(path).ok();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1906,77 +1907,58 @@ impl Workspace {
|
||||
|
||||
pub fn prompt_for_new_path(
|
||||
&mut self,
|
||||
lister: DirectoryLister,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> oneshot::Receiver<Option<ProjectPath>> {
|
||||
if (self.project.read(cx).is_via_collab() || self.project.read(cx).is_via_ssh())
|
||||
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
|
||||
if self.project.read(cx).is_via_collab()
|
||||
|| self.project.read(cx).is_via_ssh()
|
||||
|| !WorkspaceSettings::get_global(cx).use_system_path_prompts
|
||||
{
|
||||
let prompt = self.on_prompt_for_new_path.take().unwrap();
|
||||
let rx = prompt(self, window, cx);
|
||||
let rx = prompt(self, lister, window, cx);
|
||||
self.on_prompt_for_new_path = Some(prompt);
|
||||
return rx;
|
||||
}
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let abs_path = this.update(cx, |this, cx| {
|
||||
let mut relative_to = this
|
||||
cx.spawn_in(window, async move |workspace, cx| {
|
||||
let abs_path = workspace.update(cx, |workspace, cx| {
|
||||
let relative_to = workspace
|
||||
.most_recent_active_path(cx)
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()));
|
||||
if relative_to.is_none() {
|
||||
let project = this.project.read(cx);
|
||||
relative_to = project
|
||||
.visible_worktrees(cx)
|
||||
.filter_map(|worktree| {
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.or_else(|| {
|
||||
let project = workspace.project.read(cx);
|
||||
project.visible_worktrees(cx).find_map(|worktree| {
|
||||
Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
|
||||
})
|
||||
.next()
|
||||
};
|
||||
|
||||
cx.prompt_for_new_path(&relative_to.unwrap_or_else(|| PathBuf::from("")))
|
||||
})
|
||||
.or_else(std::env::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from(""));
|
||||
cx.prompt_for_new_path(&relative_to)
|
||||
})?;
|
||||
let abs_path = match abs_path.await? {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let rx = this.update_in(cx, |this, window, cx| {
|
||||
this.show_portal_error(err.to_string(), cx);
|
||||
let rx = workspace.update_in(cx, |workspace, window, cx| {
|
||||
workspace.show_portal_error(err.to_string(), cx);
|
||||
|
||||
let prompt = this.on_prompt_for_new_path.take().unwrap();
|
||||
let rx = prompt(this, window, cx);
|
||||
this.on_prompt_for_new_path = Some(prompt);
|
||||
let prompt = workspace.on_prompt_for_new_path.take().unwrap();
|
||||
let rx = prompt(workspace, lister, window, cx);
|
||||
workspace.on_prompt_for_new_path = Some(prompt);
|
||||
rx
|
||||
})?;
|
||||
if let Ok(path) = rx.await {
|
||||
tx.send(path).log_err();
|
||||
tx.send(path).ok();
|
||||
}
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let project_path = abs_path.and_then(|abs_path| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.project.update(cx, |project, cx| {
|
||||
project.find_or_create_worktree(abs_path, true, cx)
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
|
||||
if let Some(project_path) = project_path {
|
||||
let (worktree, path) = project_path.await?;
|
||||
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
|
||||
tx.send(Some(ProjectPath {
|
||||
worktree_id,
|
||||
path: path.into(),
|
||||
}))
|
||||
.ok();
|
||||
} else {
|
||||
tx.send(None).ok();
|
||||
}
|
||||
tx.send(abs_path.map(|path| vec![path])).ok();
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
.detach();
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
@@ -503,7 +503,10 @@ fn register_actions(
|
||||
directories: true,
|
||||
multiple: true,
|
||||
},
|
||||
DirectoryLister::Local(workspace.app_state().fs.clone()),
|
||||
DirectoryLister::Local(
|
||||
workspace.project().clone(),
|
||||
workspace.app_state().fs.clone(),
|
||||
),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -5,8 +5,17 @@ Currently, Zed supports `.rules` files at the directory's root and the Rules Lib
|
||||
|
||||
## `.rules` files
|
||||
|
||||
Zed supports including `.rules` files at the top level of worktrees, and act as project-level instructions you'd like to have included in all of your interactions with the Agent Panel.
|
||||
Other names for this file are also supported—the first file which matches in this list will be used: `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.github/copilot-instructions.md`, or `CLAUDE.md`.
|
||||
Zed supports including `.rules` files at the top level of worktrees, and act as project-level instructions that are included in all of your interactions with the Agent Panel.
|
||||
Other names for this file are also supported for compatibility with other agents, but note that the first file which matches in this list will be used:
|
||||
|
||||
- `.rules`
|
||||
- `.cursorrules`
|
||||
- `.windsurfrules`
|
||||
- `.clinerules`
|
||||
- `.github/copilot-instructions.md`
|
||||
- `AGENT.md`
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
|
||||
## Rules Library {#rules-library}
|
||||
|
||||
|
||||
@@ -28,20 +28,11 @@ Additionally, Ruby support (via rdbg) is being actively worked on.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Zed supports zero-configuration debugging of tests and main functions in several popular languages:
|
||||
For basic debugging, you can set up a new configuration by opening the `New Session Modal` either via the `debugger: start` (default: f4) or by clicking the plus icon at the top right of the debug panel.
|
||||
|
||||
- Rust
|
||||
- Go
|
||||
- Python
|
||||
- JavaScript and TypeScript
|
||||
For more advanced use cases, you can create debug configurations by directly editing the `.zed/debug.json` file in your project root directory.
|
||||
|
||||
If you use one of these languages, the easiest way to get started with debugging in Zed is by opening the definition of the test or function you want to debug, clicking on the triangular "play" icon in the gutter, and selecting the debug task from the list that appears.
|
||||
|
||||
You can also see a contextual list of debug tasks for the current location by opening the new process modal with the `debugger: start` action (bound by default to <kbd>f4</kbd>).
|
||||
|
||||
The new process modal can also be used to manually start a debugging session. This is especially useful for languages like C, C++, and Swift that don't have zero-configuration debugging support in Zed. To start a basic debugging session manually from the modal, go to the "Launch" tab, then select a debug adapter from the dropdown menu and fill in the command line and working directory for the process you want to debug. You can pass environment variables to the debuggee process by using syntax like `ENV=var prog arg1 arg2` in the command line field.
|
||||
|
||||
For more advanced use-cases, you can create debug configurations by directly editing the `.zed/debug.json` file in your project root directory. These handwritten debug configurations also appear in the new process modal.
|
||||
You can then use the `New Session Modal` to select a configuration and start debugging.
|
||||
|
||||
### Launching & Attaching
|
||||
|
||||
@@ -213,11 +204,9 @@ the following configuration can be used:
|
||||
]
|
||||
```
|
||||
|
||||
#### Rust/C++/C Examples
|
||||
#### Rust/C++/C
|
||||
|
||||
Either CodeLLDB or GDB can be used for these languages. GDB is not supported on ARM Macs.
|
||||
|
||||
##### Debug a Pre-Built Binary
|
||||
##### Using pre-built binary
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -225,33 +214,24 @@ Either CodeLLDB or GDB can be used for these languages. GDB is not supported on
|
||||
"label": "Debug native binary",
|
||||
"program": "$ZED_WORKTREE_ROOT/build/binary",
|
||||
"request": "launch",
|
||||
"adapter": "CodeLLDB"
|
||||
"adapter": "CodeLLDB" // GDB is available on non arm macs as well as linux
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
##### Using a Build Task
|
||||
##### Build binary then debug
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"label": "Build & Debug Rust binary",
|
||||
"label": "Build & Debug native binary",
|
||||
"build": {
|
||||
"command": "cargo",
|
||||
"args": ["build"]
|
||||
},
|
||||
"program": "$ZED_WORKTREE_ROOT/target/debug/binary",
|
||||
"request": "launch",
|
||||
"adapter": "CodeLLDB"
|
||||
},
|
||||
{
|
||||
"label": "Build & Debug C++ binary",
|
||||
"build": {
|
||||
"command": "make"
|
||||
},
|
||||
"program": "$ZED_WORKTREE_ROOT/build/binary",
|
||||
"request": "launch",
|
||||
"adapter": "GDB"
|
||||
"adapter": "CodeLLDB" // GDB is available on non arm macs as well as linux
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -284,7 +264,7 @@ Given an externally-ran web server (e.g. with `npx serve` or `npx live-server`)
|
||||
## Breakpoints
|
||||
|
||||
To set a breakpoint, simply click next to the line number in the editor gutter.
|
||||
Breakpoints can be tweaked dependending on your needs; to access additional options of a given breakpoint, right-click on the breakpoint icon in the gutter and select the desired option.
|
||||
Breakpoints can be tweaked depending on your needs; to access additional options of a given breakpoint, right-click on the breakpoint icon in the gutter and select the desired option.
|
||||
At present, you can:
|
||||
|
||||
- Add a log to a breakpoint, which will output a log message whenever that breakpoint is hit.
|
||||
|
||||
Reference in New Issue
Block a user