feat(editor): draw the editor's right-click menus like the rest of the window

The text's menu was gpui-component's native OS menu: another font and
material, no shortcuts, and Go to Definition / Show Code Actions that no
language server ever enables. It is now a PopupMenu with Attach to Agent
(the selected lines ride along as #L3-9), Undo/Redo, the clipboard,
Find, Go to Line, and the file's own items: Open in Browser for web
files or Open with Default App, Reveal, Copy Path, Copy Relative Path.

The file tabs get a menu too: Close, Close Others, Close to the Right,
and the same file items. Closing several asks once about unsaved edits.
This commit is contained in:
l0ng-ai
2026-09-28 12:04:35 +08:00
parent bc08fc49f4
commit 630caa83c5
6 changed files with 368 additions and 13 deletions
+341 -8
View File
@@ -9,7 +9,7 @@ use gpui::{
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::{Input, InputEvent, InputState, Position, RopeExt as _, TabSize};
use gpui_component::menu::ContextMenuExt as _;
use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
};
@@ -224,9 +224,12 @@ impl TabCode {
}
/// What a question about unsaved files was standing in the way of.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub(crate) enum AfterUnsaved {
CloseTab(TabId),
/// Take these files out of that tab's strip — Close Others and Close to
/// the Right.
CloseFiles(TabId, Vec<BufferId>),
ClosePane,
CloseWindow,
Quit,
@@ -605,6 +608,66 @@ fn is_program(path: &Path) -> bool {
}
}
/// A file a browser renders rather than showing its source.
fn opens_in_browser(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"html" | "htm" | "xhtml" | "svg"
)
})
}
/// Opens a local file in the default web browser. On macOS that is asked
/// for by name: what `.html` is associated with is as often an editor. The
/// other desktops go by the association.
fn open_in_browser(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
if let Some(bundle) = default_browser_bundle_id() {
std::process::Command::new("open")
.arg("-b")
.arg(bundle)
.arg(path)
.spawn()?;
return Ok(());
}
crate::terminal::view::open_file_path(path)
}
/// The app that handles `https:` links — the default browser.
#[cfg(target_os = "macos")]
fn default_browser_bundle_id() -> Option<String> {
use core_foundation::base::TCFType as _;
use core_foundation::string::{CFString, CFStringRef};
#[link(name = "CoreServices", kind = "framework")]
unsafe extern "C" {
fn LSCopyDefaultHandlerForURLScheme(scheme: CFStringRef) -> CFStringRef;
}
let scheme = CFString::new("https");
let handler = unsafe { LSCopyDefaultHandlerForURLScheme(scheme.as_concrete_TypeRef()) };
// A Copy function: the string is ours to release, which the wrapper does.
(!handler.is_null()).then(|| unsafe { CFString::wrap_under_create_rule(handler) }.to_string())
}
/// The lines a selection covers, 1-based and inclusive. One that ends at the
/// very start of a line — a whole-line selection — does not take that line.
fn selected_lines(
text: &gpui_component::input::Rope,
range: std::ops::Range<usize>,
) -> Option<(usize, usize)> {
if range.is_empty() {
return None;
}
let start = text.offset_to_point(range.start);
let end = text.offset_to_point(range.end);
let last = match end.column == 0 && end.row > start.row {
true => end.row - 1,
false => end.row,
};
Some((start.row + 1, last + 1))
}
/// What a watcher saw at a path: the file's modification time, or that it
/// is not there any more.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -1044,8 +1107,18 @@ impl Tty7App {
);
return;
}
// The OS association can fail to spawn like any other opener (#542).
if let Err(e) = crate::terminal::view::open_file_path(path) {
Self::open_with(path, crate::terminal::view::open_file_path, window, cx);
}
/// Runs one of the desktop openers on a local file, and says so when it
/// fails to spawn — as any opener can (#542).
fn open_with(
path: &Path,
opener: fn(&Path) -> std::io::Result<()>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Err(e) = opener(path) {
log::warn!("failed to open {}: {e}", path.display());
window.push_notification(
crate::ui::host_ops::failure(
@@ -1168,6 +1241,9 @@ impl Tty7App {
.replaceable(true)
.folding(true)
.soft_wrap(wrap)
// Ours is drawn by `editor_body_menu`, like every other menu
// in the window; the built-in one is a native OS menu.
.context_menu(false)
.default_value(text)
});
let id = input.entity_id();
@@ -1922,6 +1998,231 @@ impl Tty7App {
.detach();
}
/// Closes the file with this buffer in the front tab's strip, wherever
/// it has moved to since a menu was built for it.
fn editor_close_buffer(&mut self, id: BufferId, window: &mut Window, cx: &mut Context<Self>) {
if let Some(pos) = self
.tab_code()
.and_then(|c| c.files.iter().position(|f| *f == id))
{
self.editor_close_file(pos, window, cx);
}
}
/// Closes several files in the front tab's strip, asking once about the
/// ones whose edits would be lost with them — not those another tab still
/// shows.
fn editor_close_buffers(
&mut self,
ids: Vec<BufferId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let tab_ix = self.active;
let Some(tab) = self.tabs.get(tab_ix).map(|t| t.tree_id.get()) else {
return;
};
let unsaved: Vec<BufferId> = ids
.iter()
.copied()
.filter(|id| self.buffer(*id).is_some_and(|f| f.dirty) && self.buffer_refs(*id) == 1)
.collect();
if self.editor_guard_unsaved(
unsaved,
AfterUnsaved::CloseFiles(tab, ids.clone()),
window,
cx,
) {
return;
}
for id in ids {
self.editor_remove_from_tab(tab_ix, id, cx);
}
}
/// Types `@path` into the running agent's prompt, with the selected
/// lines after it when there are any.
fn editor_attach_to_agent(&mut self, id: BufferId, cx: &mut Context<Self>) {
let Some(f) = self.buffer(id).filter(|f| f.untitled.is_none()) else {
return;
};
let state = f.input.read(cx);
let suffix = match selected_lines(state.text(), state.selected_range()) {
Some((a, b)) if a == b => format!("#L{a}"),
Some((a, b)) => format!("#L{a}-{b}"),
None => String::new(),
};
let path = f.path.clone();
self.attach_path_to_agent(&path, &suffix, cx);
}
/// Right-click in the text. Edits dispatch to the editor itself, so each
/// row shows the chord that does the same thing.
fn editor_body_menu(
menu: PopupMenu,
app: &gpui::WeakEntity<Self>,
id: BufferId,
cx: &gpui::App,
) -> PopupMenu {
use gpui_component::input as edit;
let Some(this) = app.upgrade() else {
return menu;
};
let this = this.read(cx);
let Some(f) = this.buffer(id) else {
return menu;
};
let state = f.input.read(cx);
let selected = !state.selected_range().is_empty();
let mut menu = menu.min_w(px(220.)).action_context(state.focus_handle(cx));
if f.untitled.is_none() {
menu = menu
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextAttachAgent)).on_click({
let app = app.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.editor_attach_to_agent(id, cx));
}
}),
)
.separator();
}
let menu = menu
.menu(t(L10nKey::AppMenuUndo), Box::new(edit::Undo))
.menu(t(L10nKey::AppMenuRedo), Box::new(edit::Redo))
.separator()
.menu_with_disabled(t(L10nKey::AppMenuCut), Box::new(edit::Cut), !selected)
.menu_with_disabled(t(L10nKey::AppMenuCopy), Box::new(edit::Copy), !selected)
.menu_with_disabled(
t(L10nKey::AppMenuPaste),
Box::new(edit::Paste),
cx.read_from_clipboard().is_none(),
)
.menu(t(L10nKey::AppMenuSelectAll), Box::new(edit::SelectAll))
.separator()
.menu(t(L10nKey::AppMenuFind), Box::new(edit::Search))
.menu(
t(L10nKey::EditorGoToLineAction),
Box::new(crate::core::actions::EditorGoToLine),
);
this.editor_file_menu_items(menu, id, app, cx)
}
/// Right-click on a file in the header's strip.
fn editor_tab_menu(
menu: PopupMenu,
app: &gpui::WeakEntity<Self>,
pos: usize,
cx: &gpui::App,
) -> PopupMenu {
let Some(this) = app.upgrade() else {
return menu;
};
let this = this.read(cx);
let Some(files) = this.tab_code().map(|c| c.files.clone()) else {
return menu;
};
let Some(&id) = files.get(pos) else {
return menu;
};
let others: Vec<BufferId> = files.iter().copied().filter(|f| *f != id).collect();
let right = files[pos + 1..].to_vec();
let close = |label: L10nKey, ids: Vec<BufferId>| {
let app = app.clone();
PopupMenuItem::new(t(label))
.disabled(ids.is_empty())
.on_click(move |_, window, cx| {
let ids = ids.clone();
let _ = app.update(cx, |this, cx| this.editor_close_buffers(ids, window, cx));
})
};
let menu = menu
.min_w(px(220.))
.item(
PopupMenuItem::new(t(L10nKey::TabContextCloseTab)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.editor_close_buffer(id, window, cx));
}
}),
)
.item(close(L10nKey::AppMenuCloseOtherTabs, others))
.item(close(L10nKey::AppMenuCloseTabsRight, right));
this.editor_file_menu_items(menu, id, app, cx)
}
/// What both menus offer for the file as a whole: open it outside tty7,
/// show it in its folder, copy where it is. Nothing for a file that has
/// never been saved, since it is not anywhere yet.
fn editor_file_menu_items(
&self,
menu: PopupMenu,
id: BufferId,
app: &gpui::WeakEntity<Self>,
cx: &gpui::App,
) -> PopupMenu {
let Some(f) = self.buffer(id).filter(|f| f.untitled.is_none()) else {
return menu;
};
let path = f.path.clone();
// Only this machine's desktop can open or show a file, and only a
// file that is on this machine — the same rule as the file tree.
let local = f.host.id().is_local();
let mut menu = menu.separator();
if local && self.can_spawn_locally(cx) && !is_program(&path) {
let browser = opens_in_browser(&path);
let label = match browser {
true => t(L10nKey::PanelOpenInBrowser),
false => t(L10nKey::AppMenuOpenLinkWithDefaultApp),
};
let opener: fn(&Path) -> std::io::Result<()> = match browser {
true => open_in_browser,
false => crate::terminal::view::open_file_path,
};
menu = menu.item(PopupMenuItem::new(label).on_click({
let app = app.clone();
let path = path.clone();
move |_, window, cx| {
let _ = app.update(cx, |_, cx| Self::open_with(&path, opener, window, cx));
}
}));
}
if local {
menu = menu.item(
PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({
let path = path.clone();
move |_, _window, cx| {
cx.reveal_path(&crate::ui::path_display::native_separators(&path));
}
}),
);
}
let copy = |label: L10nKey, text: String| {
PopupMenuItem::new(t(label)).on_click(move |_, _window, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
})
};
// A remote host's paths are already spelled its own way; only one on
// this machine is re-spelled with its separators.
let full = match local {
true => crate::ui::path_display::native_separators(&path)
.display()
.to_string(),
false => path.display().to_string(),
};
menu = menu.item(copy(L10nKey::FileTreeContextCopyPath, full));
if let Some(rel) = self.path_under_tree_root(&path) {
let rel = match local {
true => crate::ui::path_display::native_separators(&rel)
.display()
.to_string(),
false => rel.display().to_string(),
};
menu = menu.item(copy(L10nKey::EditorCopyRelativePath, rel));
}
menu
}
pub(crate) fn editor_close_active_if_focused(
&mut self,
window: &mut Window,
@@ -2102,6 +2403,13 @@ impl Tty7App {
self.close_tab(ix, window, cx);
}
}
AfterUnsaved::CloseFiles(tab, ids) => {
if let Some(ix) = self.tab_index_of(tab) {
for id in ids {
self.editor_remove_from_tab(ix, id, cx);
}
}
}
AfterUnsaved::ClosePane => self.close_pane_after_unsaved(window, cx),
AfterUnsaved::CloseWindow => self.close_window_after_unsaved(window, cx),
AfterUnsaved::Quit => self.quit_after_unsaved(window, cx),
@@ -2549,11 +2857,21 @@ impl Tty7App {
}
Some(f) => {
let input = f.input.clone();
Input::new(&input)
.appearance(false)
.font_family(cx.theme().mono_font_family.clone())
.text_size(cx.theme().mono_font_size)
let id = f.id();
let app = cx.entity().downgrade();
div()
.id("editor-body")
.size_full()
.child(
Input::new(&input)
.appearance(false)
.font_family(cx.theme().mono_font_family.clone())
.text_size(cx.theme().mono_font_size)
.size_full(),
)
.context_menu(move |menu, _window, cx| {
Self::editor_body_menu(menu, &app, id, cx)
})
.into_any_element()
}
};
@@ -2849,6 +3167,10 @@ impl Tty7App {
this.editor_close_file(pos, window, cx);
}),
)
.context_menu({
let app = cx.entity().downgrade();
move |menu, _window, cx| Self::editor_tab_menu(menu, &app, pos, cx)
})
.into_any_element()
}
@@ -3266,6 +3588,17 @@ mod tests {
assert_eq!(&new[s..ne], "丰");
}
#[test]
fn an_agent_is_told_the_lines_a_selection_covers() {
let text = gpui_component::input::Rope::from("one\ntwo\nthree\n");
assert_eq!(selected_lines(&text, 0..0), None);
assert_eq!(selected_lines(&text, 1..2), Some((1, 1)));
assert_eq!(selected_lines(&text, 2..9), Some((1, 3)));
// Whole lines picked by dragging to the start of the next one.
assert_eq!(selected_lines(&text, 4..14), Some((2, 3)));
assert_eq!(selected_lines(&text, 0..4), Some((1, 1)));
}
#[test]
fn go_to_line_reads_the_forms_people_type() {
assert_eq!(parse_line_target("120"), Some((120, 1)));
+23 -5
View File
@@ -1607,20 +1607,38 @@ impl Tty7App {
}
fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context<Self>) {
self.attach_path_to_agent(path, "", cx);
}
/// Types `@path` into the running agent's prompt, relative to the tree's
/// root when the file is under one. `suffix` goes straight after the
/// path — the editor adds the selected lines as `#L3-9`.
pub(crate) fn attach_path_to_agent(
&mut self,
path: &Path,
suffix: &str,
cx: &mut Context<Self>,
) {
let Some(target) = self.agent_target_leaf(cx) else {
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent));
return;
};
let rel = self
.tab_code()
.path_under_tree_root(path)
.unwrap_or_else(|| path.to_path_buf());
target.update(cx, |view, cx| {
view.paste(format!("@{}{suffix} ", rel.display()), cx);
});
}
/// `path` relative to the first of this tab's tree roots it sits under.
pub(crate) fn path_under_tree_root(&self, path: &Path) -> Option<PathBuf> {
self.tab_code()
.into_iter()
.flat_map(|c| c.roots.iter())
.find_map(|r| path.strip_prefix(r).ok())
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| path.to_path_buf());
target.update(cx, |view, cx| {
view.paste(format!("@{} ", rel.display()), cx);
});
}
}
+1
View File
@@ -1197,6 +1197,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorAlreadyOpen => "\"{path}\" is already open in the editor",
L10nKey::EditorGoToLine => "Go to line",
L10nKey::EditorGoToLineAction => "Go to Line…",
L10nKey::EditorCopyRelativePath => "Copy Relative Path",
L10nKey::EditorGoToLinePlaceholder => "Line, or line:column (1–{total})",
L10nKey::EditorSaveAs => "Save as",
L10nKey::EditorSaveAsAction => "Save As…",
+1
View File
@@ -1245,6 +1245,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorAlreadyOpen => "「{path}」はすでにエディタで開かれています",
L10nKey::EditorGoToLine => "行へ移動",
L10nKey::EditorGoToLineAction => "行へ移動…",
L10nKey::EditorCopyRelativePath => "相対パスをコピー",
L10nKey::EditorGoToLinePlaceholder => "行番号、または 行:列(1–{total})",
L10nKey::EditorSaveAs => "名前を付けて保存",
L10nKey::EditorSaveAsAction => "名前を付けて保存…",
+1
View File
@@ -933,6 +933,7 @@ l10n_keys! {
EditorAlreadyOpen,
EditorGoToLine,
EditorGoToLineAction,
EditorCopyRelativePath,
EditorGoToLinePlaceholder,
EditorSaveAs,
EditorSaveAsAction,
+1
View File
@@ -1117,6 +1117,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorAlreadyOpen => "“{path}”已在编辑器中打开",
L10nKey::EditorGoToLine => "跳转到行",
L10nKey::EditorGoToLineAction => "跳转到行…",
L10nKey::EditorCopyRelativePath => "复制相对路径",
L10nKey::EditorGoToLinePlaceholder => "行号,或 行:列(1–{total})",
L10nKey::EditorSaveAs => "另存为",
L10nKey::EditorSaveAsAction => "另存为…",