Compare commits

..

5 Commits

Author SHA1 Message Date
Cole Miller
586fc30222 fix 2025-06-03 14:22:22 -04:00
Cole Miller
0a21521872 polish 2025-06-03 14:21:05 -04:00
Cole Miller
9029a34756 work 2025-06-03 14:17:57 -04:00
Cole Miller
b7f648ccb9 Merge remote-tracking branch 'origin/main' into debugger-c-docs 2025-06-03 14:05:13 -04:00
Cole Miller
483b675490 wip 2025-06-02 22:54:25 -04:00
54 changed files with 1026 additions and 1581 deletions

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 373 B

View File

@@ -278,9 +278,7 @@
"enter": "agent::Chat",
"ctrl-enter": "agent::ChatWithFollow",
"ctrl-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"ctrl-shift-y": "agent::KeepAll",
"ctrl-shift-n": "agent::RejectAll"
"shift-ctrl-r": "agent::OpenAgentDiff"
}
},
{

View File

@@ -315,9 +315,7 @@
"enter": "agent::Chat",
"cmd-enter": "agent::ChatWithFollow",
"cmd-i": "agent::ToggleProfileSelector",
"shift-ctrl-r": "agent::OpenAgentDiff",
"cmd-shift-y": "agent::KeepAll",
"cmd-shift-n": "agent::RejectAll"
"shift-ctrl-r": "agent::OpenAgentDiff"
}
},
{

View File

@@ -1497,11 +1497,11 @@
}
},
"LaTeX": {
"format_on_save": "on",
"formatter": "language_server",
"language_servers": ["texlab", "..."],
"prettier": {
"allowed": true,
"plugins": ["prettier-plugin-latex"]
"allowed": false
}
},
"Markdown": {

View File

@@ -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::{extract_message_creases, insert_message_creases};
use crate::message_editor::insert_message_creases;
use crate::thread::{
LastRestoreCheckpoint, MessageCrease, MessageId, MessageSegment, Thread, ThreadError,
ThreadEvent, ThreadFeedback, ThreadSummary,
@@ -1586,8 +1586,6 @@ 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)
@@ -1612,7 +1610,6 @@ impl ActiveThread {
message_id,
Role::User,
vec![MessageSegment::Text(edited_text)],
creases,
Some(context.loaded_context),
checkpoint.ok(),
cx,
@@ -3680,13 +3677,10 @@ fn open_editor_at_position(
#[cfg(test)]
mod tests {
use assistant_tool::{ToolRegistry, ToolWorkingSet};
use editor::{EditorSettings, display_map::CreaseMetadata};
use editor::EditorSettings;
use fs::FakeFs;
use gpui::{AppContext, TestAppContext, VisualTestContext};
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelRegistry,
fake_provider::{FakeLanguageModel, FakeLanguageModelProvider},
};
use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
use project::Project;
use prompt_store::PromptBuilder;
use serde_json::json;
@@ -3747,87 +3741,6 @@ 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);

View File

@@ -926,9 +926,8 @@ impl CompletionProvider for ContextPickerCompletionProvider {
&self,
buffer: &Entity<language::Buffer>,
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_: &str,
_: bool,
cx: &mut Context<Editor>,
) -> bool {
let buffer = buffer.read(cx);

View File

@@ -51,10 +51,6 @@ 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)?;

View File

@@ -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::{
MaxModeTooltip,
AnimatedLabel, 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, Point};
use language::{Buffer, Language};
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, KeepAll,
NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker,
ToggleProfileSelector, register_agent_preview,
ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread,
OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector,
register_agent_preview,
};
#[derive(RegisterComponent)]
@@ -459,20 +459,11 @@ 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>,
@@ -503,40 +494,6 @@ 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();
@@ -658,12 +615,6 @@ 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()
@@ -919,10 +870,7 @@ 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 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.";
let is_generating = self.thread.read(cx).is_generating();
v_flex()
.mt_1()
@@ -940,28 +888,31 @@ impl MessageEditor {
}])
.child(
h_flex()
.p_1()
.id("edits-container")
.cursor_pointer()
.p_1p5()
.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, _, _, cx| {
this.handle_edit_bar_expand(cx)
.on_click(cx.listener(|this, _ev, _window, cx| {
this.edits_expanded = !this.edits_expanded;
cx.notify();
})),
)
.map(|this| {
if pending_edits {
if is_generating {
this.child(
Label::new(format!(
"Editing {} {}",
AnimatedLabel::new(format!(
"Editing {} {}",
changed_buffers.len(),
if changed_buffers.len() == 1 {
"file"
@@ -969,15 +920,7 @@ impl MessageEditor {
"files"
}
))
.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),
),
.size(LabelSize::Small),
)
} else {
this.child(
@@ -1002,74 +945,23 @@ impl MessageEditor {
.color(Color::Muted),
)
}
})
.on_click(
cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)),
),
}),
)
.child(
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)
})),
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.))),
)
.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)
})),
),
.on_click(cx.listener(|this, _, window, cx| {
this.handle_review_click(window, cx)
})),
),
)
.when(is_edit_changes_expanded, |parent| {

View File

@@ -871,16 +871,7 @@ impl Thread {
self.tool_use
.pending_tool_uses()
.iter()
.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)
.all(|tool_use| tool_use.status.is_error())
}
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
@@ -1032,7 +1023,6 @@ 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>,
@@ -1042,7 +1032,6 @@ impl Thread {
};
message.role = new_role;
message.segments = new_segments;
message.creases = creases;
if let Some(context) = loaded_context {
message.loaded_context = context;
}

View File

@@ -70,15 +70,13 @@ impl Column for DataType {
}
}
const RULES_FILE_NAMES: [&'static str; 8] = [
const RULES_FILE_NAMES: [&'static str; 6] = [
".rules",
".cursorrules",
".windsurfrules",
".clinerules",
".github/copilot-instructions.md",
"CLAUDE.md",
"AGENT.md",
"AGENTS.md",
];
pub fn init(cx: &mut App) {

View File

@@ -337,12 +337,6 @@ 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 {
@@ -351,7 +345,6 @@ impl ToolUseState {
name: tool_use.name.clone(),
ui_text: ui_text.clone(),
input: tool_use.input,
may_perform_edits,
status,
},
);
@@ -525,7 +518,6 @@ pub struct PendingToolUse {
pub ui_text: Arc<str>,
pub input: serde_json::Value,
pub status: PendingToolUseStatus,
pub may_perform_edits: bool,
}
#[derive(Debug, Clone)]

View File

@@ -342,7 +342,6 @@ 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);

View File

@@ -218,9 +218,6 @@ 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()))

