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.
This commit is contained in:
l0ng-ai
2026-09-28 17:49:56 +08:00
parent 59ea010347
commit 0d1922ea90
9 changed files with 252 additions and 2 deletions
+6
View File
@@ -135,6 +135,12 @@ actions!(
EditorQuickFix,
EditorRenameSymbol,
EditorFormatDocument,
EditorTransformUppercase,
EditorTransformLowercase,
EditorTransformTitleCase,
EditorTrimTrailingWhitespace,
EditorJoinLines,
EditorRemoveSurroundingBrackets,
EditorNextChange,
EditorPrevChange,
EditorRevertChange,
+54
View File
@@ -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());
+95
View File
@@ -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<Self>,
) -> 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<gpui::Div>,
cx: &mut Context<Self>,
) -> gpui::Stateful<gpui::Div> {
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<Self>) -> bool {
self.code_panel_visible()
&& self.active_buffer().is_some_and(|f| {
+6
View File
@@ -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",
+6
View File
@@ -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: すべてのステージを取り消す",
+6
View File
@@ -1463,6 +1463,12 @@ l10n_keys! {
CmdDocumentWidthTwoThirds,
CmdToggleDocumentPreview,
CmdToggleDocumentWrap,
CmdEditorTransformUppercase,
CmdEditorTransformLowercase,
CmdEditorTransformTitleCase,
CmdEditorTrimTrailingWhitespace,
CmdEditorJoinLines,
CmdEditorRemoveSurroundingBrackets,
CmdGitCommit,
CmdGitStageAll,
CmdGitUnstageAll,
+6
View File
@@ -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:取消暂存全部更改",
+52 -2
View File
@@ -135,6 +135,12 @@ fn fixed_bindings() -> Vec<KeyBinding> {
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<KeyBinding> {
"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"]),
+21
View File
@@ -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",