From 0d1922ea9010ef7302db18d6c2b5ad09f110ee9b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:49:56 +0800 Subject: [PATCH] feat(editor): text commands in the palette and keymap Transform to Upper/Lower/Title Case, Trim Trailing Whitespace, Join Lines and Remove Surrounding Brackets, as tty7 actions (bindable on the Keybindings page, unbound by default) and as palette rows offered while a buffer is open. Both run gpui-component's editing action on the active buffer. Join Lines gets VS Code's ctrl-j as a fixed CodeEditor binding on macOS, where the terminal keeps it as a line feed. Strings in en, zh, ja. --- src/core/actions.rs | 6 +++ src/ui/app.rs | 54 +++++++++++++++++++++++ src/ui/code_editor.rs | 95 ++++++++++++++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 6 +++ src/ui/i18n/ja.rs | 6 +++ src/ui/i18n/mod.rs | 6 +++ src/ui/i18n/zh.rs | 6 +++ src/ui/keymap.rs | 54 ++++++++++++++++++++++- src/ui/search/command.rs | 21 +++++++++ 9 files changed, 252 insertions(+), 2 deletions(-) diff --git a/src/core/actions.rs b/src/core/actions.rs index 94a2582a..a358a011 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -135,6 +135,12 @@ actions!( EditorQuickFix, EditorRenameSymbol, EditorFormatDocument, + EditorTransformUppercase, + EditorTransformLowercase, + EditorTransformTitleCase, + EditorTrimTrailingWhitespace, + EditorJoinLines, + EditorRemoveSurroundingBrackets, EditorNextChange, EditorPrevChange, EditorRevertChange, diff --git a/src/ui/app.rs b/src/ui/app.rs index f0017ebd..afd645f5 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5914,6 +5914,37 @@ impl Tty7App { } } + // The editor's text commands, while there is a buffer to run them on. + if self.editor_can_dispatch() { + actions.extend( + [ + Item::localized( + L10nKey::CmdEditorTransformUppercase, + CommandKind::EditorTransformUppercase, + ), + Item::localized( + L10nKey::CmdEditorTransformLowercase, + CommandKind::EditorTransformLowercase, + ), + Item::localized( + L10nKey::CmdEditorTransformTitleCase, + CommandKind::EditorTransformTitleCase, + ), + Item::localized( + L10nKey::CmdEditorTrimTrailingWhitespace, + CommandKind::EditorTrimTrailingWhitespace, + ), + Item::localized(L10nKey::CmdEditorJoinLines, CommandKind::EditorJoinLines), + Item::localized( + L10nKey::CmdEditorRemoveSurroundingBrackets, + CommandKind::EditorRemoveSurroundingBrackets, + ), + ] + .into_iter() + .map(|item| item.in_group(CommandGroup::View)), + ); + } + // Offered only where it would do something. A connection opened from a // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing @@ -6313,6 +6344,28 @@ impl Tty7App { } ToggleDocumentPreview => self.toggle_document_preview(cx), ToggleDocumentWrap => self.toggle_document_wrap(window, cx), + EditorTransformUppercase => { + self.editor_dispatch(&gpui_component::input::TransformToUppercase, window, cx); + } + EditorTransformLowercase => { + self.editor_dispatch(&gpui_component::input::TransformToLowercase, window, cx); + } + EditorTransformTitleCase => { + self.editor_dispatch(&gpui_component::input::TransformToTitleCase, window, cx); + } + EditorTrimTrailingWhitespace => { + self.editor_dispatch(&gpui_component::input::TrimTrailingWhitespace, window, cx); + } + EditorJoinLines => { + self.editor_dispatch(&gpui_component::input::JoinLines, window, cx); + } + EditorRemoveSurroundingBrackets => { + self.editor_dispatch( + &gpui_component::input::RemoveSurroundingBrackets, + window, + cx, + ); + } RestartSshSession => self.restart_ssh_session(window, cx), SetTheme(i) => { if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) { @@ -9528,6 +9581,7 @@ impl Render for Tty7App { .children(self.render_switcher(window, cx)) .when_some(self.search.clone(), |this, search| this.child(search)) .children(gpui_component::Root::render_notification_layer(window, cx)); + let root = Self::with_editor_text_commands(root, cx); if let Some(start) = prof { crate::ui::perf::record("window", start.elapsed()); diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index f751c6a6..4b38af55 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -1540,6 +1540,101 @@ impl Tty7App { self.editor_note_edit(id, cx); } + /// Run one of gpui-component's editing actions on the active buffer, as + /// if it had been pressed there. Returns false with no buffer to run it + /// on. The palette closes before its row runs, so this focuses the + /// buffer first rather than relying on where the focus happens to be. + pub(crate) fn editor_dispatch( + &mut self, + action: &dyn gpui::Action, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.code_panel_visible() { + return false; + } + let Some(input) = self.active_buffer().map(|f| f.input.clone()) else { + return false; + }; + let handle = input.read(cx).focus_handle(cx); + window.focus(&handle, cx); + handle.dispatch_action(action, window, cx); + true + } + + /// The window's listeners for the editor's text commands, when the user + /// has bound one: each runs on the editor only while it has the focus. + pub(crate) fn with_editor_text_commands( + root: gpui::Stateful, + cx: &mut Context, + ) -> gpui::Stateful { + use crate::core::actions::{ + EditorJoinLines, EditorRemoveSurroundingBrackets, EditorTransformLowercase, + EditorTransformTitleCase, EditorTransformUppercase, EditorTrimTrailingWhitespace, + }; + root.on_action( + cx.listener(|this, _: &EditorTransformUppercase, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TransformToUppercase, window, cx); + }), + ) + .on_action( + cx.listener(|this, _: &EditorTransformLowercase, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TransformToLowercase, window, cx); + }), + ) + .on_action( + cx.listener(|this, _: &EditorTransformTitleCase, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TransformToTitleCase, window, cx); + }), + ) + .on_action( + cx.listener(|this, _: &EditorTrimTrailingWhitespace, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::TrimTrailingWhitespace, window, cx); + }), + ) + .on_action(cx.listener(|this, _: &EditorJoinLines, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch(&gpui_component::input::JoinLines, window, cx); + })) + .on_action( + cx.listener(|this, _: &EditorRemoveSurroundingBrackets, window, cx| { + if !this.editor_has_focus(window, cx) { + cx.propagate(); + return; + } + this.editor_dispatch( + &gpui_component::input::RemoveSurroundingBrackets, + window, + cx, + ); + }), + ) + } + + /// Whether an editor exists to run [`Self::editor_dispatch`] on. + pub(crate) fn editor_can_dispatch(&self) -> bool { + self.code_panel_visible() && self.active_buffer().is_some() + } + pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context) -> bool { self.code_panel_visible() && self.active_buffer().is_some_and(|f| { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 9f03e611..b428e917 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1717,6 +1717,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdDocumentWidthTwoThirds => "Document: Two-Thirds Width", L10nKey::CmdToggleDocumentPreview => "Document: Toggle Markdown Preview", L10nKey::CmdToggleDocumentWrap => "Document: Toggle Word Wrap", + L10nKey::CmdEditorTransformUppercase => "Document: Transform to Uppercase", + L10nKey::CmdEditorTransformLowercase => "Document: Transform to Lowercase", + L10nKey::CmdEditorTransformTitleCase => "Document: Transform to Title Case", + L10nKey::CmdEditorTrimTrailingWhitespace => "Document: Trim Trailing Whitespace", + L10nKey::CmdEditorJoinLines => "Document: Join Lines", + L10nKey::CmdEditorRemoveSurroundingBrackets => "Document: Remove Surrounding Brackets", L10nKey::CmdGitCommit => "Git: Commit", L10nKey::CmdGitStageAll => "Git: Stage All Changes", L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index dcf1568b..6201bee9 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1762,6 +1762,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdDocumentWidthTwoThirds => "ドキュメント: 幅3分の2", L10nKey::CmdToggleDocumentPreview => "ドキュメント: Markdown プレビューを切り替え", L10nKey::CmdToggleDocumentWrap => "ドキュメント: 折り返しを切り替え", + L10nKey::CmdEditorTransformUppercase => "ドキュメント: 大文字に変換", + L10nKey::CmdEditorTransformLowercase => "ドキュメント: 小文字に変換", + L10nKey::CmdEditorTransformTitleCase => "ドキュメント: 単語の先頭を大文字に変換", + L10nKey::CmdEditorTrimTrailingWhitespace => "ドキュメント: 末尾の空白を削除", + L10nKey::CmdEditorJoinLines => "ドキュメント: 行を結合", + L10nKey::CmdEditorRemoveSurroundingBrackets => "ドキュメント: 外側の括弧を削除", L10nKey::CmdGitCommit => "Git: コミット", L10nKey::CmdGitStageAll => "Git: すべての変更をステージ", L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 146d7ca3..57e3b471 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1463,6 +1463,12 @@ l10n_keys! { CmdDocumentWidthTwoThirds, CmdToggleDocumentPreview, CmdToggleDocumentWrap, + CmdEditorTransformUppercase, + CmdEditorTransformLowercase, + CmdEditorTransformTitleCase, + CmdEditorTrimTrailingWhitespace, + CmdEditorJoinLines, + CmdEditorRemoveSurroundingBrackets, CmdGitCommit, CmdGitStageAll, CmdGitUnstageAll, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index e4016d4d..19faf99a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1593,6 +1593,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdDocumentWidthTwoThirds => "文档:三分之二宽", L10nKey::CmdToggleDocumentPreview => "文档:切换 Markdown 预览", L10nKey::CmdToggleDocumentWrap => "文档:切换自动换行", + L10nKey::CmdEditorTransformUppercase => "文档:转换为大写", + L10nKey::CmdEditorTransformLowercase => "文档:转换为小写", + L10nKey::CmdEditorTransformTitleCase => "文档:转换为词首字母大写", + L10nKey::CmdEditorTrimTrailingWhitespace => "文档:删除行尾空白", + L10nKey::CmdEditorJoinLines => "文档:合并行", + L10nKey::CmdEditorRemoveSurroundingBrackets => "文档:删除外层括号", L10nKey::CmdGitCommit => "Git:提交", L10nKey::CmdGitStageAll => "Git:暂存全部更改", L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 8d851d0c..748d32e6 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -135,6 +135,12 @@ fn fixed_bindings() -> Vec { KeyBinding::new("secondary-l", SelectLine, editor), KeyBinding::new("secondary-shift-\\", MoveToMatchingBracket, editor), ]); + #[cfg(target_os = "macos")] + bindings.push(KeyBinding::new( + "ctrl-j", + gpui_component::input::JoinLines, + editor, + )); } bindings } @@ -680,6 +686,15 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { ("EditorQuickFix", ""), ("EditorRenameSymbol", ""), ("EditorFormatDocument", ""), + // The editor's text commands. Unbound: none of them has a chord in + // VS Code either, except Join Lines, whose ⌃J is a fixed `CodeEditor` + // binding on macOS (in the terminal it is a line feed). + ("EditorTransformUppercase", ""), + ("EditorTransformLowercase", ""), + ("EditorTransformTitleCase", ""), + ("EditorTrimTrailingWhitespace", ""), + ("EditorJoinLines", ""), + ("EditorRemoveSurroundingBrackets", ""), // Alt+F5 / Shift+Alt+F5 (VS Code's) are fixed `Input`-context // bindings: a default on a function key would hide it from the shell. ("EditorNextChange", ""), @@ -985,6 +1000,30 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Terminal, t(L10nKey::LspFormatDocument).to_string(), ), + "EditorTransformUppercase" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTransformUppercase).to_string(), + ), + "EditorTransformLowercase" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTransformLowercase).to_string(), + ), + "EditorTransformTitleCase" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTransformTitleCase).to_string(), + ), + "EditorTrimTrailingWhitespace" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorTrimTrailingWhitespace).to_string(), + ), + "EditorJoinLines" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorJoinLines).to_string(), + ), + "EditorRemoveSurroundingBrackets" => ( + CommandGroup::Terminal, + t(L10nKey::CmdEditorRemoveSurroundingBrackets).to_string(), + ), "EditorGoToSymbol" => ( CommandGroup::Terminal, t(L10nKey::EditorGoToSymbolAction).to_string(), @@ -1730,6 +1769,16 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "EditorQuickFix" => KeyBinding::new(keystroke, EditorQuickFix, None), "EditorRenameSymbol" => KeyBinding::new(keystroke, EditorRenameSymbol, None), "EditorFormatDocument" => KeyBinding::new(keystroke, EditorFormatDocument, None), + "EditorTransformUppercase" => KeyBinding::new(keystroke, EditorTransformUppercase, None), + "EditorTransformLowercase" => KeyBinding::new(keystroke, EditorTransformLowercase, None), + "EditorTransformTitleCase" => KeyBinding::new(keystroke, EditorTransformTitleCase, None), + "EditorTrimTrailingWhitespace" => { + KeyBinding::new(keystroke, EditorTrimTrailingWhitespace, None) + } + "EditorJoinLines" => KeyBinding::new(keystroke, EditorJoinLines, None), + "EditorRemoveSurroundingBrackets" => { + KeyBinding::new(keystroke, EditorRemoveSurroundingBrackets, None) + } "EditorNextChange" => KeyBinding::new(keystroke, EditorNextChange, None), "EditorPrevChange" => KeyBinding::new(keystroke, EditorPrevChange, None), "EditorRevertChange" => KeyBinding::new(keystroke, EditorRevertChange, None), @@ -2782,7 +2831,8 @@ mod gpui_tests { .map(|b| b.action().name()) }; let editor = ["Workspace", "Input CodeEditor"]; - for (keys, action) in [ + let join_lines = cfg!(target_os = "macos").then_some(("ctrl-j", "input::JoinLines")); + for (keys, action) in join_lines.into_iter().chain([ ("secondary-/", "input::ToggleLineComment"), ("alt-up", "input::MoveLineUp"), ("alt-down", "input::MoveLineDown"), @@ -2793,7 +2843,7 @@ mod gpui_tests { ("secondary-shift-enter", "input::InsertLineAbove"), ("secondary-l", "input::SelectLine"), ("secondary-shift-\\", "input::MoveToMatchingBracket"), - ] { + ]) { assert_eq!(first(keys, &editor), Some(action), "{keys} in the editor"); assert_ne!( first(keys, &["Workspace", "Input"]), diff --git a/src/ui/search/command.rs b/src/ui/search/command.rs index 7dea7c65..84644115 100644 --- a/src/ui/search/command.rs +++ b/src/ui/search/command.rs @@ -90,6 +90,13 @@ pub enum CommandKind { DocumentWidthTwoThirds, ToggleDocumentPreview, ToggleDocumentWrap, + /// The code editor's text commands, run on the active buffer. + EditorTransformUppercase, + EditorTransformLowercase, + EditorTransformTitleCase, + EditorTrimTrailingWhitespace, + EditorJoinLines, + EditorRemoveSurroundingBrackets, RestartSshSession, ScmCommit, ScmStageAll, @@ -252,6 +259,12 @@ impl CommandKind { DocumentWidthTwoThirds => "document-width-two-thirds", ToggleDocumentPreview => "document-preview", ToggleDocumentWrap => "document-wrap", + EditorTransformUppercase => "editor-uppercase", + EditorTransformLowercase => "editor-lowercase", + EditorTransformTitleCase => "editor-title-case", + EditorTrimTrailingWhitespace => "editor-trim-trailing-whitespace", + EditorJoinLines => "editor-join-lines", + EditorRemoveSurroundingBrackets => "editor-remove-brackets", RestartSshSession => "ssh-reconnect", ScmCommit => "git-commit", ScmStageAll => "git-stage-all", @@ -298,6 +311,8 @@ impl CommandKind { CutText => return inline("secondary-x"), PasteText => return inline("secondary-v"), SelectAllText => return inline("secondary-a"), + // A fixed `CodeEditor` binding, not one of the keymap's slots. + EditorJoinLines => return inline("ctrl-j"), LaunchAgent(agent) => { return crate::ui::keymap::effective_key( crate::ui::agent_launch::launch_action_name(*agent), @@ -381,6 +396,12 @@ impl CommandKind { DocumentWidthTwoThirds => "DocumentWidthTwoThirds", ToggleDocumentPreview => "ToggleDocumentPreview", ToggleDocumentWrap => "ToggleDocumentWrap", + EditorTransformUppercase => "EditorTransformUppercase", + EditorTransformLowercase => "EditorTransformLowercase", + EditorTransformTitleCase => "EditorTransformTitleCase", + EditorTrimTrailingWhitespace => "EditorTrimTrailingWhitespace", + EditorJoinLines => "EditorJoinLines", + EditorRemoveSurroundingBrackets => "EditorRemoveSurroundingBrackets", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", ScmCommit => "ScmCommit",