View File

@@ -48,10 +48,6 @@ impl Tool for CopyPathTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("./copy_path_tool/description.md").into()
}

View File

@@ -33,16 +33,12 @@ 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 may_perform_edits(&self) -> bool {
false
fn description(&self) -> String {
include_str!("./create_directory_tool/description.md").into()
}
fn icon(&self) -> IconName {

View File

@@ -37,10 +37,6 @@ impl Tool for DeletePathTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("./delete_path_tool/description.md").into()
}

View File

@@ -50,10 +50,6 @@ impl Tool for DiagnosticsTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./diagnostics_tool/description.md").into()
}

View File

@@ -129,10 +129,6 @@ 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()
}

View File

@@ -118,11 +118,7 @@ impl Tool for FetchTool {
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
false
}
fn may_perform_edits(&self) -> bool {
false
true
}
fn description(&self) -> String {

View File

@@ -59,10 +59,6 @@ impl Tool for FindPathTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./find_path_tool/description.md").into()
}

View File

@@ -60,10 +60,6 @@ impl Tool for GrepTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./grep_tool/description.md").into()
}

View File

@@ -48,10 +48,6 @@ impl Tool for ListDirectoryTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./list_directory_tool/description.md").into()
}

View File

@@ -46,10 +46,6 @@ impl Tool for MovePathTool {
false
}
fn may_perform_edits(&self) -> bool {
true
}
fn description(&self) -> String {
include_str!("./move_path_tool/description.md").into()
}

View File

