Compare commits

...

16 Commits

Author SHA1 Message Date
Angelk90
557531bebe Add test 2025-06-09 11:56:45 +05:30
Angelk90
158ea75d85 Fix 2025-06-09 11:56:45 +05:30
Angelk90
42717f5f4a Fix multiple worktrees open 2025-06-09 11:56:41 +05:30
Angelk90
668b60690a Fix comment 2025-06-09 11:43:15 +05:30
Angelk90
4b1d61698a Init 2025-06-09 11:43:15 +05:30
CharlesChen0823
4fe05530b0 editor: Add support for drag_and_drop_selection (#30671)
Closes #4958 

Release Notes:

- Added support for drag and drop text selection. It can be disabled by
setting `drag_and_drop_selection` to `false`.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-06-09 10:21:18 +05:30
Joseph T. Lyons
b15aef4310 Introduce dynamic tab titles for unsaved files based on buffer content (#32353)
https://github.com/user-attachments/assets/0bb08784-251c-4221-890a-2d6b3fb94e0f

For new, unsaved files:

- If a buffer has no content, or contains only whitespace, use
`untitled`
- If a buffer has content, take the first 40 chars of the first line

| Sublime | VS Code | Zed |
|---------|---------|-----|
| <img width="227" alt="SCR-20250608-ouux"
src="https://github.com/user-attachments/assets/d02b1e50-5775-4252-86e6-6c9d3f6c72fb"
/> | <img width="230" alt="SCR-20250608-ousn"
src="https://github.com/user-attachments/assets/7c9c016b-642f-4a80-9bc1-8c9bdc7bbd32"
/> | <img width="242" alt="SCR-20250608-ovbg"
src="https://github.com/user-attachments/assets/c7f4be5c-5bba-4a2a-b477-1392ca938cd5"
/> |

Note that this implementation also trims all leading whitespace, so that
if the buffer has any non-whitespace content, we use it. VS Code and
Sublime do not do this.

| Sublime | VS Code | Zed |
|---------|---------|-----|
| <img width="233" alt="SCR-20250608-oviq"
src="https://github.com/user-attachments/assets/ccffecc6-0f46-4d1b-8739-740240bc067b"
/> | <img width="198" alt="SCR-20250608-ovkq"
src="https://github.com/user-attachments/assets/35c20149-f898-417b-aff3-dda22b8cc1f3"
/> | <img width="233" alt="SCR-20250608-ovns"
src="https://github.com/user-attachments/assets/2509e8f6-254b-4fcb-a0ea-e18e95bb685b"
/> |

Release Notes:

- Introduced dynamic tab titles for unsaved files based on buffer
content
2025-06-08 17:30:33 -04:00
Michael Sloan
23adff6ff2 Add CI check that cmd- is not in linux keymaps + check other mods (#32334)
Motivation for the `cmd-` check is that there were a couple keybindings
using `cmd-` in the linux keymap and so these were bound to super /
windows

Release Notes:

- N/A
2025-06-08 09:34:07 +00:00
Michael Sloan
866fe427b3 Cleanup comments in linux keymaps (#32333)
Release Notes:

- N/A
2025-06-08 09:02:52 +00:00
Michael Sloan
f7b2faf64f Fix a few linux keybindings that use cmd- instead of ctrl- (#32332)
Release Notes:

- N/A
2025-06-08 08:50:35 +00:00
Kirill Bulatov
5187954711 Remove previous multi buffer hardcode from the outline panel (#32321)
Closes https://github.com/zed-industries/zed/issues/32316

Multi buffer design was changed so that control buttons are not
occupying extra lines, the hardcoded logic for that is obsolete thus
removed.

Release Notes:

- Fixed incorrect offsets during outline panel navigation in singleton
buffers
2025-06-07 23:54:47 +00:00
Michael Sloan
cabd22f36b No longer instantiate recently opened agent threads on startup (#32285)
This was causing a lot of work on startup, particularly due to
instantiating edit tool cards. The minor downside is that now these
threads don't open quite as fast.

Includes a few other improvements:

* On text thread rename, now immediately updates the metadata for
display in the UI instead of waiting for reload.

* On text thread rename, first renames the file before writing. Before
if the file removal failed you'd end up with a duplicate.

* Now only stores text thread file names instead of full paths. This is
more concise and allows for the app data dir changing location.

* Renames `ThreadStore::unordered_threads` to
`ThreadStore::reverse_chronological_threads` (and removes the old one
that sorted), since the recent change to use a SQL database queries them
in that order.

* Removes `ContextStore::reverse_chronological_contexts` since it was
only used in one location where it does sorting anyway - no need to sort
twice.

* `SavedContextMetadata::title` is now `SharedString` instead of
`String`.

Release Notes:

- Fixed regression in startup performance by not deserializing and
instantiating recently opened agent threads.
2025-06-07 14:53:36 -06:00
Tommy D. Rossi
1552198b55 Cursor keymap: Add cmd-enter to submit inline assistant (#32295)
Closes https://github.com/zed-industries/zed/discussions/29035

Release Notes:

- N/A
2025-06-07 16:37:45 -04:00
Richard Feldman
0da97b0c8b editor: Respect multi_cursor_modifier setting when making columnar selections using mouse (#32273)
Closes https://github.com/zed-industries/zed/issues/31181

Release Notes:

- Added the `multi_cursor_modifier` setting to be respected when making
columnar selections using the mouse drag.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-06-08 01:51:13 +05:30
Joseph T. Lyons
037df8cec5 Simplify logic updating pinned tab count (#32310)
Just a tiny improvement to clean up the logic

Release Notes:

- N/A
2025-06-07 19:15:30 +00:00
Peter Tripp
05ac9f1f84 docs: Missing . from .sql-formatter.json (#32312)
See:
https://github.com/zed-industries/zed/issues/9537#issuecomment-2952784074

Release Notes:

- N/A
2025-06-07 19:11:19 +00:00
30 changed files with 815 additions and 380 deletions

View File

@@ -183,6 +183,9 @@ jobs:
- name: Check for todo! and FIXME comments
run: script/check-todos
- name: Check modifier use in keymaps
run: script/check-keymaps
- name: Run style checks
uses: ./.github/actions/check_style

View File

@@ -35,7 +35,7 @@
"ctrl-shift-f5": "debugger::Restart",
"f6": "debugger::Pause",
"f7": "debugger::StepOver",
"cmd-f11": "debugger::StepInto",
"ctrl-f11": "debugger::StepInto",
"shift-f11": "debugger::StepOut",
"f11": "zed::ToggleFullScreen",
"ctrl-alt-z": "edit_prediction::RateCompletions",
@@ -59,7 +59,6 @@
"tab": "editor::Tab",
"shift-tab": "editor::Backtab",
"ctrl-k": "editor::CutToEndOfLine",
// "ctrl-t": "editor::Transpose",
"ctrl-k ctrl-q": "editor::Rewrap",
"ctrl-k q": "editor::Rewrap",
"ctrl-backspace": "editor::DeleteToPreviousWordStart",
@@ -100,21 +99,16 @@
"shift-down": "editor::SelectDown",
"shift-left": "editor::SelectLeft",
"shift-right": "editor::SelectRight",
"ctrl-shift-left": "editor::SelectToPreviousWordStart", // cursorWordLeftSelect
"ctrl-shift-right": "editor::SelectToNextWordEnd", // cursorWordRightSelect
"ctrl-shift-left": "editor::SelectToPreviousWordStart",
"ctrl-shift-right": "editor::SelectToNextWordEnd",
"ctrl-shift-home": "editor::SelectToBeginning",
"ctrl-shift-end": "editor::SelectToEnd",
"ctrl-a": "editor::SelectAll",
"ctrl-l": "editor::SelectLine",
"ctrl-shift-i": "editor::Format",
"alt-shift-o": "editor::OrganizeImports",
// "cmd-shift-left": ["editor::SelectToBeginningOfLine", {"stop_at_soft_wraps": true, "stop_at_indent": true }],
// "ctrl-shift-a": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }],
"shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }],
// "cmd-shift-right": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
// "ctrl-shift-e": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
"shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
// "alt-v": ["editor::MovePageUp", { "center_cursor": true }],
"ctrl-alt-space": "editor::ShowCharacterPalette",
"ctrl-;": "editor::ToggleLineNumbers",
"ctrl-'": "editor::ToggleSelectedDiffHunks",
@@ -140,7 +134,6 @@
"find": "buffer_search::Deploy",
"ctrl-f": "buffer_search::Deploy",
"ctrl-h": "buffer_search::DeployReplace",
// "cmd-e": ["buffer_search::Deploy", { "focus": false }],
"ctrl->": "assistant::QuoteSelection",
"ctrl-<": "assistant::InsertIntoEditor",
"ctrl-alt-e": "editor::SelectEnclosingSymbol",
@@ -267,8 +260,8 @@
{
"context": "AgentPanel && prompt_editor",
"bindings": {
"cmd-n": "agent::NewTextThread",
"cmd-alt-t": "agent::NewThread"
"ctrl-n": "agent::NewTextThread",
"ctrl-alt-t": "agent::NewThread"
}
},
{

View File

@@ -38,7 +38,7 @@
"ctrl-shift-d": "editor::DuplicateSelection",
"alt-f3": "editor::SelectAllMatches", // find_all_under
// "ctrl-f3": "", // find_under (cancels any selections)
// "cmd-alt-shift-g": "" // find_under_prev (cancels any selections)
// "ctrl-alt-shift-g": "" // find_under_prev (cancels any selections)
"f9": "editor::SortLinesCaseSensitive",
"ctrl-f9": "editor::SortLinesCaseInsensitive",
"f12": "editor::GoToDefinition",

View File

@@ -28,7 +28,8 @@
"context": "InlineAssistEditor",
"use_key_equivalents": true,
"bindings": {
"cmd-shift-backspace": "editor::Cancel"
"cmd-shift-backspace": "editor::Cancel",
"cmd-enter": "menu::Confirm"
// "alt-enter": // Quick Question
// "cmd-shift-enter": // Full File Context
// "cmd-shift-k": // Toggle input focus (editor <> inline assist)

View File

@@ -101,9 +101,12 @@
// The second option is decimal.
"unit": "binary"
},
// The key to use for adding multiple cursors
// Currently "alt" or "cmd_or_ctrl" (also aliased as
// "cmd" and "ctrl") are supported.
// Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
//
// 1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS:
// "alt"
// 2. Maps `Control` on Linux and Windows and to `Command` on MacOS:
// "cmd_or_ctrl" (alias: "cmd", "ctrl")
"multi_cursor_modifier": "alt",
// Whether to enable vim modes and key bindings.
"vim_mode": false,
@@ -214,6 +217,8 @@
"show_signature_help_after_edits": false,
// Whether to show code action button at start of buffer line.
"inline_code_actions": true,
// Whether to allow drag and drop text selection in buffer.
"drag_and_drop_selection": true,
// What to do when go to definition yields no results.
//
// 1. Do nothing: `none`
@@ -599,7 +604,9 @@
// 2. Never show indent guides:
// "never"
"show": "always"
}
},
/// Hide main root dir
"hide_root": false
},
"outline_panel": {
// Whether to show the outline panel button in the status bar

View File

@@ -57,7 +57,7 @@ use zed_llm_client::{CompletionIntent, UsageLimit};
use crate::active_thread::{self, ActiveThread, ActiveThreadEvent};
use crate::agent_configuration::{AgentConfiguration, AssistantConfigurationEvent};
use crate::agent_diff::AgentDiff;
use crate::history_store::{HistoryStore, RecentEntry};
use crate::history_store::{HistoryEntryId, HistoryStore};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::thread::{Thread, ThreadError, ThreadId, ThreadSummary, TokenUsageRatio};
use crate::thread_history::{HistoryEntryElement, ThreadHistory};
@@ -257,6 +257,7 @@ impl ActiveView {
pub fn prompt_editor(
context_editor: Entity<ContextEditor>,
history_store: Entity<HistoryStore>,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut App,
@@ -322,6 +323,19 @@ impl ActiveView {
editor.set_text(summary, window, cx);
})
}
ContextEvent::PathChanged { old_path, new_path } => {
history_store.update(cx, |history_store, cx| {
if let Some(old_path) = old_path {
history_store
.replace_recently_opened_text_thread(old_path, new_path, cx);
} else {
history_store.push_recently_opened_entry(
HistoryEntryId::Context(new_path.clone()),
cx,
);
}
});
}
_ => {}
}
}),
@@ -516,8 +530,7 @@ impl AgentPanel {
HistoryStore::new(
thread_store.clone(),
context_store.clone(),
[RecentEntry::Thread(thread_id, thread.clone())],
window,
[HistoryEntryId::Thread(thread_id)],
cx,
)
});
@@ -544,7 +557,13 @@ impl AgentPanel {
editor.insert_default_prompt(window, cx);
editor
});
ActiveView::prompt_editor(context_editor, language_registry.clone(), window, cx)
ActiveView::prompt_editor(
context_editor,
history_store.clone(),
language_registry.clone(),
window,
cx,
)
}
};
@@ -581,86 +600,9 @@ impl AgentPanel {
let panel = weak_panel.clone();
let assistant_navigation_menu =
ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
let recently_opened = panel
.update(cx, |this, cx| {
this.history_store.update(cx, |history_store, cx| {
history_store.recently_opened_entries(cx)
})
})
.unwrap_or_default();
if !recently_opened.is_empty() {
menu = menu.header("Recently Opened");
for entry in recently_opened.iter() {
if let RecentEntry::Context(context) = entry {
if context.read(cx).path().is_none() {
log::error!(
"bug: text thread in recent history list was never saved"
);
continue;
}
}
let summary = entry.summary(cx);
menu = menu.entry_with_end_slot_on_hover(
summary,
None,
{
let panel = panel.clone();
let entry = entry.clone();
move |window, cx| {
panel
.update(cx, {
let entry = entry.clone();
move |this, cx| match entry {
RecentEntry::Thread(_, thread) => {
this.open_thread(thread, window, cx)
}
RecentEntry::Context(context) => {
let Some(path) = context.read(cx).path()
else {
return;
};
this.open_saved_prompt_editor(
path.clone(),
window,
cx,
)
.detach_and_log_err(cx)
}
}
})
.ok();
}
},
IconName::Close,
"Close Entry".into(),
{
let panel = panel.clone();
let entry = entry.clone();
move |_window, cx| {
panel
.update(cx, |this, cx| {
this.history_store.update(
cx,
|history_store, cx| {
history_store.remove_recently_opened_entry(
&entry, cx,
);
},
);
})
.ok();
}
},
);
}
menu = menu.separator();
if let Some(panel) = panel.upgrade() {
menu = Self::populate_recently_opened_menu_section(menu, panel, cx);
}
menu.action("View All", Box::new(OpenHistory))
.end_slot_action(DeleteRecentlyOpenThread.boxed_clone())
.fixed_width(px(320.).into())
@@ -898,6 +840,7 @@ impl AgentPanel {
self.set_active_view(
ActiveView::prompt_editor(
context_editor.clone(),
self.history_store.clone(),
self.language_registry.clone(),
window,
cx,
@@ -984,7 +927,13 @@ impl AgentPanel {
)
});
self.set_active_view(
ActiveView::prompt_editor(editor.clone(), self.language_registry.clone(), window, cx),
ActiveView::prompt_editor(
editor.clone(),
self.history_store.clone(),
self.language_registry.clone(),
window,
cx,
),
window,
cx,
);
@@ -1383,16 +1332,6 @@ impl AgentPanel {
}
}
}
ActiveView::TextThread { context_editor, .. } => {
let context = context_editor.read(cx).context();
// When switching away from an unsaved text thread, delete its entry.
if context.read(cx).path().is_none() {
let context = context.clone();
self.history_store.update(cx, |store, cx| {
store.remove_recently_opened_entry(&RecentEntry::Context(context), cx);
});
}
}
_ => {}
}
@@ -1400,13 +1339,14 @@ impl AgentPanel {
ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| {
if let Some(thread) = thread.upgrade() {
let id = thread.read(cx).id().clone();
store.push_recently_opened_entry(RecentEntry::Thread(id, thread), cx);
store.push_recently_opened_entry(HistoryEntryId::Thread(id), cx);
}
}),
ActiveView::TextThread { context_editor, .. } => {
self.history_store.update(cx, |store, cx| {
let context = context_editor.read(cx).context().clone();
store.push_recently_opened_entry(RecentEntry::Context(context), cx)
if let Some(path) = context_editor.read(cx).context().read(cx).path() {
store.push_recently_opened_entry(HistoryEntryId::Context(path.clone()), cx)
}
})
}
_ => {}
@@ -1425,6 +1365,70 @@ impl AgentPanel {
self.focus_handle(cx).focus(window);
}
fn populate_recently_opened_menu_section(
mut menu: ContextMenu,
panel: Entity<Self>,
cx: &mut Context<ContextMenu>,
) -> ContextMenu {
let entries = panel
.read(cx)
.history_store
.read(cx)
.recently_opened_entries(cx);
if entries.is_empty() {
return menu;
}
menu = menu.header("Recently Opened");
for entry in entries {
let title = entry.title().clone();
let id = entry.id();
menu = menu.entry_with_end_slot_on_hover(
title,
None,
{
let panel = panel.downgrade();
let id = id.clone();
move |window, cx| {
let id = id.clone();
panel
.update(cx, move |this, cx| match id {
HistoryEntryId::Thread(id) => this
.open_thread_by_id(&id, window, cx)
.detach_and_log_err(cx),
HistoryEntryId::Context(path) => this
.open_saved_prompt_editor(path.clone(), window, cx)
.detach_and_log_err(cx),
})
.ok();
}
},
IconName::Close,
"Close Entry".into(),
{
let panel = panel.downgrade();
let id = id.clone();
move |_window, cx| {
panel
.update(cx, |this, cx| {
this.history_store.update(cx, |history_store, cx| {
history_store.remove_recently_opened_entry(&id, cx);
});
})
.ok();
}
},
);
}
menu = menu.separator();
menu
}
}
impl Focusable for AgentPanel {

View File

@@ -282,15 +282,18 @@ pub fn unordered_thread_entries(
text_thread_store: Entity<TextThreadStore>,
cx: &App,
) -> impl Iterator<Item = (DateTime<Utc>, ThreadContextEntry)> {
let threads = thread_store.read(cx).unordered_threads().map(|thread| {
(
thread.updated_at,
ThreadContextEntry::Thread {
id: thread.id.clone(),
title: thread.summary.clone(),
},
)
});
let threads = thread_store
.read(cx)
.reverse_chronological_threads()
.map(|thread| {
(
thread.updated_at,
ThreadContextEntry::Thread {
id: thread.id.clone(),
title: thread.summary.clone(),
},
)
});
let text_threads = text_thread_store
.read(cx)
@@ -300,7 +303,7 @@ pub fn unordered_thread_entries(
context.mtime.to_utc(),
ThreadContextEntry::Context {
path: context.path.clone(),
title: context.title.clone().into(),
title: context.title.clone(),
},
)
});

View File

@@ -1,18 +1,17 @@
use std::{collections::VecDeque, path::Path, sync::Arc};
use anyhow::Context as _;
use assistant_context_editor::{AssistantContext, SavedContextMetadata};
use anyhow::{Context as _, Result};
use assistant_context_editor::SavedContextMetadata;
use chrono::{DateTime, Utc};
use futures::future::{TryFutureExt as _, join_all};
use gpui::{Entity, Task, prelude::*};
use gpui::{AsyncApp, Entity, SharedString, Task, prelude::*};
use itertools::Itertools;
use paths::contexts_dir;
use serde::{Deserialize, Serialize};
use smol::future::FutureExt;
use std::time::Duration;
use ui::{App, SharedString, Window};
use ui::App;
use util::ResultExt as _;
use crate::{
Thread,
thread::ThreadId,
thread_store::{SerializedThreadMetadata, ThreadStore},
};
@@ -41,52 +40,34 @@ impl HistoryEntry {
HistoryEntry::Context(context) => HistoryEntryId::Context(context.path.clone()),
}
}
pub fn title(&self) -> &SharedString {
match self {
HistoryEntry::Thread(thread) => &thread.summary,
HistoryEntry::Context(context) => &context.title,
}
}
}
/// Generic identifier for a history entry.
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum HistoryEntryId {
Thread(ThreadId),
Context(Arc<Path>),
}
#[derive(Clone, Debug)]
pub(crate) enum RecentEntry {
Thread(ThreadId, Entity<Thread>),
Context(Entity<AssistantContext>),
}
impl PartialEq for RecentEntry {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Thread(l0, _), Self::Thread(r0, _)) => l0 == r0,
(Self::Context(l0), Self::Context(r0)) => l0 == r0,
_ => false,
}
}
}
impl Eq for RecentEntry {}
impl RecentEntry {
pub(crate) fn summary(&self, cx: &App) -> SharedString {
match self {
RecentEntry::Thread(_, thread) => thread.read(cx).summary().or_default(),
RecentEntry::Context(context) => context.read(cx).summary().or_default(),
}
}
}
#[derive(Serialize, Deserialize)]
enum SerializedRecentEntry {
enum SerializedRecentOpen {
Thread(String),
ContextName(String),
/// Old format which stores the full path
Context(String),
}
pub struct HistoryStore {
thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>,
recently_opened_entries: VecDeque<RecentEntry>,
recently_opened_entries: VecDeque<HistoryEntryId>,
_subscriptions: Vec<gpui::Subscription>,
_save_recently_opened_entries_task: Task<()>,
}
@@ -95,8 +76,7 @@ impl HistoryStore {
pub fn new(
thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>,
initial_recent_entries: impl IntoIterator<Item = RecentEntry>,
window: &mut Window,
initial_recent_entries: impl IntoIterator<Item = HistoryEntryId>,
cx: &mut Context<Self>,
) -> Self {
let subscriptions = vec![
@@ -104,68 +84,20 @@ impl HistoryStore {
cx.observe(&context_store, |_, _, cx| cx.notify()),
];
window
.spawn(cx, {
let thread_store = thread_store.downgrade();
let context_store = context_store.downgrade();
let this = cx.weak_entity();
async move |cx| {
let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
let contents = cx
.background_spawn(async move { std::fs::read_to_string(path) })
.await
.ok()?;
let entries = serde_json::from_str::<Vec<SerializedRecentEntry>>(&contents)
.context("deserializing persisted agent panel navigation history")
.log_err()?
.into_iter()
.take(MAX_RECENTLY_OPENED_ENTRIES)
.map(|serialized| match serialized {
SerializedRecentEntry::Thread(id) => thread_store
.update_in(cx, |thread_store, window, cx| {
let thread_id = ThreadId::from(id.as_str());
thread_store
.open_thread(&thread_id, window, cx)
.map_ok(|thread| RecentEntry::Thread(thread_id, thread))
.boxed()
})
.unwrap_or_else(|_| {
async {
anyhow::bail!("no thread store");
}
.boxed()
}),
SerializedRecentEntry::Context(id) => context_store
.update(cx, |context_store, cx| {
context_store
.open_local_context(Path::new(&id).into(), cx)
.map_ok(RecentEntry::Context)
.boxed()
})
.unwrap_or_else(|_| {
async {
anyhow::bail!("no context store");
}
.boxed()
}),
});
let entries = join_all(entries)
.await
.into_iter()
.filter_map(|result| result.log_with_level(log::Level::Debug))
.collect::<VecDeque<_>>();
this.update(cx, |this, _| {
this.recently_opened_entries.extend(entries);
this.recently_opened_entries
.truncate(MAX_RECENTLY_OPENED_ENTRIES);
})
.ok();
Some(())
}
cx.spawn(async move |this, cx| {
let entries = Self::load_recently_opened_entries(cx).await.log_err()?;
this.update(cx, |this, _| {
this.recently_opened_entries
.extend(
entries.into_iter().take(
MAX_RECENTLY_OPENED_ENTRIES
.saturating_sub(this.recently_opened_entries.len()),
),
);
})
.detach();
.ok()
})
.detach();
Self {
thread_store,
@@ -184,19 +116,20 @@ impl HistoryStore {
return history_entries;
}
for thread in self
.thread_store
.update(cx, |this, _cx| this.reverse_chronological_threads())
{
history_entries.push(HistoryEntry::Thread(thread));
}
for context in self
.context_store
.update(cx, |this, _cx| this.reverse_chronological_contexts())
{
history_entries.push(HistoryEntry::Context(context));
}
history_entries.extend(
self.thread_store
.read(cx)
.reverse_chronological_threads()
.cloned()
.map(HistoryEntry::Thread),
);
history_entries.extend(
self.context_store
.read(cx)
.unordered_contexts()
.cloned()
.map(HistoryEntry::Context),
);
history_entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.updated_at()));
history_entries
@@ -206,15 +139,62 @@ impl HistoryStore {
self.entries(cx).into_iter().take(limit).collect()
}
pub fn recently_opened_entries(&self, cx: &App) -> Vec<HistoryEntry> {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
return Vec::new();
}
let thread_entries = self
.thread_store
.read(cx)
.reverse_chronological_threads()
.flat_map(|thread| {
self.recently_opened_entries
.iter()
.enumerate()
.flat_map(|(index, entry)| match entry {
HistoryEntryId::Thread(id) if &thread.id == id => {
Some((index, HistoryEntry::Thread(thread.clone())))
}
_ => None,
})
});
let context_entries =
self.context_store
.read(cx)
.unordered_contexts()
.flat_map(|context| {
self.recently_opened_entries
.iter()
.enumerate()
.flat_map(|(index, entry)| match entry {
HistoryEntryId::Context(path) if &context.path == path => {
Some((index, HistoryEntry::Context(context.clone())))
}
_ => None,
})
});
thread_entries
.chain(context_entries)
// optimization to halt iteration early
.take(self.recently_opened_entries.len())
.sorted_unstable_by_key(|(index, _)| *index)
.map(|(_, entry)| entry)
.collect()
}
fn save_recently_opened_entries(&mut self, cx: &mut Context<Self>) {
let serialized_entries = self
.recently_opened_entries
.iter()
.filter_map(|entry| match entry {
RecentEntry::Context(context) => Some(SerializedRecentEntry::Context(
context.read(cx).path()?.to_str()?.to_owned(),
)),
RecentEntry::Thread(id, _) => Some(SerializedRecentEntry::Thread(id.to_string())),
HistoryEntryId::Context(path) => path.file_name().map(|file| {
SerializedRecentOpen::ContextName(file.to_string_lossy().to_string())
}),
HistoryEntryId::Thread(id) => Some(SerializedRecentOpen::Thread(id.to_string())),
})
.collect::<Vec<_>>();
@@ -233,7 +213,33 @@ impl HistoryStore {
});
}
pub fn push_recently_opened_entry(&mut self, entry: RecentEntry, cx: &mut Context<Self>) {
fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<Vec<HistoryEntryId>>> {
cx.background_spawn(async move {
let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
let contents = smol::fs::read_to_string(path).await?;
let entries = serde_json::from_str::<Vec<SerializedRecentOpen>>(&contents)
.context("deserializing persisted agent panel navigation history")?
.into_iter()
.take(MAX_RECENTLY_OPENED_ENTRIES)
.flat_map(|entry| match entry {
SerializedRecentOpen::Thread(id) => {
Some(HistoryEntryId::Thread(id.as_str().into()))
}
SerializedRecentOpen::ContextName(file_name) => Some(HistoryEntryId::Context(
contexts_dir().join(file_name).into(),
)),
SerializedRecentOpen::Context(path) => {
Path::new(&path).file_name().map(|file_name| {
HistoryEntryId::Context(contexts_dir().join(file_name).into())
})
}
})
.collect::<Vec<_>>();
Ok(entries)
})
}
pub fn push_recently_opened_entry(&mut self, entry: HistoryEntryId, cx: &mut Context<Self>) {
self.recently_opened_entries
.retain(|old_entry| old_entry != &entry);
self.recently_opened_entries.push_front(entry);
@@ -244,24 +250,33 @@ impl HistoryStore {
pub fn remove_recently_opened_thread(&mut self, id: ThreadId, cx: &mut Context<Self>) {
self.recently_opened_entries.retain(|entry| match entry {
RecentEntry::Thread(thread_id, _) if thread_id == &id => false,
HistoryEntryId::Thread(thread_id) if thread_id == &id => false,
_ => true,
});
self.save_recently_opened_entries(cx);
}
pub fn remove_recently_opened_entry(&mut self, entry: &RecentEntry, cx: &mut Context<Self>) {
pub fn replace_recently_opened_text_thread(
&mut self,
old_path: &Path,
new_path: &Arc<Path>,
cx: &mut Context<Self>,
) {
for entry in &mut self.recently_opened_entries {
match entry {
HistoryEntryId::Context(path) if path.as_ref() == old_path => {
*entry = HistoryEntryId::Context(new_path.clone());
break;
}
_ => {}
}
}
self.save_recently_opened_entries(cx);
}
pub fn remove_recently_opened_entry(&mut self, entry: &HistoryEntryId, cx: &mut Context<Self>) {
self.recently_opened_entries
.retain(|old_entry| old_entry != entry);
self.save_recently_opened_entries(cx);
}
pub fn recently_opened_entries(&self, _cx: &mut Context<Self>) -> VecDeque<RecentEntry> {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
return VecDeque::new();
}
self.recently_opened_entries.clone()
}
}

View File

@@ -671,7 +671,7 @@ impl RenderOnce for HistoryEntryElement {
),
HistoryEntry::Context(context) => (
context.path.to_string_lossy().to_string(),
context.title.clone().into(),
context.title.clone(),
context.mtime.timestamp(),
),
};

View File

@@ -393,16 +393,11 @@ impl ThreadStore {
self.threads.len()
}
pub fn unordered_threads(&self) -> impl Iterator<Item = &SerializedThreadMetadata> {
pub fn reverse_chronological_threads(&self) -> impl Iterator<Item = &SerializedThreadMetadata> {
// ordering is from "ORDER BY" in `list_threads`
self.threads.iter()
}
pub fn reverse_chronological_threads(&self) -> Vec<SerializedThreadMetadata> {
let mut threads = self.threads.iter().cloned().collect::<Vec<_>>();
threads.sort_unstable_by_key(|thread| std::cmp::Reverse(thread.updated_at));
threads
}
pub fn create_thread(&mut self, cx: &mut Context<Self>) -> Entity<Thread> {
cx.new(|cx| {
Thread::new(

View File

@@ -11,7 +11,7 @@ use assistant_slash_commands::FileCommandMetadata;
use client::{self, proto, telemetry::Telemetry};
use clock::ReplicaId;
use collections::{HashMap, HashSet};
use fs::{Fs, RemoveOptions};
use fs::{Fs, RenameOptions};
use futures::{FutureExt, StreamExt, future::Shared};
use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, RenderImage, SharedString, Subscription,
@@ -452,6 +452,10 @@ pub enum ContextEvent {
MessagesEdited,
SummaryChanged,
SummaryGenerated,
PathChanged {
old_path: Option<Arc<Path>>,
new_path: Arc<Path>,
},
StreamedCompletion,
StartedThoughtProcess(Range<language::Anchor>),
EndedThoughtProcess(language::Anchor),
@@ -2894,22 +2898,34 @@ impl AssistantContext {
}
fs.create_dir(contexts_dir().as_ref()).await?;
fs.atomic_write(new_path.clone(), serde_json::to_string(&context).unwrap())
.await?;
if let Some(old_path) = old_path {
// rename before write ensures that only one file exists
if let Some(old_path) = old_path.as_ref() {
if new_path.as_path() != old_path.as_ref() {
fs.remove_file(
fs.rename(
&old_path,
RemoveOptions {
recursive: false,
ignore_if_not_exists: true,
&new_path,
RenameOptions {
overwrite: true,
ignore_if_exists: true,
},
)
.await?;
}
}
this.update(cx, |this, _| this.path = Some(new_path.into()))?;
// update path before write in case it fails
this.update(cx, {
let new_path: Arc<Path> = new_path.clone().into();
move |this, cx| {
this.path = Some(new_path.clone());
cx.emit(ContextEvent::PathChanged { old_path, new_path });
}
})
.ok();
fs.atomic_write(new_path, serde_json::to_string(&context).unwrap())
.await?;
}
Ok(())
@@ -3277,7 +3293,7 @@ impl SavedContextV0_1_0 {
#[derive(Debug, Clone)]
pub struct SavedContextMetadata {
pub title: String,
pub title: SharedString,
pub path: Arc<Path>,
pub mtime: chrono::DateTime<chrono::Local>,
}

View File

@@ -580,6 +580,7 @@ impl ContextEditor {
});
}
ContextEvent::SummaryGenerated => {}
ContextEvent::PathChanged { .. } => {}
ContextEvent::StartedThoughtProcess(range) => {
let creases = self.insert_thought_process_output_sections(
[(

View File

@@ -347,12 +347,6 @@ impl ContextStore {
self.contexts_metadata.iter()
}
pub fn reverse_chronological_contexts(&self) -> Vec<SavedContextMetadata> {
let mut contexts = self.contexts_metadata.iter().cloned().collect::<Vec<_>>();
contexts.sort_unstable_by_key(|thread| std::cmp::Reverse(thread.mtime));
contexts
}
pub fn create(&mut self, cx: &mut Context<Self>) -> Entity<AssistantContext> {
let context = cx.new(|cx| {
AssistantContext::local(
@@ -618,6 +612,16 @@ impl ContextStore {
ContextEvent::SummaryChanged => {
self.advertise_contexts(cx);
}
ContextEvent::PathChanged { old_path, new_path } => {
if let Some(old_path) = old_path.as_ref() {
for metadata in &mut self.contexts_metadata {
if &metadata.path == old_path {
metadata.path = new_path.clone();
break;
}
}
}
}
ContextEvent::Operation(operation) => {
let context_id = context.read(cx).id().to_proto();
let operation = operation.to_proto();
@@ -792,7 +796,7 @@ impl ContextStore {
.next()
{
contexts.push(SavedContextMetadata {
title: title.to_string(),
title: title.to_string().into(),
path: path.into(),
mtime: metadata.mtime.timestamp_for_user().into(),
});

View File

@@ -213,11 +213,14 @@ use workspace::{
searchable::SearchEvent,
};
use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
use crate::{
code_context_menus::CompletionsMenuSource,
hover_links::{find_url, find_url_from_range},
};
use crate::{
editor_settings::MultiCursorModifier,
signature_help::{SignatureHelpHiddenBy, SignatureHelpState},
};
pub const FILE_HEADER_HEIGHT: u32 = 2;
pub const MULTI_BUFFER_EXCERPT_HEADER_HEIGHT: u32 = 1;
@@ -253,14 +256,6 @@ pub type RenderDiffHunkControlsFn = Arc<
) -> AnyElement,
>;
const COLUMNAR_SELECTION_MODIFIERS: Modifiers = Modifiers {
alt: true,
shift: true,
control: false,
platform: false,
function: false,
};
struct InlineValueCache {
enabled: bool,
inlays: Vec<InlayId>,
@@ -911,6 +906,18 @@ struct InlineBlamePopover {
popover_state: InlineBlamePopoverState,
}
enum SelectionDragState {
/// State when no drag related activity is detected.
None,
/// State when the mouse is down on a selection that is about to be dragged.
ReadyToDrag { selection: Selection<Anchor> },
/// State when the mouse is dragging the selection in the editor.
Dragging {
selection: Selection<Anchor>,
drop_cursor: Selection<Anchor>,
},
}
/// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have
/// a breakpoint on them.
#[derive(Clone, Copy, Debug)]
@@ -1096,6 +1103,8 @@ pub struct Editor {
hide_mouse_mode: HideMouseMode,
pub change_list: ChangeList,
inline_value_cache: InlineValueCache,
selection_drag_state: SelectionDragState,
drag_and_drop_selection_enabled: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
@@ -1990,6 +1999,8 @@ impl Editor {
.unwrap_or_default(),
change_list: ChangeList::new(),
mode,
selection_drag_state: SelectionDragState::None,
drag_and_drop_selection_enabled: EditorSettings::get_global(cx).drag_and_drop_selection,
};
if let Some(breakpoints) = editor.breakpoint_store.as_ref() {
editor
@@ -3535,6 +3546,7 @@ impl Editor {
pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.selection_mark_mode = false;
self.selection_drag_state = SelectionDragState::None;
if self.clear_expanded_diff_hunks(cx) {
cx.notify();
@@ -7091,6 +7103,29 @@ impl Editor {
)
}
fn multi_cursor_modifier(
cursor_event: bool,
modifiers: &Modifiers,
cx: &mut Context<Self>,
) -> bool {
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
if cursor_event {
match multi_cursor_setting {
MultiCursorModifier::Alt => modifiers.alt,
MultiCursorModifier::CmdOrCtrl => modifiers.secondary(),
}
} else {
match multi_cursor_setting {
MultiCursorModifier::Alt => modifiers.secondary(),
MultiCursorModifier::CmdOrCtrl => modifiers.alt,
}
}
}
fn columnar_selection_modifiers(multi_cursor_modifier: bool, modifiers: &Modifiers) -> bool {
modifiers.shift && multi_cursor_modifier && modifiers.number_of_modifiers() == 2
}
fn update_selection_mode(
&mut self,
modifiers: &Modifiers,
@@ -7098,7 +7133,10 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
if modifiers != &COLUMNAR_SELECTION_MODIFIERS || self.selections.pending.is_none() {
let multi_cursor_modifier = Self::multi_cursor_modifier(true, modifiers, cx);
if !Self::columnar_selection_modifiers(multi_cursor_modifier, modifiers)
|| self.selections.pending.is_none()
{
return;
}
@@ -10563,6 +10601,56 @@ impl Editor {
});
}
pub fn drop_selection(
&mut self,
point_for_position: Option<PointForPosition>,
is_cut: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if let Some(point_for_position) = point_for_position {
match self.selection_drag_state {
SelectionDragState::Dragging { ref selection, .. } => {
let snapshot = self.snapshot(window, cx);
let selection_display =
selection.map(|anchor| anchor.to_display_point(&snapshot));
if !point_for_position.intersects_selection(&selection_display) {
let point = point_for_position.previous_valid;
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let buffer = &display_map.buffer_snapshot;
let mut edits = Vec::new();
let insert_point = display_map
.clip_point(point, Bias::Left)
.to_point(&display_map);
let text = buffer
.text_for_range(selection.start..selection.end)
.collect::<String>();
if is_cut {
edits.push(((selection.start..selection.end), String::new()));
}
let insert_anchor = buffer.anchor_before(insert_point);
edits.push(((insert_anchor..insert_anchor), text));
let last_edit_start = insert_anchor.bias_left(buffer);
let last_edit_end = insert_anchor.bias_right(buffer);
self.transact(window, cx, |this, window, cx| {
this.buffer.update(cx, |buffer, cx| {
buffer.edit(edits, None, cx);
});
this.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.select_anchor_ranges([last_edit_start..last_edit_end]);
});
});
self.selection_drag_state = SelectionDragState::None;
return true;
}
}
_ => {}
}
}
self.selection_drag_state = SelectionDragState::None;
false
}
pub fn duplicate(
&mut self,
upwards: bool,
@@ -18770,6 +18858,11 @@ impl Editor {
cx.emit(EditorEvent::BufferEdited);
cx.emit(SearchEvent::MatchesInvalidated);
if *singleton_buffer_edited {
if let Some(buffer) = multibuffer.read(cx).as_singleton() {
if buffer.read(cx).file().is_none() {
cx.emit(EditorEvent::TitleChanged);
}
}
if let Some(project) = &self.project {
#[allow(clippy::mutable_key_type)]
let languages_affected = multibuffer.update(cx, |multibuffer, cx| {
@@ -18961,6 +19054,7 @@ impl Editor {
self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
self.cursor_shape = editor_settings.cursor_shape.unwrap_or_default();
self.hide_mouse_mode = editor_settings.hide_mouse.unwrap_or_default();
self.drag_and_drop_selection_enabled = editor_settings.drag_and_drop_selection;
}
if old_cursor_shape != self.cursor_shape {

View File

@@ -49,6 +49,7 @@ pub struct EditorSettings {
#[serde(default)]
pub diagnostics_max_severity: Option<DiagnosticSeverity>,
pub inline_code_actions: bool,
pub drag_and_drop_selection: bool,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -422,7 +423,7 @@ pub struct EditorSettingsContent {
/// Default: always
pub seed_search_query_from_cursor: Option<SeedQuerySetting>,
pub use_smartcase_search: Option<bool>,
/// The key to use for adding multiple cursors
/// Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
///
/// Default: alt
pub multi_cursor_modifier: Option<MultiCursorModifier>,
@@ -495,6 +496,11 @@ pub struct EditorSettingsContent {
///
/// Default: true
pub inline_code_actions: Option<bool>,
/// Whether to allow drag and drop text selection in buffer.
///
/// Default: true
pub drag_and_drop_selection: Option<bool>,
}
// Toolbar related settings

View File

@@ -1,14 +1,14 @@
use crate::{
ActiveDiagnostic, BlockId, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR,
ChunkRendererContext, ChunkReplacement, CodeActionSource, ConflictsOurs, ConflictsOursMarker,
ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape,
CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, DocumentHighlightRead,
DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode, EditorSettings, EditorSnapshot,
EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp,
HandleInput, HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown,
LineHighlight, LineUp, MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MINIMAP_FONT_SIZE,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, PhantomBreakpointIndicator,
Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
ActiveDiagnostic, BlockId, CURSORS_VISIBLE_FOR, ChunkRendererContext, ChunkReplacement,
CodeActionSource, ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs,
ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk,
DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode,
Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT,
FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor,
InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp,
MAX_LINE_LEN, MIN_LINE_NUMBER_DIGITS, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
OpenExcerpts, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt,
SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SoftWrap,
StickyHeaderExcerpt, ToPoint, ToggleFold,
code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
display_map::{
@@ -17,8 +17,7 @@ use crate::{
},
editor_settings::{
CurrentLineHighlight, DoubleClickInMultibuffer, MinimapThumb, MinimapThumbBorder,
MultiCursorModifier, ScrollBeyondLastLine, ScrollbarAxes, ScrollbarDiagnostics,
ShowMinimap, ShowScrollbar,
ScrollBeyondLastLine, ScrollbarAxes, ScrollbarDiagnostics, ShowMinimap, ShowScrollbar,
},
git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer},
hover_popover::{
@@ -79,10 +78,11 @@ use std::{
time::Duration,
};
use sum_tree::Bias;
use text::BufferId;
use text::{BufferId, SelectionGoal};
use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
use ui::{ButtonLike, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*};
use unicode_segmentation::UnicodeSegmentation;
use util::post_inc;
use util::{RangeExt, ResultExt, debug_panic};
use workspace::{CollaboratorId, Workspace, item::Item, notifications::NotifyTaskExt};
@@ -620,6 +620,7 @@ impl EditorElement {
let text_hitbox = &position_map.text_hitbox;
let gutter_hitbox = &position_map.gutter_hitbox;
let point_for_position = position_map.point_for_position(event.position);
let mut click_count = event.click_count;
let mut modifiers = event.modifiers;
@@ -633,6 +634,19 @@ impl EditorElement {
return;
}
if editor.drag_and_drop_selection_enabled && click_count == 1 {
let newest_anchor = editor.selections.newest_anchor();
let snapshot = editor.snapshot(window, cx);
let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot));
if point_for_position.intersects_selection(&selection) {
editor.selection_drag_state = SelectionDragState::ReadyToDrag {
selection: newest_anchor.clone(),
};
cx.stop_propagation();
return;
}
}
let is_singleton = editor.buffer().read(cx).is_singleton();
if click_count == 2 && !is_singleton {
@@ -676,9 +690,9 @@ impl EditorElement {
}
}
let point_for_position = position_map.point_for_position(event.position);
let position = point_for_position.previous_valid;
if modifiers == COLUMNAR_SELECTION_MODIFIERS {
let multi_cursor_modifier = Editor::multi_cursor_modifier(true, &modifiers, cx);
if Editor::columnar_selection_modifiers(multi_cursor_modifier, &modifiers) {
editor.select(
SelectPhase::BeginColumnar {
position,
@@ -699,11 +713,6 @@ impl EditorElement {
cx,
);
} else {
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
let multi_cursor_modifier = match multi_cursor_setting {
MultiCursorModifier::Alt => modifiers.alt,
MultiCursorModifier::CmdOrCtrl => modifiers.secondary(),
};
editor.select(
SelectPhase::Begin {
position,
@@ -821,6 +830,12 @@ impl EditorElement {
let text_hitbox = &position_map.text_hitbox;
let end_selection = editor.has_pending_selection();
let pending_nonempty_selections = editor.has_pending_nonempty_selection();
let point_for_position = position_map.point_for_position(event.position);
let is_cut = !event.modifiers.control;
if editor.drop_selection(Some(point_for_position), is_cut, window, cx) {
return;
}
if end_selection {
editor.select(SelectPhase::End, window, cx);
@@ -867,13 +882,9 @@ impl EditorElement {
let text_hitbox = &position_map.text_hitbox;
let pending_nonempty_selections = editor.has_pending_nonempty_selection();
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
let multi_cursor_modifier = match multi_cursor_setting {
MultiCursorModifier::Alt => event.modifiers().secondary(),
MultiCursorModifier::CmdOrCtrl => event.modifiers().alt,
};
let hovered_link_modifier = Editor::multi_cursor_modifier(false, &event.modifiers(), cx);
if !pending_nonempty_selections && multi_cursor_modifier && text_hitbox.is_hovered(window) {
if !pending_nonempty_selections && hovered_link_modifier && text_hitbox.is_hovered(window) {
let point = position_map.point_for_position(event.up.position);
editor.handle_click_hovered_link(point, event.modifiers(), window, cx);
@@ -888,12 +899,15 @@ impl EditorElement {
window: &mut Window,
cx: &mut Context<Editor>,
) {
if !editor.has_pending_selection() {
if !editor.has_pending_selection()
&& matches!(editor.selection_drag_state, SelectionDragState::None)
{
return;
}
let text_bounds = position_map.text_hitbox.bounds;
let point_for_position = position_map.point_for_position(event.position);
let mut scroll_delta = gpui::Point::<f32>::default();
let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0);
let top = text_bounds.origin.y + vertical_margin;
@@ -925,15 +939,46 @@ impl EditorElement {
scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right);
}
editor.select(
SelectPhase::Update {
position: point_for_position.previous_valid,
goal_column: point_for_position.exact_unclipped.column(),
scroll_delta,
},
window,
cx,
);
if !editor.has_pending_selection() {
let drop_anchor = position_map
.snapshot
.display_point_to_anchor(point_for_position.previous_valid, Bias::Left);
match editor.selection_drag_state {
SelectionDragState::Dragging {
ref mut drop_cursor,
..
} => {
drop_cursor.start = drop_anchor;
drop_cursor.end = drop_anchor;
}
SelectionDragState::ReadyToDrag { ref selection } => {
let drop_cursor = Selection {
id: post_inc(&mut editor.selections.next_selection_id),
start: drop_anchor,
end: drop_anchor,
reversed: false,
goal: SelectionGoal::None,
};
editor.selection_drag_state = SelectionDragState::Dragging {
selection: selection.clone(),
drop_cursor,
};
}
_ => {}
}
editor.apply_scroll_delta(scroll_delta, window, cx);
cx.notify();
} else {
editor.select(
SelectPhase::Update {
position: point_for_position.previous_valid,
goal_column: point_for_position.exact_unclipped.column(),
scroll_delta,
},
window,
cx,
);
}
}
fn mouse_moved(
@@ -1162,6 +1207,34 @@ impl EditorElement {
let player = editor.current_user_player_color(cx);
selections.push((player, layouts));
if let SelectionDragState::Dragging {
ref selection,
ref drop_cursor,
} = editor.selection_drag_state
{
if drop_cursor
.start
.cmp(&selection.start, &snapshot.buffer_snapshot)
.eq(&Ordering::Less)
|| drop_cursor
.end
.cmp(&selection.end, &snapshot.buffer_snapshot)
.eq(&Ordering::Greater)
{
let drag_cursor_layout = SelectionLayout::new(
drop_cursor.clone(),
false,
CursorShape::Bar,
&snapshot.display_snapshot,
false,
false,
None,
);
let absent_color = cx.theme().players().absent();
selections.push((absent_color, vec![drag_cursor_layout]));
}
}
}
if let Some(collaboration_hub) = &editor.collaboration_hub {
@@ -9242,6 +9315,35 @@ impl PointForPosition {
None
}
}
pub fn intersects_selection(&self, selection: &Selection<DisplayPoint>) -> bool {
let Some(valid_point) = self.as_valid() else {
return false;
};
let range = selection.range();
let candidate_row = valid_point.row();
let candidate_col = valid_point.column();
let start_row = range.start.row();
let start_col = range.start.column();
let end_row = range.end.row();
let end_col = range.end.column();
if candidate_row < start_row || candidate_row > end_row {
false
} else if start_row == end_row {
candidate_col >= start_col && candidate_col < end_col
} else {
if candidate_row == start_row {
candidate_col >= start_col
} else if candidate_row == end_row {
candidate_col < end_col
} else {
true
}
}
}
}
impl PositionMap {

View File

@@ -1,7 +1,7 @@
use crate::{
Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition,
GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase,
editor_settings::{GoToDefinitionFallback, MultiCursorModifier},
editor_settings::GoToDefinitionFallback,
hover_popover::{self, InlayHover},
scroll::ScrollAmount,
};
@@ -120,11 +120,7 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
let hovered_link_modifier = match multi_cursor_setting {
MultiCursorModifier::Alt => modifiers.secondary(),
MultiCursorModifier::CmdOrCtrl => modifiers.alt,
};
let hovered_link_modifier = Editor::multi_cursor_modifier(false, &modifiers, cx);
if !hovered_link_modifier || self.has_pending_selection() {
self.hide_hovered_link(cx);
return;

View File

@@ -2600,13 +2600,27 @@ impl MultiBuffer {
return title.into();
}
if let Some(buffer) = self.as_singleton() {
if let Some(file) = buffer.read(cx).file() {
return file.file_name(cx).to_string_lossy();
}
}
self.as_singleton()
.and_then(|buffer| {
let buffer = buffer.read(cx);
"untitled".into()
if let Some(file) = buffer.file() {
return Some(file.file_name(cx).to_string_lossy());
}
let title = buffer
.snapshot()
.chars()
.skip_while(|ch| ch.is_whitespace())
.take_while(|&ch| ch != '\n')
.take(40)
.collect::<String>()
.trim_end()
.to_string();
(!title.is_empty()).then(|| title.into())
})
.unwrap_or("untitled".into())
}
pub fn set_title(&mut self, title: String, cx: &mut Context<Self>) {

View File

@@ -3651,3 +3651,59 @@ fn assert_line_indents(snapshot: &MultiBufferSnapshot) {
"reversed_line_indents({max_row})"
);
}
#[gpui::test]
fn test_new_empty_buffer_uses_untitled_title(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local("", cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
assert_eq!(multibuffer.read(cx).title(cx), "untitled");
}
#[gpui::test]
fn test_new_empty_buffer_uses_untitled_title_when_only_contains_whitespace(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local("\n ", cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
assert_eq!(multibuffer.read(cx).title(cx), "untitled");
}
#[gpui::test]
fn test_new_empty_buffer_takes_first_line_for_title(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local("Hello World\nSecond line", cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
assert_eq!(multibuffer.read(cx).title(cx), "Hello World");
}
#[gpui::test]
fn test_new_empty_buffer_takes_trimmed_first_line_for_title(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local("\nHello, World ", cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
assert_eq!(multibuffer.read(cx).title(cx), "Hello, World");
}
#[gpui::test]
fn test_new_empty_buffer_uses_truncated_first_line_for_title(cx: &mut App) {
let title_after = ["a", "b", "c", "d"]
.map(|letter| letter.repeat(10))
.join("");
let title = format!("{}{}", title_after, "e".repeat(10));
let buffer = cx.new(|cx| Buffer::local(title, cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
assert_eq!(multibuffer.read(cx).title(cx), title_after);
}
#[gpui::test]
fn test_new_empty_buffers_title_can_be_set(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local("Hello World", cx));
let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
assert_eq!(multibuffer.read(cx).title(cx), "Hello World");
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_title("Hey".into(), cx)
});
assert_eq!(multibuffer.read(cx).title(cx), "Hey");
}

View File

@@ -1107,38 +1107,14 @@ impl OutlinePanel {
});
} else {
let mut offset = Point::default();
let expand_excerpt_control_height = 1.0;
if let Some(buffer_id) = scroll_to_buffer {
let current_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx);
if current_folded {
let previous_buffer_id = self
.fs_entries
.iter()
.rev()
.filter_map(|entry| match entry {
FsEntry::File(file) => Some(file.buffer_id),
FsEntry::ExternalFile(external_file) => {
Some(external_file.buffer_id)
}
FsEntry::Directory(..) => None,
})
.skip_while(|id| *id != buffer_id)
.nth(1);
if let Some(previous_buffer_id) = previous_buffer_id {
if !active_editor
.read(cx)
.is_buffer_folded(previous_buffer_id, cx)
{
offset.y += expand_excerpt_control_height;
}
}
} else {
if multi_buffer_snapshot.as_singleton().is_none() {
offset.y = -(active_editor.read(cx).file_header_size() as f32);
}
offset.y -= expand_excerpt_control_height;
if multi_buffer_snapshot.as_singleton().is_none()
&& !active_editor.read(cx).is_buffer_folded(buffer_id, cx)
{
offset.y = -(active_editor.read(cx).file_header_size() as f32);
}
}
active_editor.update(cx, |editor, cx| {
editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, window, cx);
});

View File

@@ -2790,7 +2790,9 @@ impl ProjectPanel {
let old_ancestors = std::mem::take(&mut self.ancestors);
self.visible_entries.clear();
let mut max_width_item = None;
for worktree in project.visible_worktrees(cx) {
let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
let hide_root = settings.hide_root && visible_worktrees.len() == 1;
for worktree in visible_worktrees {
let worktree_snapshot = worktree.read(cx).snapshot();
let worktree_id = worktree_snapshot.id();
@@ -2825,6 +2827,10 @@ impl ProjectPanel {
GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
let mut auto_folded_ancestors = vec![];
while let Some(entry) = entry_iter.entry() {
if hide_root && Some(entry.entry) == worktree.read(cx).root_entry() {
entry_iter.advance();
continue;
}
if auto_collapse_dirs && entry.kind.is_dir() {
auto_folded_ancestors.push(entry.id);
if !self.unfolded_dir_ids.contains(&entry.id) {

View File

@@ -44,6 +44,7 @@ pub struct ProjectPanelSettings {
pub auto_fold_dirs: bool,
pub scrollbar: ScrollbarSettings,
pub show_diagnostics: ShowDiagnostics,
pub hide_root: bool,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -145,6 +146,10 @@ pub struct ProjectPanelSettingsContent {
pub show_diagnostics: Option<ShowDiagnostics>,
/// Settings related to indent guides in the project panel.
pub indent_guides: Option<IndentGuidesSettingsContent>,
/// Hide main root dir
///
/// Default: false
pub hide_root: Option<bool>,
}
impl Settings for ProjectPanelSettings {

View File

@@ -4849,6 +4849,87 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
);
}
/// Test the functionality of hiding the worktree root in the project panel.
#[gpui::test]
async fn test_hide_worktree_root(cx: &mut gpui::TestAppContext) {
// Initialize the test environment.
init_test(cx);
// Create a fake file system with a specific directory structure.
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
"dir1": {
"subdir1": {
"nested1": {
"file1.txt": "",
"file2.txt": ""
},
},
"subdir2": {
"file4.txt": ""
}
},
"dir2": {
"single_file": {
"file5.txt": ""
}
},
"file3.txt": ""
}),
)
.await;
// Create a project using the fake file system.
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
// Add a new window with the project panel.
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
// Create a visual test context from the window.
let cx = &mut VisualTestContext::from_window(*workspace, cx);
// Initialize the project panel.
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Update the global settings to hide the worktree root.
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
hide_root: true,
auto_fold_dirs: true,
..settings
},
cx,
);
});
// Refresh the panel to apply the settings.
panel.update_in(cx, |panel, window, cx| {
panel.cancel(&menu::Cancel, window, cx);
});
// Assert that the worktree root is not visible in the panel.
/*
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"> dir1",
"> dir2",
" file3.txt"
]
);
*/
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"> dir1",
"> dir2/single_file",
" file3.txt"
]
);
}
#[gpui::test]
async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
init_test(cx);

View File

@@ -915,6 +915,9 @@ impl Vim {
if mode == Mode::Normal || mode != last_mode {
self.current_tx.take();
self.current_anchor.take();
self.update_editor(window, cx, |_, editor, window, cx| {
editor.drop_selection(None, false, window, cx);
});
}
Vim::take_forced_motion(cx);
if mode != Mode::Insert && mode != Mode::Replace {

View File

@@ -2925,15 +2925,9 @@ impl Pane {
let ix = if moved_right { ix - 1 } else { ix };
let is_pinned_in_to_pane = this.is_tab_pinned(ix);
if (was_pinned_in_from_pane && is_pinned_in_to_pane)
|| (!was_pinned_in_from_pane && !is_pinned_in_to_pane)
{
return;
}
if is_pinned_in_to_pane {
if !was_pinned_in_from_pane && is_pinned_in_to_pane {
this.pinned_tab_count += 1;
} else {
} else if was_pinned_in_from_pane && !is_pinned_in_to_pane {
this.pinned_tab_count -= 1;
}
} else if this.items.len() >= to_pane_old_length {

View File

@@ -3027,7 +3027,7 @@ mod tests {
});
cx.read(|cx| {
assert!(editor.is_dirty(cx));
assert_eq!(editor.read(cx).title(cx), "untitled");
assert_eq!(editor.read(cx).title(cx), "hi");
});
// When the save completes, the buffer's title is updated and the language is assigned based

View File

@@ -1216,6 +1216,16 @@ or
`boolean` values
### Drag And Drop Selection
- Description: Whether to allow drag and drop text selection in buffer.
- Setting: `drag_and_drop_selection`
- Default: `true`
**Options**
`boolean` values
## Editor Toolbar
- Description: Whether or not to show various elements in the editor toolbar.
@@ -1924,6 +1934,30 @@ Example:
`boolean` values
## Multi Cursor Modifier
- Description: Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
- Setting: `multi_cursor_modifier`
- Default: `alt`
**Options**
1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS:
```jsonc
{
"multi_cursor_modifier": "alt",
}
```
2. Maps `Control` on Linux and Windows and to `Command` on MacOS:
```jsonc
{
"multi_cursor_modifier": "cmd_or_ctrl", // alias: "cmd", "ctrl"
}
```
## Hover Popover Enabled
- Description: Whether or not to show the informational hover box when moving the mouse over symbols in the editor.

View File

@@ -42,7 +42,7 @@ You can add this to Zed project settings (`.zed/settings.json`) or via your Zed
### Advanced Formatting
Sql-formatter also allows more precise control by providing [sql-formatter configuration options](https://github.com/sql-formatter-org/sql-formatter#configuration-options). To provide these, create a `sql-formatter.json` file in your project:
Sql-formatter also allows more precise control by providing [sql-formatter configuration options](https://github.com/sql-formatter-org/sql-formatter#configuration-options). To provide these, create a `.sql-formatter.json` file in your project:
```json
{
@@ -53,7 +53,7 @@ Sql-formatter also allows more precise control by providing [sql-formatter confi
}
```
When using a `sql-formatter.json` file you can use a more simplified set of Zed settings since the language need not be specified inline:
When using a `.sql-formatter.json` file you can use a more simplified set of Zed settings since the language need not be specified inline:
```json
"languages": {

26
script/check-keymaps Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
pattern='cmd-'
result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \
'assets/keymaps/' \
':(exclude)assets/keymaps/storybook.json' \
':(exclude)assets/keymaps/default-macos.json' \
':(exclude)assets/keymaps/macos/*.json' || true)
if [[ -n "${result}" ]]; then
echo "${result}"
echo "Error: Found 'cmd-' in non-macOS keymap files."
exit 1
fi
pattern='super-|win-|fn-'
result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \
'assets/keymaps/' || true)
if [[ -n "${result}" ]]; then
echo "${result}"
echo "Error: Found 'super-', 'win-', or 'fn-' in keymap files. Currently these aren't used."
exit 1
fi

View File

@@ -8,7 +8,7 @@ result=$(git grep --no-color --ignore-case --line-number --extended-regexp -e $p
':(exclude).github/workflows/ci.yml' \
':(exclude)*criteria.md' \
':(exclude)*prompt.md' || true)
echo "${result}"
if [[ -n "${result}" ]]; then
echo "${result}"
exit 1
fi