@@ -37,10 +37,6 @@ 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()
}

View File

@@ -26,9 +26,7 @@ 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()
}

View File

@@ -58,10 +58,6 @@ impl Tool for ReadFileTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./read_file_tool/description.md").into()
}

View File

@@ -80,10 +80,6 @@ impl Tool for TerminalTool {
true
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./terminal_tool/description.md").to_string()
}

View File

@@ -28,10 +28,6 @@ impl Tool for ThinkingTool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./thinking_tool/description.md").to_string()
}

View File

@@ -36,10 +36,6 @@ 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()
}

View File

@@ -89,7 +89,6 @@ impl CompletionProvider for MessageEditorCompletionProvider {
_position: language::Anchor,
text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_cx: &mut Context<Editor>,
) -> bool {
text == "@"

View File

@@ -309,7 +309,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider {
_position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_cx: &mut Context<Editor>,
) -> bool {
true

View File

@@ -194,7 +194,6 @@ 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>>,
@@ -209,6 +208,7 @@ 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,13 +227,6 @@ 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) {
@@ -244,9 +237,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,
@@ -265,13 +258,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([]))),
@@ -335,7 +328,6 @@ impl CompletionsMenu {
.collect();
Self {
id,
source: CompletionsMenuSource::SnippetChoices,
sort_completions,
initial_position: selection.start,
initial_query: None,
@@ -350,6 +342,7 @@ 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,

View File

@@ -2512,9 +2512,7 @@ pub mod tests {
cx.update(|cx| syntax_chunks(DisplayRow(0)..DisplayRow(5), &map, &theme, cx)),
[
("fn \n".to_string(), None),
("oute".to_string(), Some(Hsla::blue())),
("\n".to_string(), None),
("r".to_string(), Some(Hsla::blue())),
("oute\nr".to_string(), Some(Hsla::blue())),
("() \n{}\n\n".to_string(), None),
]
);
@@ -2537,11 +2535,8 @@ pub mod tests {
[
("out".to_string(), Some(Hsla::blue())),
("\n".to_string(), None),
(" ".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)
(" \nfn ".to_string(), Some(Hsla::red())),
("i\n".to_string(), Some(Hsla::blue()))
]
);
}

View File

@@ -933,7 +933,7 @@ impl<'a> Iterator for WrapChunks<'a> {
self.transforms.next(&());
return Some(Chunk {
text: &display_text[start_ix..end_ix],
..Default::default()
..self.input_chunk.clone()
});
}

View File

@@ -211,11 +211,8 @@ 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;
@@ -4513,40 +4510,30 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let completions_source = self
let ignore_completion_provider = self
.context_menu
.borrow()
.as_ref()
.and_then(|menu| match menu {
CodeContextMenu::Completions(completions_menu) => Some(completions_menu.source),
CodeContextMenu::CodeActions(_) => None,
});
.map(|menu| match menu {
CodeContextMenu::Completions(completions_menu) => {
completions_menu.ignore_completion_provider
}
CodeContextMenu::CodeActions(_) => false,
})
.unwrap_or(false);
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);
}
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);
}
}
@@ -4554,7 +4541,6 @@ 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();
@@ -4572,7 +4558,6 @@ impl Editor {
position.text_anchor,
text,
trigger_in_words,
menu_is_open,
cx,
)
} else {
@@ -5023,7 +5008,7 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_or_update_completions_menu(Some(CompletionsMenuSource::Words), None, window, cx);
self.open_or_update_completions_menu(true, None, window, cx);
}
pub fn show_completions(
@@ -5032,12 +5017,12 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_or_update_completions_menu(None, options.trigger.as_deref(), window, cx);
self.open_or_update_completions_menu(false, options.trigger.as_deref(), window, cx);
}
fn open_or_update_completions_menu(
&mut self,
requested_source: Option<CompletionsMenuSource>,
ignore_completion_provider: bool,
trigger: Option<&str>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -5062,13 +5047,10 @@ impl Editor {
Self::completion_query(&self.buffer.read(cx).read(cx), position)
.map(|query| query.into());
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 provider = if ignore_completion_provider {
None
} else {
self.completion_provider.clone()
};
let sort_completions = provider
@@ -5124,15 +5106,14 @@ impl Editor {
trigger_kind,
};
let (word_replace_range, word_to_exclude) = if let (word_range, Some(CharKind::Word)) =
buffer_snapshot.surrounding_word(buffer_position)
{
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_to_exclude = buffer_snapshot
.text_for_range(word_range.clone())
.text_for_range(replace_range.clone())
.collect::<String>();
(
buffer_snapshot.anchor_before(word_range.start)
..buffer_snapshot.anchor_after(buffer_position),
buffer_snapshot.anchor_before(replace_range.start)
..buffer_snapshot.anchor_after(replace_range.end),
Some(word_to_exclude),
)
} else {
@@ -5240,7 +5221,7 @@ impl Editor {
words.remove(&lsp_completion.new_text);
}
completions.extend(words.into_iter().map(|(word, word_range)| Completion {
replace_range: word_replace_range.clone(),
replace_range: replace_range.clone(),
new_text: word.clone(),
label: CodeLabel::plain(word, None),
icon_path: None,
@@ -5264,9 +5245,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,
@@ -5550,12 +5531,14 @@ impl Editor {
}
}
let common_prefix_len = old_text
.chars()
.zip(new_text.chars())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum::<usize>();
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;
}
}
cx.emit(EditorEvent::InputHandled {
utf16_range_to_replace: None,
@@ -20311,7 +20294,6 @@ pub trait CompletionProvider {
position: language::Anchor,
text: &str,
trigger_in_words: bool,
menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool;
@@ -20629,7 +20611,6 @@ 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();
@@ -20644,7 +20625,7 @@ impl CompletionProvider for Entity<Project> {
let buffer = buffer.read(cx);
let snapshot = buffer.snapshot();
if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input {
if !snapshot.settings_at(position, cx).show_completions_on_input {
return false;
}
let classifier = snapshot.char_classifier_at(position).for_completion(true);

View File

@@ -7618,7 +7618,10 @@ 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::Minimap { .. }) {
if matches!(
editor.mode,
EditorMode::AutoHeight { .. } | EditorMode::Minimap { .. }
) {
snapshot
} else {
let wrap_width_for = |column: u32| (column as f32 * em_advance).ceil();
@@ -9639,7 +9642,6 @@ 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
@@ -9656,18 +9658,10 @@ fn compute_auto_height_layout(
let overscroll = size(em_width, px(0.));
let editor_width = text_width - gutter_dimensions.margin - overscroll.width - em_width;
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);
if !matches!(editor.soft_wrap_mode(cx), SoftWrap::None) {
if editor.set_wrap_width(Some(editor_width), cx) {
snapshot = editor.snapshot(window, cx);
}
}
let scroll_height = (snapshot.max_point().row().next_row().0 as f32) * line_height;

View File

@@ -101,10 +101,7 @@ pub fn init(cx: &mut App) {
directories: true,
multiple: false,
},
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
),
DirectoryLister::Local(workspace.app_state().fs.clone()),
window,
cx,
);

View File

@@ -4,6 +4,7 @@ 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;
@@ -19,6 +20,7 @@ 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};
@@ -83,8 +85,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 {

View File

@@ -0,0 +1,526 @@
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()),
})
}
}
}
}

View File

@@ -2,7 +2,6 @@ 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;
@@ -13,136 +12,61 @@ use std::{
atomic::{self, AtomicBool},
},
};
use ui::{Context, LabelLike, ListItem, Window};
use ui::{Context, 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: DirectoryState,
directory_state: Option<DirectoryState>,
matches: Vec<usize>,
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,
creating_path: bool,
) -> Self {
pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
Self {
tx: Some(tx),
lister,
selected_index: 0,
directory_state: DirectoryState::None {
create: creating_path,
},
directory_state: None,
matches: Vec::new(),
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> {
match &self.directory_state {
DirectoryState::List { entries, .. } => self
.string_matches
if let Some(state) = self.directory_state.as_ref() {
self.matches
.iter()
.filter_map(|string_match| {
entries
.iter()
.find(|entry| entry.path.id == string_match.candidate_id)
.filter_map(|&index| {
state
.match_candidates
.get(index)
.map(|candidate| candidate.path.string.clone())
})
.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(),
.collect()
} else {
Vec::new()
}
}
}
#[derive(Debug)]
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,
struct DirectoryState {
path: String,
match_candidates: Vec<CandidateInfo>,
error: Option<SharedString>,
}
#[derive(Debug, Clone)]
@@ -159,19 +83,7 @@ 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, 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);
Self::prompt_for_open_path(workspace, lister, tx, window, cx);
rx
}));
}
@@ -179,13 +91,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(), creating_path);
let delegate = OpenPathDelegate::new(tx, lister.clone());
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
let query = lister.default_query(cx);
picker.set_query(query, window, cx);
@@ -198,16 +110,7 @@ impl PickerDelegate for OpenPathDelegate {
type ListItem = ui::ListItem;
fn match_count(&self) -> usize {
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
self.matches.len()
}
fn selected_index(&self) -> usize {
@@ -224,196 +127,127 @@ impl PickerDelegate for OpenPathDelegate {
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let lister = &self.lister;
let last_item = Path::new(&query)
) -> gpui::Task<()> {
let lister = self.lister.clone();
let query_path = Path::new(&query);
let last_item = query_path
.file_name()
.unwrap_or_default()
.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())
.to_string_lossy()
.to_string();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
(dir.to_string(), last_item)
} else {
(query, String::new())
};
if dir == "" {
dir = PROMPT_ROOT.to_string();
#[cfg(not(target_os = "windows"))]
{
dir = "/".to_string();
}
#[cfg(target_os = "windows")]
{
dir = "C:\\".to_string();
}
}
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)),
let query = if self
.directory_state
.as_ref()
.map_or(false, |s| s.path == dir)
{
None
} else {
Some(lister.list_directory(dir.clone(), cx))
};
self.cancel_flag.store(true, atomic::Ordering::Release);
self.cancel_flag.store(true, atomic::Ordering::Relaxed);
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::Acquire) {
if cancel_flag.load(atomic::Ordering::Relaxed) {
return;
}
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
});
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(),
});
}
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;
}
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 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()
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;
}
Some(directory_state.match_candidates.clone())
})
else {
.unwrap_or(None);
let Some(mut match_candidates) = match_candidates else {
return;
};
if !suffix.starts_with('.') {
new_entries.retain(|entry| !entry.path.string.starts_with('.'));
match_candidates.retain(|m| !m.path.string.starts_with('.'));
}
if suffix.is_empty() {
if suffix == "" {
this.update(cx, |this, cx| {
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,
},
};
this.delegate.matches.clear();
this.delegate.string_matches.clear();
this.delegate
.matches
.extend(match_candidates.iter().map(|m| m.path.id));
cx.notify();
})
.ok();
return;
}
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 candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
let matches = fuzzy::match_strings(
candidates.as_slice(),
&suffix,
@@ -423,57 +257,27 @@ impl PickerDelegate for OpenPathDelegate {
cx.background_executor().clone(),
)
.await;
if cancel_flag.load(atomic::Ordering::Acquire) {
if cancel_flag.load(atomic::Ordering::Relaxed) {
return;
}
this.update(cx, |this, cx| {
this.delegate.selected_index = 0;
this.delegate.matches.clear();
this.delegate.string_matches = matches.clone();
this.delegate.string_matches.sort_by_key(|m| {
this.delegate
.matches
.extend(matches.into_iter().map(|m| m.candidate_id));
this.delegate.matches.sort_by_key(|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.directory_state.as_ref().and_then(|d| {
d.match_candidates
.get(*m)
.map(|c| !c.path.string.starts_with(&suffix))
}),
*m,
)
});
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,
}),
}
}
};
this.delegate.selected_index = 0;
cx.notify();
})
.ok();
@@ -486,107 +290,49 @@ impl PickerDelegate for OpenPathDelegate {
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) -> Option<String> {
let candidate = self.get_entry(self.selected_index)?;
Some(
maybe!({
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,
}
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 {
""
}
))
})
.unwrap_or(query),
)
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(candidate) = self.get_entry(self.selected_index) else {
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(m) = self.matches.get(self.selected_index) else {
return;
};
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();
}
}
},
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();
}
cx.emit(gpui::DismissEvent);
}
@@ -605,30 +351,19 @@ 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 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 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 file_icon = maybe!({
if !settings.file_icons {
@@ -643,128 +378,34 @@ impl PickerDelegate for OpenPathDelegate {
Some(Icon::from_path(icon).color(Color::Muted))
});
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,
}
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,
)),
)
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
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")
}
})
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)
}
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()
}

View File

@@ -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, false, cx);
let (picker, cx) = build_open_path_prompt(project, 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, false, cx);
let (picker, cx) = build_open_path_prompt(project, 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, false, cx);
let (picker, cx) = build_open_path_prompt(project, cx);
// Support both forward and backward slashes.
let query = "C:/root/";
@@ -251,54 +251,6 @@ 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);
@@ -314,12 +266,11 @@ 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(), creating_path);
let delegate = OpenPathDelegate::new(tx, lister.clone());
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
(

View File

@@ -155,7 +155,6 @@ pub enum IconName {
LineHeight,
Link,
ListCollapse,
ListTodo,
ListTree,
ListX,
LoadCircle,

View File

@@ -685,9 +685,8 @@ impl CompletionProvider for RustStyleCompletionProvider {
&self,
buffer: &Entity<language::Buffer>,
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
_: &str,
_: bool,
cx: &mut Context<Editor>,
) -> bool {
completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some()

View File

@@ -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).take(128).peekable();
let mut prev_chars = self.reversed_chars_at(start).take(128).peekable();
let mut next_chars = self.chars_at(start).peekable();
let mut prev_chars = self.reversed_chars_at(start).peekable();
let classifier = self.char_classifier_at(start);
let word_kind = cmp::max(

View File

@@ -343,8 +343,6 @@ 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()

View File

@@ -770,26 +770,13 @@ pub struct DirectoryItem {
#[derive(Clone)]
pub enum DirectoryLister {
Project(Entity<Project>),
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:?})")
}
}
}
Local(Arc<dyn Fs>),
}
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(),
}
}
@@ -803,28 +790,12 @@ impl DirectoryLister {
}
pub fn default_query(&self, cx: &mut App) -> 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}")
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();
}
})
};
format!("~{}", std::path::MAIN_SEPARATOR_STR)
}
pub fn list_directory(&self, path: String, cx: &mut App) -> Task<Result<Vec<DirectoryItem>>> {
@@ -832,7 +803,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![];
@@ -4078,7 +4049,7 @@ impl Project {
cx: &mut Context<Self>,
) -> Task<Result<Vec<DirectoryItem>>> {
if self.is_local() {
DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
DirectoryLister::Local(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 {

View File

@@ -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, transparent_white, uniform_list,
anchored, deferred, div, impl_actions, point, px, size, uniform_list,
};
use indexmap::IndexMap;
use language::DiagnosticSeverity;
@@ -85,7 +85,8 @@ pub struct ProjectPanel {
ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
last_worktree_root_id: Option<ProjectEntryId>,
drag_target_entry: Option<DragTargetEntry>,
last_selection_drag_over_entry: Option<ProjectEntryId>,
last_external_paths_drag_over_entry: Option<ProjectEntryId>,
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
@@ -111,13 +112,6 @@ 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,
@@ -478,8 +472,9 @@ 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,
@@ -3708,67 +3703,6 @@ 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,
@@ -3811,8 +3745,6 @@ 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;
@@ -3870,27 +3802,6 @@ 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)
@@ -3904,111 +3815,95 @@ 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| {
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;
if event.bounds.contains(&event.event.position) {
if this.last_external_paths_drag_over_entry == Some(entry_id) {
return;
}
return;
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();
}
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| {
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;
if event.bounds.contains(&event.event.position) {
if this.last_selection_drag_over_entry == Some(entry_id) {
return;
}
return;
this.last_selection_drag_over_entry = Some(entry_id);
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.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();
}));
}
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(
@@ -4022,10 +3917,14 @@ impl ProjectPanel {
})
},
)
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
.drag_over::<DraggedSelection>(move |style, _, _, _| {
if folded_directory_drag_target.is_some() {
return style;
}
style.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() {
@@ -4227,7 +4126,6 @@ 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);
@@ -4310,7 +4208,6 @@ 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);
@@ -4676,14 +4573,13 @@ impl Render for ProjectPanel {
.map(|(_, worktree_entries, _)| worktree_entries.len())
.sum();
fn handle_drag_move<T: 'static>(
fn handle_drag_move_scroll<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();
@@ -4737,8 +4633,8 @@ impl Render for ProjectPanel {
h_flex()
.id("project-panel")
.group("project-panel")
.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
.on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
.on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
.on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
.size_full()
.relative()
.on_hover(cx.listener(|this, hovered, window, cx| {
@@ -4994,7 +4890,8 @@ impl Render for ProjectPanel {
})
.on_drop(cx.listener(
move |this, external_paths: &ExternalPaths, window, cx| {
this.drag_target_entry = None;
this.last_external_paths_drag_over_entry = None;
this.marked_entries.clear();
this.hover_scroll_task.take();
if let Some(task) = this
.workspace

View File

@@ -5098,205 +5098,6 @@ 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| {

View File

@@ -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, false);
let delegate = file_finder::OpenPathDelegate::new(tx, lister);
let picker = cx.new(|cx| {
let picker = Picker::uniform_list(delegate, window, cx)

View File

@@ -25,7 +25,7 @@ use gpui::{
use itertools::Itertools;
use language::DiagnosticSeverity;
use parking_lot::Mutex;
use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, WorktreeId};
use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
use schemars::JsonSchema;
use serde::Deserialize;
use settings::{Settings, SettingsStore};
@@ -1921,56 +1921,24 @@ impl Pane {
})?
.await?;
} else if can_save_as && is_singleton {
let new_path = pane.update_in(cx, |pane, window, cx| {
let abs_path = pane.update_in(cx, |pane, window, cx| {
pane.activate_item(item_ix, true, true, window, cx);
pane.workspace.update(cx, |workspace, 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)
workspace.prompt_for_new_path(window, cx)
})
})??;
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(),
};
if let Some(abs_path) = abs_path.await.ok().flatten() {
pane.update_in(cx, |pane, window, cx| {
if let Some(item) = pane.item_for_path(new_path.clone(), cx) {
if let Some(item) = pane.item_for_path(abs_path.clone(), cx) {
pane.remove_item(item.item_id(), false, false, window, cx);
}
item.save_as(project, new_path, window, cx)
item.save_as(project, abs_path, window, cx)
})?
.await?;
} else {
return Ok(false);
};
save_task.await?;
return Ok(true);
}
}
}

View File

@@ -899,10 +899,9 @@ pub enum OpenVisible {
type PromptForNewPath = Box<
dyn Fn(
&mut Workspace,
DirectoryLister,
&mut Window,
&mut Context<Workspace>,
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
) -> oneshot::Receiver<Option<ProjectPath>>,
>;
type PromptForOpenPath = Box<
@@ -1875,25 +1874,25 @@ impl Workspace {
let (tx, rx) = oneshot::channel();
let abs_path = cx.prompt_for_paths(path_prompt_options);
cx.spawn_in(window, async move |workspace, cx| {
cx.spawn_in(window, async move |this, cx| {
let Ok(result) = abs_path.await else {
return Ok(());
};
match result {
Ok(result) => {
tx.send(result).ok();
tx.send(result).log_err();
}
Err(err) => {
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);
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);
rx
})?;
if let Ok(path) = rx.await {
tx.send(path).ok();
tx.send(path).log_err();
}
}
};
@@ -1907,58 +1906,77 @@ impl Workspace {
pub fn prompt_for_new_path(
&mut self,
lister: DirectoryLister,
window: &mut Window,
cx: &mut Context<Self>,
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
if self.project.read(cx).is_via_collab()
|| self.project.read(cx).is_via_ssh()
) -> oneshot::Receiver<Option<ProjectPath>> {
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, lister, window, cx);
let rx = prompt(self, window, cx);
self.on_prompt_for_new_path = Some(prompt);
return rx;
}
let (tx, rx) = oneshot::channel();
cx.spawn_in(window, async move |workspace, cx| {
let abs_path = workspace.update(cx, |workspace, cx| {
let relative_to = workspace
cx.spawn_in(window, async move |this, cx| {
let abs_path = this.update(cx, |this, cx| {
let mut relative_to = this
.most_recent_active_path(cx)
.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| {
.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| {
Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
})
})
.or_else(std::env::home_dir)
.unwrap_or_else(|| PathBuf::from(""));
cx.prompt_for_new_path(&relative_to)
.next()
};
cx.prompt_for_new_path(&relative_to.unwrap_or_else(|| PathBuf::from("")))
})?;
let abs_path = match abs_path.await? {
Ok(path) => path,
Err(err) => {
let rx = workspace.update_in(cx, |workspace, window, cx| {
workspace.show_portal_error(err.to_string(), cx);
let rx = this.update_in(cx, |this, window, cx| {
this.show_portal_error(err.to_string(), cx);
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);
let prompt = this.on_prompt_for_new_path.take().unwrap();
let rx = prompt(this, window, cx);
this.on_prompt_for_new_path = Some(prompt);
rx
})?;
if let Ok(path) = rx.await {
tx.send(path).ok();
tx.send(path).log_err();
}
return anyhow::Ok(());
}
};
tx.send(abs_path.map(|path| vec![path])).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();
}
anyhow::Ok(())
})
.detach();
.detach_and_log_err(cx);
rx
}

View File

@@ -503,10 +503,7 @@ fn register_actions(
directories: true,
multiple: true,
},
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
),
DirectoryLister::Local(workspace.app_state().fs.clone()),
window,
cx,
);

View File

@@ -5,17 +5,8 @@ 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 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`
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 supportedthe first file which matches in this list will be used: `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.github/copilot-instructions.md`, or `CLAUDE.md`.
## Rules Library {#rules-library}

View File

@@ -28,11 +28,20 @@ Additionally, Ruby support (via rdbg) is being actively worked on.
## Getting Started
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.
Zed supports zero-configuration debugging of tests and main functions in several popular languages:
For more advanced use cases, you can create debug configurations by directly editing the `.zed/debug.json` file in your project root directory.
- Rust
- Go
- Python
- JavaScript and TypeScript
You can then use the `New Session Modal` to select a configuration and start debugging.
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.
### Launching & Attaching
@@ -204,9 +213,11 @@ the following configuration can be used:
]
```
#### Rust/C++/C
#### Rust/C++/C Examples
##### Using pre-built binary
Either CodeLLDB or GDB can be used for these languages. GDB is not supported on ARM Macs.
##### Debug a Pre-Built Binary
```json
[
@@ -214,24 +225,33 @@ the following configuration can be used:
"label": "Debug native binary",
"program": "$ZED_WORKTREE_ROOT/build/binary",
"request": "launch",
"adapter": "CodeLLDB" // GDB is available on non arm macs as well as linux
"adapter": "CodeLLDB"
}
]
```
##### Build binary then debug
##### Using a Build Task
```json
[
{
"label": "Build & Debug native binary",
"label": "Build & Debug Rust binary",
"build": {
"command": "cargo",
"args": ["build"]
},
"program": "$ZED_WORKTREE_ROOT/target/debug/binary",
"request": "launch",
"adapter": "CodeLLDB" // GDB is available on non arm macs as well as linux
"adapter": "CodeLLDB"
},
{
"label": "Build & Debug C++ binary",
"build": {
"command": "make"
},
"program": "$ZED_WORKTREE_ROOT/build/binary",
"request": "launch",
"adapter": "GDB"
}
]
```
@@ -264,7 +284,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 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.
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.
At present, you can:
- Add a log to a breakpoint, which will output a log message whenever that breakpoint is hit.