mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #175 from l0ng-ai/feat/ux-overhaul
feat(ux): rebuild the menu bar, command palette, and Settings IA
This commit is contained in:
@@ -38,6 +38,16 @@ actions!(
|
||||
SelectWorkspace8,
|
||||
SelectWorkspace9,
|
||||
CloseActiveTab,
|
||||
// Tab operations that until now existed only as tab-context-menu rows,
|
||||
// reachable by right-clicking the *right* chip. As actions they also
|
||||
// reach the menu bar, the palette, and Settings → Keybindings; each acts
|
||||
// on the active tab, which is what "this tab" means with no chip clicked.
|
||||
RenameTab,
|
||||
NewWorktreeTab,
|
||||
CloseOtherTabs,
|
||||
CloseTabsToTheRight,
|
||||
CopyWorkingDirectory,
|
||||
MarkTabUnread,
|
||||
SplitRight,
|
||||
SplitDown,
|
||||
FocusNextPane,
|
||||
@@ -100,6 +110,32 @@ actions!(
|
||||
ShowRightPanelChanges,
|
||||
ShowRightPanelFiles,
|
||||
OpenSettings,
|
||||
// Open Settings straight to its Keybindings section — the Help menu's
|
||||
// "Keyboard Shortcuts" and the palette's shortcut entry both land here,
|
||||
// rather than making the user open Settings and then find the section.
|
||||
ShowKeyboardShortcuts,
|
||||
// Open Settings on the About section. The macOS App menu's first item
|
||||
// has to exist and has to be called "About tty7"; routing it to the
|
||||
// section that already carries version/links keeps one About, not two.
|
||||
About,
|
||||
// Run the same update check the app does at startup (see `core::update`)
|
||||
// on demand, then report the outcome. Previously only the tray offered
|
||||
// this, which is not where a Mac user looks for it.
|
||||
CheckForUpdates,
|
||||
// Standard macOS App-menu items. gpui exposes the platform calls but
|
||||
// binds nothing by default, so they need real actions to hang off.
|
||||
HideApp,
|
||||
HideOthers,
|
||||
ShowAll,
|
||||
// Standard macOS Window-menu items.
|
||||
MinimizeWindow,
|
||||
ZoomWindow,
|
||||
// Help menu destinations. Each opens a URL in the default browser; kept
|
||||
// as separate actions (rather than one parameterized one) so they can be
|
||||
// bound and searched by name like everything else.
|
||||
OpenDocumentation,
|
||||
OpenDiscord,
|
||||
ReportIssue,
|
||||
RestartDaemon,
|
||||
// Show the detail panel's Files tab, which browses the focused pane's
|
||||
// remote filesystem over SFTP when that pane is native SSH (WS5).
|
||||
|
||||
@@ -303,6 +303,15 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
|
||||
|
||||
/// Per-command usage for the palette's "Recent" group, keyed by the stable
|
||||
/// id in `ui::palette::CommandKind::id`. The static command list is ordered
|
||||
/// by hand, which means the first screenful is whatever the author typed
|
||||
/// first rather than what this user actually runs; this is what lets the
|
||||
/// palette lead with the latter. Only commands with a stable id are tracked
|
||||
/// — a "switch to tab 3" is not a thing to be recently-used.
|
||||
#[serde(default)]
|
||||
pub command_frecency: HashMap<String, ProfileUsage>,
|
||||
|
||||
// ── CLI coding agents ────────────────────────────────────────────────────
|
||||
/// User-defined agent-detection rules: a command basename → an agent slug
|
||||
/// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get
|
||||
@@ -581,6 +590,7 @@ impl Default for Config {
|
||||
verify_host_keys: true,
|
||||
ssh_warn_on_close: false,
|
||||
ssh_profile_frecency: HashMap::new(),
|
||||
command_frecency: HashMap::new(),
|
||||
agent_commands: HashMap::new(),
|
||||
restore_agent_sessions: true,
|
||||
}
|
||||
|
||||
+171
-82
@@ -40,15 +40,24 @@ use crate::daemon::protocol::{RemoteContext, ShellSpec};
|
||||
const GRID_PAD_X: f32 = 8.;
|
||||
const GRID_PAD_Y: f32 = 4.;
|
||||
|
||||
// Terminal-scoped actions dispatched by the right-click context menu. They route
|
||||
// to this view via `.on_action` handlers on the terminal surface; tab/split
|
||||
// actions in the same menu bubble up to `Tty7App` from the focused terminal.
|
||||
// Terminal-scoped actions dispatched by the right-click context menu and the
|
||||
// menu bar's Edit menu. They route to this view via `.on_action` handlers on the
|
||||
// terminal surface; tab/split actions in the same menu bubble up to `Tty7App`
|
||||
// from the focused terminal.
|
||||
//
|
||||
// Every one of these is the *single* path for its gesture: the ⌘-chord, the
|
||||
// context-menu row, and the Edit-menu item all dispatch the same action, so the
|
||||
// three can't drift (they did — the context menu's Paste used to skip the
|
||||
// image-paste branch that ⌘V had).
|
||||
actions!(
|
||||
terminal,
|
||||
[
|
||||
CopyText,
|
||||
CutText,
|
||||
PasteText,
|
||||
SelectAll,
|
||||
UndoEdit,
|
||||
RedoEdit,
|
||||
FindInTerminal,
|
||||
FindNext,
|
||||
FindPrevious,
|
||||
@@ -1605,73 +1614,34 @@ impl TerminalView {
|
||||
) -> CmdKey {
|
||||
let m = &ks.modifiers;
|
||||
match ks.key.as_str() {
|
||||
// Copy / cut / paste route through the same methods the `CopyText` /
|
||||
// `CutText` / `PasteText` actions call, so the chord, the right-click
|
||||
// row and the Edit menu can't drift apart.
|
||||
"c" => {
|
||||
// At the prompt, ⌘C copies the editor's selection — but only when
|
||||
// the editor actually has one. With no editor selection we must NOT
|
||||
// swallow the key: the user may have mouse-selected terminal
|
||||
// output/scrollback (which lives in `term.selection`), so fall
|
||||
// through to the terminal-selection branch below.
|
||||
if self.input_active() {
|
||||
if let Some(text) = self.cmd.selected_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
// Same dual-purpose rule as the terminal selection
|
||||
// below: a Ctrl+C copy consumes the editor selection,
|
||||
// so the next press reaches the editor's ^C (abort
|
||||
// line) instead of copying forever (#111).
|
||||
if m.control {
|
||||
self.cmd.clear_selection();
|
||||
cx.notify();
|
||||
}
|
||||
return CmdKey::Consumed;
|
||||
}
|
||||
// `clear_on_copy`: Ctrl+C is dual-purpose — copy with a
|
||||
// selection, ^C (SIGINT) without — so the copy must consume the
|
||||
// selection or the next press copies again instead of
|
||||
// interrupting (#111). ⌘C never doubles as SIGINT, so there the
|
||||
// selection stays highlighted (the macOS convention).
|
||||
if self.copy_contextual(m.control, cx) {
|
||||
CmdKey::Consumed
|
||||
} else {
|
||||
// Nothing was selected anywhere: don't swallow the key, so
|
||||
// Ctrl+C still reaches the PTY as ^C.
|
||||
CmdKey::FallThrough
|
||||
}
|
||||
// Copy the terminal selection, if any; else fall through
|
||||
// (Ctrl+C handles SIGINT).
|
||||
if self.has_selection() {
|
||||
self.copy_selection(cx);
|
||||
// Ctrl+C is dual-purpose — copy with a selection, ^C
|
||||
// (SIGINT) without — so the copy must consume the selection
|
||||
// or the next press copies again instead of interrupting
|
||||
// (#111). Cmd+C never doubles as SIGINT, so there the
|
||||
// selection stays highlighted (the macOS convention).
|
||||
if m.control {
|
||||
self.terminal.term.lock().selection = None;
|
||||
cx.notify();
|
||||
}
|
||||
return CmdKey::Consumed;
|
||||
}
|
||||
CmdKey::FallThrough
|
||||
}
|
||||
"x" => {
|
||||
// Cut: only meaningful in the editor with a selection — copy it
|
||||
// out, then delete it. Elsewhere it's a no-op (swallowed).
|
||||
if self.input_active() {
|
||||
if let Some(text) = self.cmd.selected_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
self.cmd.delete_selection();
|
||||
self.close_completion();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
}
|
||||
return CmdKey::Consumed;
|
||||
// Cut is editor-only; outside the prompt there is nothing to
|
||||
// remove, so the key falls through rather than dying silently.
|
||||
if self.cut_contextual(cx) {
|
||||
CmdKey::Consumed
|
||||
} else {
|
||||
CmdKey::FallThrough
|
||||
}
|
||||
CmdKey::FallThrough
|
||||
}
|
||||
"v" => {
|
||||
if let Some(item) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard_paste_text(&item) {
|
||||
self.paste(text, cx);
|
||||
} else if !self.input_active() {
|
||||
if let Some(img) = item.entries().iter().find_map(|e| match e {
|
||||
ClipboardEntry::Image(img) => Some(img),
|
||||
_ => None,
|
||||
}) {
|
||||
// Clipboard holds an image (e.g. a screenshot) with no text,
|
||||
// and a foreground TUI (a coding agent) owns the pane.
|
||||
self.paste_clipboard_image(img, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.paste_from_clipboard(cx);
|
||||
CmdKey::Consumed
|
||||
}
|
||||
// Find (open bar) and ⌘G / ⌘⇧G (next / previous match) are registered
|
||||
@@ -1688,15 +1658,7 @@ impl TerminalView {
|
||||
// The following are editor-only (macOS line editing); they're swallowed
|
||||
// elsewhere since they have no terminal meaning.
|
||||
"z" => {
|
||||
if self.input_active() {
|
||||
if m.shift {
|
||||
self.cmd.redo();
|
||||
} else {
|
||||
self.cmd.undo();
|
||||
}
|
||||
self.close_completion();
|
||||
cx.notify();
|
||||
}
|
||||
self.undo_edit(m.shift, cx);
|
||||
CmdKey::Consumed
|
||||
}
|
||||
"left" => {
|
||||
@@ -2220,6 +2182,14 @@ impl TerminalView {
|
||||
self.terminal.term.lock().selection.is_some()
|
||||
}
|
||||
|
||||
/// Is there anything [`copy_contextual`](Self::copy_contextual) would copy —
|
||||
/// in the grid *or* in the prompt editor? What the Copy / Cut menu rows gate
|
||||
/// on: `has_selection` alone is grid-only, so a prompt selection used to
|
||||
/// leave "Copy" greyed out even though ⌘C would have copied it.
|
||||
fn any_selection(&self) -> bool {
|
||||
self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
|
||||
}
|
||||
|
||||
/// Snapshot the Kitty keyboard-protocol flags the app has enabled, read off the
|
||||
/// local `Term`'s mode bits (the reader thread keeps them current by advancing
|
||||
/// the emulator over all child output). Consulted by the key encoder so TUIs
|
||||
@@ -2496,15 +2466,117 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy whatever is selected, preferring the prompt editor's selection over
|
||||
/// the terminal grid's. Returns whether anything was actually copied — the
|
||||
/// ⌃C path needs to know, because with nothing selected the key has to fall
|
||||
/// through to ^C (SIGINT).
|
||||
///
|
||||
/// `clear_on_copy` drops the selection after copying. Ctrl+C is dual-purpose
|
||||
/// (copy with a selection, SIGINT without), so it must consume the selection
|
||||
/// or the next press copies forever instead of interrupting (#111); ⌘C and
|
||||
/// the menu items leave the highlight up, the macOS convention.
|
||||
///
|
||||
/// The single copy path: ⌘C / ⌃C, the right-click "Copy" row, and the Edit
|
||||
/// menu all land here.
|
||||
pub fn copy_contextual(&mut self, clear_on_copy: bool, cx: &mut Context<Self>) -> bool {
|
||||
// At the prompt the editor's selection wins — but only when it has one.
|
||||
// With no editor selection we fall on through: the user may have
|
||||
// mouse-selected terminal output/scrollback, which lives in
|
||||
// `term.selection`, not in the editor.
|
||||
if self.input_active() {
|
||||
if let Some(text) = self.cmd.selected_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
if clear_on_copy {
|
||||
self.cmd.clear_selection();
|
||||
cx.notify();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self.has_selection() {
|
||||
self.copy_selection(cx);
|
||||
if clear_on_copy {
|
||||
self.terminal.term.lock().selection = None;
|
||||
cx.notify();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Step to the next (`forward`) or previous search match. A no-op while the
|
||||
/// find bar is closed — there is nothing to step through. Exposed for the
|
||||
/// palette's "Find Next" / "Find Previous", which run from outside the
|
||||
/// terminal module and so can't reach `step_match` directly.
|
||||
pub fn find_step(&mut self, forward: bool, cx: &mut Context<Self>) {
|
||||
let direction = if forward {
|
||||
Direction::Right
|
||||
} else {
|
||||
Direction::Left
|
||||
};
|
||||
self.step_match(direction, cx);
|
||||
}
|
||||
|
||||
/// Undo (or, with `redo`, redo) the last prompt edit. Editor-only: the
|
||||
/// terminal grid has no edit history, so outside the prompt this is a no-op
|
||||
/// that still swallows the gesture rather than sending ⌘Z to the PTY.
|
||||
/// Shared by the ⌘Z chord and the Edit menu's Undo / Redo.
|
||||
pub fn undo_edit(&mut self, redo: bool, cx: &mut Context<Self>) {
|
||||
if !self.input_active() {
|
||||
return;
|
||||
}
|
||||
if redo {
|
||||
self.cmd.redo();
|
||||
} else {
|
||||
self.cmd.undo();
|
||||
}
|
||||
self.close_completion();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Cut the prompt editor's selection: copy it out, then delete it. Only
|
||||
/// meaningful at the prompt — the terminal grid is not editable — so this
|
||||
/// reports whether the gesture was *handled* (i.e. the prompt was active),
|
||||
/// not whether text was actually removed; a cut with nothing selected is
|
||||
/// still a no-op the prompt owns rather than a key the PTY should see.
|
||||
pub fn cut_contextual(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
if !self.input_active() {
|
||||
return false;
|
||||
}
|
||||
if let Some(text) = self.cmd.selected_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
self.cmd.delete_selection();
|
||||
self.close_completion();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Read the system clipboard and paste it into the PTY (bracketed-paste
|
||||
/// aware). Used by Cmd+V and the right-click "Paste" item.
|
||||
/// aware). The single paste path: ⌘V / ⌃V, the right-click "Paste" row, and
|
||||
/// the Edit menu.
|
||||
///
|
||||
/// Text wins when the clipboard carries any. Failing that — an image-only
|
||||
/// clipboard (a screenshot) dropped on a pane whose foreground app is a TUI
|
||||
/// coding agent — the image is written to a temp file and its path typed in,
|
||||
/// which is how those agents take attachments.
|
||||
pub fn paste_from_clipboard(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(text) = cx
|
||||
.read_from_clipboard()
|
||||
.as_ref()
|
||||
.and_then(clipboard_paste_text)
|
||||
{
|
||||
let Some(item) = cx.read_from_clipboard() else {
|
||||
return;
|
||||
};
|
||||
if let Some(text) = clipboard_paste_text(&item) {
|
||||
self.paste(text, cx);
|
||||
return;
|
||||
}
|
||||
if self.input_active() {
|
||||
return;
|
||||
}
|
||||
if let Some(img) = item.entries().iter().find_map(|e| match e {
|
||||
ClipboardEntry::Image(img) => Some(img),
|
||||
_ => None,
|
||||
}) {
|
||||
self.paste_clipboard_image(img, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5143,9 +5215,10 @@ impl Render for TerminalView {
|
||||
|
||||
// Captured for the right-click menu: the focus handle routes dispatched
|
||||
// actions to this terminal (and lets tab/split ones bubble to the root),
|
||||
// and the selection state greys out "Copy" when there's nothing selected.
|
||||
// and the selection state greys out "Copy" / "Cut" when there's nothing
|
||||
// selected in either the grid or the prompt editor.
|
||||
let menu_focus = self.focus_handle.clone();
|
||||
let has_selection = self.has_selection();
|
||||
let has_selection = self.any_selection();
|
||||
|
||||
div()
|
||||
.id("terminal-surface")
|
||||
@@ -5189,9 +5262,18 @@ impl Render for TerminalView {
|
||||
}))
|
||||
// Context-menu actions handled by this view; tab/split actions in the
|
||||
// same menu fall through to `Tty7App`.
|
||||
.on_action(cx.listener(|this, _: &CopyText, _w, cx| this.copy_selection(cx)))
|
||||
// Menu-dispatched copy leaves the selection up (`clear_on_copy:
|
||||
// false`) — only the dual-purpose ⌃C chord has to consume it.
|
||||
.on_action(cx.listener(|this, _: &CopyText, _w, cx| {
|
||||
this.copy_contextual(false, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CutText, _w, cx| {
|
||||
this.cut_contextual(cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx)))
|
||||
.on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx)))
|
||||
.on_action(cx.listener(|this, _: &UndoEdit, _w, cx| this.undo_edit(false, cx)))
|
||||
.on_action(cx.listener(|this, _: &RedoEdit, _w, cx| this.undo_edit(true, cx)))
|
||||
.on_action(
|
||||
cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)),
|
||||
)
|
||||
@@ -5241,7 +5323,7 @@ impl Render for TerminalView {
|
||||
// match the command palette's row height. A fixed min-width keeps
|
||||
// the menu a consistent, intentional size instead of hugging the
|
||||
// longest label (which reads ragged).
|
||||
// Copy/Paste/Select All/Find are dispatched inline (see
|
||||
// Copy/Cut/Paste/Select All are dispatched inline (see
|
||||
// `handle_cmd_shortcut`) with no registered `KeyBinding`, so the menu
|
||||
// can't auto-derive their hints the way it does for the items below.
|
||||
// We render the hint ourselves via `menu_row_with_hint` to keep the
|
||||
@@ -5254,6 +5336,13 @@ impl Render for TerminalView {
|
||||
!has_selection,
|
||||
menu_row_with_hint("Copy", Some("secondary-c")),
|
||||
)
|
||||
// Cut is prompt-only; it shares Copy's enablement cue rather
|
||||
// than offering a row that silently does nothing on output.
|
||||
.menu_element_with_disabled(
|
||||
Box::new(CutText),
|
||||
!has_selection,
|
||||
menu_row_with_hint("Cut", Some("secondary-x")),
|
||||
)
|
||||
.menu_element(
|
||||
Box::new(PasteText),
|
||||
menu_row_with_hint("Paste", Some("secondary-v")),
|
||||
|
||||
+179
-30
@@ -28,7 +28,9 @@ use crate::core::ssh_config;
|
||||
use crate::core::window_state::WindowState;
|
||||
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
|
||||
use crate::terminal::view::{ChildExited, TerminalView};
|
||||
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
|
||||
use crate::ui::palette::{
|
||||
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
|
||||
};
|
||||
use crate::ui::pane::{CloseOutcome, Dir, Pane};
|
||||
use crate::ui::presets::Fill;
|
||||
use crate::ui::settings::{
|
||||
@@ -123,6 +125,13 @@ pub(crate) const TILE_GLYPH_LINE: f32 = 16.;
|
||||
pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
|
||||
pub(crate) const TILE_PAD_SM: f32 = (TILE_SIZE_SM - TILE_GLYPH_SM) / 2.;
|
||||
|
||||
/// Help-menu destinations. The README already points people at these; the app
|
||||
/// itself offered none of them, so the only in-product way to reach the docs or
|
||||
/// the chat was to already know the URL.
|
||||
const DOCS_URL: &str = "https://github.com/l0ng-ai/tty7#readme";
|
||||
const DISCORD_URL: &str = "https://discord.gg/s3dethqz2V";
|
||||
const ISSUES_URL: &str = "https://github.com/l0ng-ai/tty7/issues/new";
|
||||
|
||||
/// The one content inset the whole window aligns to: the rail's text and icons,
|
||||
/// the title bar's chrome glyphs, and the side panels all start (or end) here, so
|
||||
/// every vertical edge in the chrome falls on one of two lines rather than the
|
||||
@@ -662,16 +671,19 @@ impl Tty7App {
|
||||
running in them is terminated."
|
||||
.to_string(),
|
||||
};
|
||||
// Phrased as the question it is, like every other prompt in the app —
|
||||
// this one used to be a bare statement of fact with two verbs under it.
|
||||
// The version details it used to carry in the title are in the body.
|
||||
let answer = window.prompt(
|
||||
PromptLevel::Warning,
|
||||
"Daemon Is From Another Version",
|
||||
"Restart Daemon?",
|
||||
Some(&detail),
|
||||
&["Keep Sessions", "Restart Daemon"],
|
||||
&["Keep Sessions", "Restart"],
|
||||
cx,
|
||||
);
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Index 1 == "Restart Daemon"; "Keep Sessions" or a dismissed
|
||||
// prompt leave the old daemon (and every session) untouched.
|
||||
// Index 1 == "Restart"; "Keep Sessions" or a dismissed prompt leave
|
||||
// the old daemon (and every session) untouched.
|
||||
if !matches!(answer.await, Ok(1)) {
|
||||
return;
|
||||
}
|
||||
@@ -1385,11 +1397,10 @@ impl Tty7App {
|
||||
}
|
||||
TrayAction::CheckForUpdates => {
|
||||
surface_window(window, cx);
|
||||
// Forced: a manual "check now" should work even when the
|
||||
// startup check is disabled. The result lands in the About
|
||||
// panel we open next (via the `UpdateStatus` global).
|
||||
crate::core::update::spawn_check_forced(cx);
|
||||
self.open_settings_section(SettingsSection::About, window, cx);
|
||||
// Same path as the App menu's "Check for Updates…" — the tray
|
||||
// used to carry its own copy of this, and was for a while the
|
||||
// only place in the app offering the check at all.
|
||||
self.check_for_updates_now(window, cx);
|
||||
}
|
||||
// Same as ⌘Q: sessions keep running in the daemon.
|
||||
TrayAction::Quit => cx.quit(),
|
||||
@@ -3276,6 +3287,24 @@ impl Tty7App {
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
}
|
||||
|
||||
/// Copy the active tab's working directory to the clipboard — the
|
||||
/// `CopyWorkingDirectory` action behind the File menu, the palette, and the
|
||||
/// tab context menu's row of the same name. A no-op when the pane has yet to
|
||||
/// report a cwd, which is also when the context-menu row renders disabled.
|
||||
pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context<Self>) {
|
||||
if let Some(cwd) = self.tab_cwd(self.active, window, cx) {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(cwd.display().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit "check now", from the App menu or the tray. Forced, so it
|
||||
/// works even with the startup check turned off — "I asked" outranks "don't
|
||||
/// ask on my behalf" — and it opens About, where the result lands.
|
||||
pub(crate) fn check_for_updates_now(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
crate::core::update::spawn_check_forced(cx);
|
||||
self.open_settings_section(SettingsSection::About, window, cx);
|
||||
}
|
||||
|
||||
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
|
||||
/// for the worktree operations, which shell out to a local `git`. "Copy
|
||||
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
|
||||
@@ -3468,7 +3497,15 @@ impl Tty7App {
|
||||
/// Build the full command catalog: the static commands plus one
|
||||
/// "Switch to Tab: …" entry per open tab (label matches the tab strip).
|
||||
fn palette_commands(&self, cx: &App) -> Vec<Command> {
|
||||
let mut commands = Command::base_commands();
|
||||
// This window's own chrome state, not the config's copy of it — see
|
||||
// `ChromeState`.
|
||||
let mut commands = Command::base_commands(
|
||||
cx,
|
||||
ChromeState {
|
||||
rail_collapsed: self.sidebar_collapsed,
|
||||
right_panel_visible: self.right_panel_visible,
|
||||
},
|
||||
);
|
||||
|
||||
// Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row
|
||||
// connects (natively) on Enter and edits on ⌘⏎ / →.
|
||||
@@ -3500,7 +3537,8 @@ impl Tty7App {
|
||||
format!("SSH: {title}"),
|
||||
CommandKind::ConnectSavedProfile(p.id),
|
||||
)
|
||||
.with_subtitle(subtitle),
|
||||
.with_subtitle(subtitle)
|
||||
.in_group(CommandGroup::Ssh),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3518,10 +3556,13 @@ impl Tty7App {
|
||||
continue;
|
||||
}
|
||||
let label = self.tab_label(tab, i, None, cx);
|
||||
commands.push(Command::new(
|
||||
format!("Switch to Tab: {label}"),
|
||||
CommandKind::ActivateTab(i),
|
||||
));
|
||||
commands.push(
|
||||
Command::new(
|
||||
format!("Switch to Tab: {label}"),
|
||||
CommandKind::ActivateTab(i),
|
||||
)
|
||||
.in_group(CommandGroup::TabsPanes),
|
||||
);
|
||||
}
|
||||
commands
|
||||
}
|
||||
@@ -3568,9 +3609,30 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The focused terminal of the active tab, for palette commands that act on
|
||||
/// the pane rather than the shell. The palette has already closed by the
|
||||
/// time these run, so focus is back where the user left it.
|
||||
fn focused_leaf(&self, window: &Window, cx: &App) -> Option<Entity<TerminalView>> {
|
||||
self.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
}
|
||||
|
||||
/// Record that a palette command was run, for the palette's Recent band.
|
||||
/// Only commands with a stable id are tracked (see `CommandKind::id`).
|
||||
fn bump_command_frecency(&mut self, kind: &CommandKind, cx: &mut Context<Self>) {
|
||||
let Some(id) = kind.id() else { return };
|
||||
self.update_config(cx, |cfg| {
|
||||
let entry = cfg.command_frecency.entry(id.to_string()).or_default();
|
||||
entry.count = entry.count.saturating_add(1);
|
||||
entry.last_used = crate::core::config::unix_now();
|
||||
});
|
||||
}
|
||||
|
||||
/// Run a palette command by dispatching to the matching tab/pane operation.
|
||||
fn run_command(&mut self, kind: CommandKind, window: &mut Window, cx: &mut Context<Self>) {
|
||||
use CommandKind::*;
|
||||
self.bump_command_frecency(&kind, cx);
|
||||
match kind {
|
||||
NewTab => self.new_tab(window, cx),
|
||||
NewWorkspace => crate::ui::windows::open(cx, None),
|
||||
@@ -3604,30 +3666,72 @@ impl Tty7App {
|
||||
ToggleRightPanel => self.toggle_right_panel(cx),
|
||||
ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx),
|
||||
ResetFontSize => self.reset_font_size(cx),
|
||||
// Pane-scoped commands act on the terminal the closing palette just
|
||||
// handed focus back to.
|
||||
FindInTerminal => {
|
||||
// Open the search bar on the pane focus just returned to (the
|
||||
// palette closed before we got here, restoring terminal focus).
|
||||
if let Some(leaf) = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
{
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.open_search(window, cx));
|
||||
}
|
||||
}
|
||||
FindNext => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.find_step(true, cx));
|
||||
}
|
||||
}
|
||||
FindPrevious => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.find_step(false, cx));
|
||||
}
|
||||
}
|
||||
ClearTerminal => {
|
||||
// Same focus story as FindInTerminal: act on the pane the closing
|
||||
// palette just handed focus back to.
|
||||
if let Some(leaf) = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
{
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.clear_scrollback(cx));
|
||||
}
|
||||
}
|
||||
CopyText => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
// `false`: a menu/palette copy leaves the highlight up. Only
|
||||
// the dual-purpose ⌃C chord has to consume the selection.
|
||||
leaf.update(cx, |view, cx| {
|
||||
view.copy_contextual(false, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
CutText => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| {
|
||||
view.cut_contextual(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
PasteText => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.paste_from_clipboard(cx));
|
||||
}
|
||||
}
|
||||
SelectAllText => {
|
||||
if let Some(leaf) = self.focused_leaf(window, cx) {
|
||||
leaf.update(cx, |view, cx| view.select_all_contextual(cx));
|
||||
}
|
||||
}
|
||||
ReopenClosedTab => self.reopen_closed_tab(window, cx),
|
||||
RenameTab => self.start_rename(self.active, window, cx),
|
||||
NewWorktreeTab => self.new_worktree_tab(self.active, window, cx),
|
||||
CloseOtherTabs => self.close_other_tabs(self.active, window, cx),
|
||||
CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx),
|
||||
CopyWorkingDirectory => self.copy_active_cwd(window, cx),
|
||||
MarkTabUnread => self.mark_tab_unread(self.active, cx),
|
||||
RenameWorkspace => self.start_workspace_rename(window, cx),
|
||||
OpenSettings => self.toggle_settings(window, cx),
|
||||
ShowKeyboardShortcuts => {
|
||||
self.open_settings_section(SettingsSection::Keybindings, window, cx)
|
||||
}
|
||||
About => self.open_settings_section(SettingsSection::About, window, cx),
|
||||
CheckForUpdates => self.check_for_updates_now(window, cx),
|
||||
OpenDocumentation => cx.open_url(DOCS_URL),
|
||||
OpenDiscord => cx.open_url(DISCORD_URL),
|
||||
ReportIssue => cx.open_url(ISSUES_URL),
|
||||
Quit => cx.quit(),
|
||||
RestartDaemon => self.restart_daemon(window, cx),
|
||||
ToggleSftp => self.toggle_sftp(window, cx),
|
||||
ShowSshForwards => self.show_ssh_forwards(window, cx),
|
||||
@@ -5374,6 +5478,51 @@ impl Render for Tty7App {
|
||||
.on_action(cx.listener(|this, _: &RestartSshSession, window, cx| {
|
||||
this.restart_ssh_session(window, cx)
|
||||
}))
|
||||
// Tab operations that used to be reachable only by right-clicking a
|
||||
// chip. Each targets the active tab, so the menu bar / palette /
|
||||
// keyboard all mean "this tab" without a click to say which.
|
||||
.on_action(cx.listener(|this, _: &RenameTab, window, cx| {
|
||||
this.start_rename(this.active, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &NewWorktreeTab, window, cx| {
|
||||
this.new_worktree_tab(this.active, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CloseOtherTabs, window, cx| {
|
||||
this.close_other_tabs(this.active, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CloseTabsToTheRight, window, cx| {
|
||||
this.close_tabs_right_of(this.active, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CopyWorkingDirectory, window, cx| {
|
||||
this.copy_active_cwd(window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| {
|
||||
this.mark_tab_unread(this.active, cx)
|
||||
}))
|
||||
// Settings destinations that deserve their own way in: Help →
|
||||
// Keyboard Shortcuts and the App menu's About both used to require
|
||||
// opening Settings and then hunting for the section.
|
||||
.on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| {
|
||||
this.open_settings_section(SettingsSection::Keybindings, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &About, window, cx| {
|
||||
this.open_settings_section(SettingsSection::About, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CheckForUpdates, window, cx| {
|
||||
this.check_for_updates_now(window, cx)
|
||||
}))
|
||||
// Standard macOS App / Window menu items. gpui exposes the platform
|
||||
// calls but ships no actions for them.
|
||||
.on_action(cx.listener(|_, _: &HideApp, _window, cx| cx.hide()))
|
||||
.on_action(cx.listener(|_, _: &HideOthers, _window, cx| cx.hide_other_apps()))
|
||||
.on_action(cx.listener(|_, _: &ShowAll, _window, cx| cx.unhide_other_apps()))
|
||||
.on_action(cx.listener(|_, _: &MinimizeWindow, window, _cx| window.minimize_window()))
|
||||
.on_action(cx.listener(|_, _: &ZoomWindow, window, _cx| window.zoom_window()))
|
||||
// Help destinations. Opened in the default browser; a failure here is
|
||||
// not worth interrupting the user over, so it is logged, not toasted.
|
||||
.on_action(cx.listener(|_, _: &OpenDocumentation, _window, cx| cx.open_url(DOCS_URL)))
|
||||
.on_action(cx.listener(|_, _: &OpenDiscord, _window, cx| cx.open_url(DISCORD_URL)))
|
||||
.on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL)))
|
||||
// The theme's background image, composited over the background fill
|
||||
// at its own opacity and under all content. Absolute, so it doesn't
|
||||
// participate in the flex column; the wrapper clips the Cover
|
||||
|
||||
+3
-1
@@ -44,7 +44,9 @@ const HOME_SHORTCUTS: [(&str, &str); 6] = [
|
||||
("TogglePalette", "Command Palette"),
|
||||
("SplitRight", "Split Right"),
|
||||
("SplitDown", "Split Down"),
|
||||
("OpenSettings", "Settings"),
|
||||
// "Settings…" everywhere: the menu bar, the tray, the palette and this page
|
||||
// used to offer four different names for the same destination.
|
||||
("OpenSettings", "Settings…"),
|
||||
];
|
||||
|
||||
/// Longest label shown for a recently-closed tab before ellipsizing, matching
|
||||
|
||||
@@ -116,6 +116,16 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
("NewTab", "secondary-t"),
|
||||
("NewWorkspace", "secondary-shift-n"),
|
||||
("CloseActiveTab", "secondary-w"),
|
||||
// Tab operations promoted out of the tab context menu (see
|
||||
// `core::actions`). No default chords: the menu bar, the palette and the
|
||||
// right-click menu all reach them, and none is frequent enough to earn a
|
||||
// reflexive shortcut — but they're bindable here like anything else.
|
||||
("RenameTab", ""),
|
||||
("NewWorktreeTab", ""),
|
||||
("CloseOtherTabs", ""),
|
||||
("CloseTabsToTheRight", ""),
|
||||
("CopyWorkingDirectory", ""),
|
||||
("MarkTabUnread", ""),
|
||||
// No default chord on purpose: this is the one action that kills running
|
||||
// sessions, and it must not sit one slip away from ⌘W. Reachable from
|
||||
// the Shell menu and the palette; bindable in Settings for anyone who
|
||||
@@ -224,6 +234,53 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
// Like Terminal.app / iTerm2 / Ghostty ⌘K: wipe the screen + scrollback.
|
||||
("ClearScrollback", "secondary-k"),
|
||||
("OpenSettings", "secondary-,"),
|
||||
// Help → Keyboard Shortcuts, on the ⌘/ that editors and browsers use for
|
||||
// "show me the shortcuts". Off macOS `secondary-/` is Ctrl+/, which some
|
||||
// shells bind to undo, so leave it unbound there.
|
||||
(
|
||||
"ShowKeyboardShortcuts",
|
||||
if cfg!(target_os = "macos") {
|
||||
"secondary-/"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
),
|
||||
// Menu-bar-only entries: real actions so the palette and Settings can see
|
||||
// them, but nothing here wants a chord by default.
|
||||
("About", ""),
|
||||
("CheckForUpdates", ""),
|
||||
("OpenDocumentation", ""),
|
||||
("OpenDiscord", ""),
|
||||
("ReportIssue", ""),
|
||||
// macOS supplies these chords itself for a standard App/Window menu; we
|
||||
// list them so they show up in Settings → Keybindings rather than looking
|
||||
// like undocumented magic, but bind them only where they exist.
|
||||
(
|
||||
"HideApp",
|
||||
if cfg!(target_os = "macos") {
|
||||
"secondary-h"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
),
|
||||
(
|
||||
"HideOthers",
|
||||
if cfg!(target_os = "macos") {
|
||||
"secondary-alt-h"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
),
|
||||
("ShowAll", ""),
|
||||
(
|
||||
"MinimizeWindow",
|
||||
if cfg!(target_os = "macos") {
|
||||
"secondary-m"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
),
|
||||
("ZoomWindow", ""),
|
||||
// No default chord — reachable from the command palette ("SSH: Remote
|
||||
// Files") and bindable in Settings like any other action.
|
||||
("ToggleSftp", ""),
|
||||
@@ -484,6 +541,12 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"DeleteWorkspace" => KeyBinding::new(keystroke, DeleteWorkspace, None),
|
||||
"RenameWorkspace" => KeyBinding::new(keystroke, RenameWorkspace, None),
|
||||
"CloseActiveTab" => KeyBinding::new(keystroke, CloseActiveTab, None),
|
||||
"RenameTab" => KeyBinding::new(keystroke, RenameTab, None),
|
||||
"NewWorktreeTab" => KeyBinding::new(keystroke, NewWorktreeTab, None),
|
||||
"CloseOtherTabs" => KeyBinding::new(keystroke, CloseOtherTabs, None),
|
||||
"CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None),
|
||||
"CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None),
|
||||
"MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None),
|
||||
"SplitRight" => KeyBinding::new(keystroke, SplitRight, None),
|
||||
"SplitDown" => KeyBinding::new(keystroke, SplitDown, None),
|
||||
"FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None),
|
||||
@@ -546,6 +609,17 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||||
"FindPrevious" => KeyBinding::new(keystroke, FindPrevious, Some("Terminal")),
|
||||
"ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, Some("Terminal")),
|
||||
"OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None),
|
||||
"ShowKeyboardShortcuts" => KeyBinding::new(keystroke, ShowKeyboardShortcuts, None),
|
||||
"About" => KeyBinding::new(keystroke, About, None),
|
||||
"CheckForUpdates" => KeyBinding::new(keystroke, CheckForUpdates, None),
|
||||
"OpenDocumentation" => KeyBinding::new(keystroke, OpenDocumentation, None),
|
||||
"OpenDiscord" => KeyBinding::new(keystroke, OpenDiscord, None),
|
||||
"ReportIssue" => KeyBinding::new(keystroke, ReportIssue, None),
|
||||
"HideApp" => KeyBinding::new(keystroke, HideApp, None),
|
||||
"HideOthers" => KeyBinding::new(keystroke, HideOthers, None),
|
||||
"ShowAll" => KeyBinding::new(keystroke, ShowAll, None),
|
||||
"MinimizeWindow" => KeyBinding::new(keystroke, MinimizeWindow, None),
|
||||
"ZoomWindow" => KeyBinding::new(keystroke, ZoomWindow, None),
|
||||
"ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None),
|
||||
"ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None),
|
||||
"ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None),
|
||||
|
||||
+750
-118
File diff suppressed because it is too large
Load Diff
+59
-13
@@ -461,14 +461,28 @@ impl Tty7App {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A quiet "nothing to show" line, used wherever a tab has no data yet.
|
||||
fn panel_empty(&self, text: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
div()
|
||||
/// A quiet "nothing to show" line, used wherever a tab has no data yet,
|
||||
/// with an optional second line saying what would fill it.
|
||||
///
|
||||
/// The hint is the point. An empty state that only reports the absence
|
||||
/// ("No changes.") leaves the user to work out whether the panel is broken,
|
||||
/// still loading, or simply pointed at the wrong thing; one that names the
|
||||
/// condition turns a dead end into an instruction.
|
||||
fn panel_empty(&self, text: &str, hint: Option<&str>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
v_flex()
|
||||
.px(px(CONTENT_INSET))
|
||||
.py(px(4.))
|
||||
.gap(px(3.))
|
||||
.text_size(px(12.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.text_color(muted)
|
||||
.child(text.to_string())
|
||||
.children(hint.map(|h| {
|
||||
div()
|
||||
.text_size(px(11.))
|
||||
.text_color(muted.opacity(0.75))
|
||||
.child(h.to_string())
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -535,7 +549,14 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return self.panel_scroll(self.panel_empty("No active session.", cx), title);
|
||||
return self.panel_scroll(
|
||||
self.panel_empty(
|
||||
"No active session.",
|
||||
Some("Open a tab to see its shell, directory, and processes here."),
|
||||
cx,
|
||||
),
|
||||
title,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep the process/port query pointed at the pane on screen, and keep it
|
||||
@@ -916,7 +937,14 @@ impl Tty7App {
|
||||
.and_then(|t| t.detail_pane(window, cx))
|
||||
else {
|
||||
let title = self.panel_title("Outline", None, None, cx);
|
||||
return self.panel_scroll(self.panel_empty("No active session.", cx), title);
|
||||
return self.panel_scroll(
|
||||
self.panel_empty(
|
||||
"No active session.",
|
||||
Some("Open a tab to see its shell, directory, and processes here."),
|
||||
cx,
|
||||
),
|
||||
title,
|
||||
);
|
||||
};
|
||||
// Count first (a cheap getter) so the borrow ends before `panel_title`
|
||||
// needs `&mut cx`; the list re-borrows the marks below.
|
||||
@@ -927,7 +955,11 @@ impl Tty7App {
|
||||
// `sh`, a nested PTY that eats the marks).
|
||||
let title = self.panel_title("Outline", None, None, cx);
|
||||
return self.panel_scroll(
|
||||
self.panel_empty("No commands recorded for this pane.", cx),
|
||||
self.panel_empty(
|
||||
"No commands recorded for this pane.",
|
||||
Some("Run a command — shell integration marks each one so you can jump back to it."),
|
||||
cx,
|
||||
),
|
||||
title,
|
||||
);
|
||||
}
|
||||
@@ -1024,7 +1056,14 @@ impl Tty7App {
|
||||
|
||||
let Some(cwd) = cwd else {
|
||||
let title = self.panel_title("Changes", None, None, cx);
|
||||
return self.panel_scroll(self.panel_empty("No working directory.", cx), title);
|
||||
return self.panel_scroll(
|
||||
self.panel_empty(
|
||||
"No working directory.",
|
||||
Some("This pane has not reported one yet."),
|
||||
cx,
|
||||
),
|
||||
title,
|
||||
);
|
||||
};
|
||||
// Probe on first paint for this cwd, and whenever the pane moves to a
|
||||
// different repository. Refreshes ride the same git-status observer the
|
||||
@@ -1055,11 +1094,18 @@ impl Tty7App {
|
||||
let mono = cx.theme().mono_font_family.clone();
|
||||
|
||||
let inner = match &self.right_panel.diff {
|
||||
None => self.panel_empty("Loading…", cx),
|
||||
Some(None) => self.panel_empty("Not a git work tree.", cx),
|
||||
Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => {
|
||||
self.panel_empty("No changes.", cx)
|
||||
}
|
||||
None => self.panel_empty("Loading…", None, cx),
|
||||
Some(None) => self.panel_empty(
|
||||
"Not a git repository.",
|
||||
Some("cd into one and this tab lists its uncommitted changes."),
|
||||
cx,
|
||||
),
|
||||
Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => self
|
||||
.panel_empty(
|
||||
"No uncommitted changes.",
|
||||
Some("The working tree is clean."),
|
||||
cx,
|
||||
),
|
||||
Some(Some(snap)) => {
|
||||
let files: Vec<(String, u32, u32)> = snap
|
||||
.files
|
||||
|
||||
+508
-199
@@ -45,13 +45,26 @@ use crate::ui::presets;
|
||||
|
||||
/// Which section of the settings panel is currently selected in the sidebar.
|
||||
/// Sections are named for the *object* being configured (the appearance, the
|
||||
/// terminal, the shell, the window) — never for a property class like
|
||||
/// "Behavior", which reads fine but predicts nothing about what's inside.
|
||||
/// terminal, the window) — never for a property class like "Behavior", which
|
||||
/// reads fine but predicts nothing about what's inside.
|
||||
///
|
||||
/// Two of these were rearranged because the old split didn't survive contact
|
||||
/// with a user asking "which page is that on?":
|
||||
///
|
||||
/// * **Shell** used to be its own page holding three settings, and nothing
|
||||
/// distinguished "the Terminal page" from "the Shell page" from the outside.
|
||||
/// Its rows are now Terminal's first group — the program a pane launches is a
|
||||
/// property of the terminal, not a peer of it. (It also freed the word
|
||||
/// "Shell", which the menu bar was simultaneously using for its File menu.)
|
||||
/// * **Input** is new. Completion, history search, the Option/Meta split and
|
||||
/// selection/clipboard behaviour were scattered through the bottom of the
|
||||
/// Terminal page under four headers; they're the app's most distinctive
|
||||
/// surface and they now have a name you can look for.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SettingsSection {
|
||||
Appearance,
|
||||
Terminal,
|
||||
Shell,
|
||||
Input,
|
||||
Ssh,
|
||||
Agents,
|
||||
WindowTabs,
|
||||
@@ -60,13 +73,27 @@ pub(crate) enum SettingsSection {
|
||||
}
|
||||
|
||||
impl SettingsSection {
|
||||
/// Every section, in nav order. The single source of truth for "what
|
||||
/// sections exist" — [`best_matching_section`] used to carry its own
|
||||
/// hand-written copy of this list and had silently fallen two behind.
|
||||
pub(crate) const ALL: [SettingsSection; 8] = [
|
||||
SettingsSection::Appearance,
|
||||
SettingsSection::Terminal,
|
||||
SettingsSection::Input,
|
||||
SettingsSection::Ssh,
|
||||
SettingsSection::Agents,
|
||||
SettingsSection::WindowTabs,
|
||||
SettingsSection::Keybindings,
|
||||
SettingsSection::About,
|
||||
];
|
||||
|
||||
/// A `&'static` label for `TTY7_PROFILE` aggregation, so each section's build
|
||||
/// cost and rebuild rate report under their own line.
|
||||
fn profile_label(self) -> &'static str {
|
||||
match self {
|
||||
SettingsSection::Appearance => "settings:appearance",
|
||||
SettingsSection::Terminal => "settings:terminal",
|
||||
SettingsSection::Shell => "settings:shell",
|
||||
SettingsSection::Input => "settings:input",
|
||||
SettingsSection::Ssh => "settings:ssh",
|
||||
SettingsSection::Agents => "settings:agents",
|
||||
SettingsSection::WindowTabs => "settings:window-tabs",
|
||||
@@ -93,7 +120,7 @@ struct SearchEntry {
|
||||
fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
use SettingsSection::*;
|
||||
&[
|
||||
// Appearance
|
||||
// ── Appearance ──────────────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Theme",
|
||||
@@ -101,8 +128,23 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Font family",
|
||||
keywords: "typeface monospace typography",
|
||||
title: "Sync with system",
|
||||
keywords: "theme dark light auto follow os appearance mode",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Custom themes",
|
||||
keywords: "theme duplicate edit colors folder yaml import",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Opacity",
|
||||
keywords: "transparency translucent see through window alpha",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Blur",
|
||||
keywords: "transparency translucent frosted vibrancy window background",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
@@ -114,6 +156,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Line height",
|
||||
keywords: "typography leading spacing",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Font family",
|
||||
keywords: "typeface monospace typography",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Bold font",
|
||||
@@ -144,11 +191,21 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "ANSI colors",
|
||||
keywords: "palette 16 terminal colours theme",
|
||||
},
|
||||
// Terminal
|
||||
// ── Terminal ────────────────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Option acts as Meta",
|
||||
keywords: "alt keyboard modifier escape macos",
|
||||
title: "Program",
|
||||
keywords: "shell binary zsh bash fish pwsh powershell executable launch",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Arguments",
|
||||
keywords: "shell flags login args",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Start in",
|
||||
keywords: "cwd working directory start folder path home inherit custom",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
@@ -170,6 +227,16 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Hide mouse while typing",
|
||||
keywords: "cursor pointer autohide",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Report mouse to apps",
|
||||
keywords: "mouse reporting vim tmux click scroll shift passthrough",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Terminal bell",
|
||||
keywords: "bell audible visual flash sound silence beep ^g",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Detect URLs",
|
||||
@@ -178,45 +245,50 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Forward SSH loopback links",
|
||||
keywords: "ssh remote port tunnel localhost forward",
|
||||
keywords: "ssh remote port tunnel localhost forward links",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Open files with",
|
||||
keywords: "links file editor command external app path line column",
|
||||
},
|
||||
// ── Input ───────────────────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: Input,
|
||||
title: "Tab completion",
|
||||
keywords: "complete completion menu suggestions tab prompt",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Input,
|
||||
title: "History search",
|
||||
keywords: "ctrl-r reverse search fuzzy history recall fzf prompt",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Input,
|
||||
title: "Option acts as Meta",
|
||||
keywords: "alt keyboard modifier escape macos option meta",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Input,
|
||||
title: "Smart selection",
|
||||
keywords: "double click word url path select semantic",
|
||||
keywords: "double click word url path select semantic bracket email",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
section: Input,
|
||||
title: "Copy on select",
|
||||
keywords: "clipboard selection yank",
|
||||
keywords: "clipboard selection yank mouse",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
section: Input,
|
||||
title: "Trim trailing spaces on copy",
|
||||
keywords: "clipboard whitespace",
|
||||
keywords: "clipboard whitespace copy",
|
||||
},
|
||||
// ── SSH ─────────────────────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Notify on command finish",
|
||||
keywords: "notification alert bell done osc",
|
||||
section: Ssh,
|
||||
title: "SSH profiles",
|
||||
keywords: "ssh host connection saved profile import ssh_config manage add edit",
|
||||
},
|
||||
// Shell
|
||||
SearchEntry {
|
||||
section: Shell,
|
||||
title: "Program",
|
||||
keywords: "shell binary zsh bash fish executable",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Shell,
|
||||
title: "Arguments",
|
||||
keywords: "shell flags login args",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Shell,
|
||||
title: "Working directory",
|
||||
keywords: "cwd start folder path directory",
|
||||
},
|
||||
// SSH
|
||||
SearchEntry {
|
||||
section: Ssh,
|
||||
title: "Verify host keys",
|
||||
@@ -227,7 +299,12 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Warn before closing",
|
||||
keywords: "ssh confirm close tab pane live session security",
|
||||
},
|
||||
// Agents
|
||||
SearchEntry {
|
||||
section: Ssh,
|
||||
title: "Port forwarding",
|
||||
keywords: "ssh tunnel local remote dynamic socks forward rule",
|
||||
},
|
||||
// ── Agents ──────────────────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: Agents,
|
||||
title: "Claude Code hooks",
|
||||
@@ -253,17 +330,22 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Pi extension",
|
||||
keywords: "agent integration install pi",
|
||||
},
|
||||
// Window & Tabs
|
||||
// ── Window & Tabs ───────────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Startup window",
|
||||
keywords: "restore session launch open",
|
||||
keywords: "launch open maximized fullscreen normal",
|
||||
},
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Remember window size & position",
|
||||
keywords: "window size position bounds geometry launch startup remember",
|
||||
},
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Restore last layout",
|
||||
keywords: "restore session previous tabs splits reopen launch startup layout",
|
||||
},
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Show tray icon",
|
||||
@@ -272,24 +354,43 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "New tab position",
|
||||
keywords: "tabs order end after",
|
||||
keywords: "tabs order end after current",
|
||||
},
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Tab bar position",
|
||||
keywords: "tabs vertical sidebar left top layout",
|
||||
keywords: "tabs vertical sidebar left top layout rail",
|
||||
},
|
||||
// Keybindings
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Sidebar grouping",
|
||||
keywords: "tabs group repo repository git scratch header sidebar flat",
|
||||
},
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Notify on command finish",
|
||||
keywords: "notification alert done osc desktop banner long command",
|
||||
},
|
||||
SearchEntry {
|
||||
section: WindowTabs,
|
||||
title: "Notify threshold",
|
||||
keywords: "notification alert seconds duration long command delay",
|
||||
},
|
||||
// ── Keybindings / About ─────────────────────────────────────────────
|
||||
SearchEntry {
|
||||
section: Keybindings,
|
||||
title: "Keybindings",
|
||||
keywords: "shortcut hotkey keyboard binding chord tmux preset rebind",
|
||||
keywords: "shortcut hotkey keyboard binding chord tmux preset rebind prefix",
|
||||
},
|
||||
// About
|
||||
SearchEntry {
|
||||
section: About,
|
||||
title: "About",
|
||||
keywords: "version license credits build",
|
||||
keywords: "version license credits build update check github",
|
||||
},
|
||||
SearchEntry {
|
||||
section: About,
|
||||
title: "How sessions work",
|
||||
keywords: "session daemon detach persist background close quit stop delete workspace layout survive reboot tmux",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -312,9 +413,12 @@ pub(crate) fn section_match_count(section: SettingsSection, query: &str) -> usiz
|
||||
/// The section a search should jump to: the one with the most matches, ties
|
||||
/// broken by nav order (the first section wins). `None` when nothing matches, so
|
||||
/// the caller leaves the current selection alone.
|
||||
///
|
||||
/// Driven by [`SettingsSection::ALL`] rather than a hand-written list: the old
|
||||
/// literal here omitted SSH and Agents, so searching "claude" or "known hosts"
|
||||
/// annotated the nav with a match count and then refused to go there.
|
||||
pub(crate) fn best_matching_section(query: &str) -> Option<SettingsSection> {
|
||||
use SettingsSection::*;
|
||||
[Appearance, Terminal, Shell, WindowTabs, Keybindings, About]
|
||||
SettingsSection::ALL
|
||||
.into_iter()
|
||||
.map(|s| (s, section_match_count(s, query)))
|
||||
.filter(|(_, n)| *n > 0)
|
||||
@@ -712,7 +816,7 @@ impl Tty7App {
|
||||
}
|
||||
};
|
||||
|
||||
// The six section links stay put during search — only their `(N)` suffixes
|
||||
// The section links stay put during search — only their `(N)` suffixes
|
||||
// change — so the nav never collapses out from under the user.
|
||||
let nav_body = SidebarMenu::new()
|
||||
.child(nav_item(
|
||||
@@ -720,18 +824,18 @@ impl Tty7App {
|
||||
SettingsSection::Appearance,
|
||||
Icon::new(IconName::Palette),
|
||||
))
|
||||
// Sliders for Terminal (it's the tuning page), the `>_`
|
||||
// prompt glyph for Shell (it configures the prompt's
|
||||
// program) — the two would otherwise both claim `>_`.
|
||||
// The `>_` prompt glyph for Terminal, which now owns the shell
|
||||
// program; the "Aa" glyph is the closest thing the icon set has to
|
||||
// a keyboard for Input.
|
||||
.child(nav_item(
|
||||
"Terminal",
|
||||
SettingsSection::Terminal,
|
||||
Icon::new(IconName::Settings2),
|
||||
Icon::new(IconName::SquareTerminal),
|
||||
))
|
||||
.child(nav_item(
|
||||
"Shell",
|
||||
SettingsSection::Shell,
|
||||
Icon::new(IconName::SquareTerminal),
|
||||
"Input",
|
||||
SettingsSection::Input,
|
||||
Icon::new(IconName::Settings2),
|
||||
))
|
||||
.child(nav_item(
|
||||
"SSH",
|
||||
@@ -821,7 +925,7 @@ impl Tty7App {
|
||||
let content = match section {
|
||||
SettingsSection::Appearance => self.render_settings_appearance(cx),
|
||||
SettingsSection::Terminal => self.render_settings_terminal(cx),
|
||||
SettingsSection::Shell => self.render_settings_shell(cx),
|
||||
SettingsSection::Input => self.render_settings_input(cx),
|
||||
SettingsSection::Ssh => self.render_settings_ssh(cx),
|
||||
SettingsSection::Agents => self.render_settings_agents(cx),
|
||||
SettingsSection::WindowTabs => self.render_settings_window_tabs(cx),
|
||||
@@ -1359,7 +1463,10 @@ impl Tty7App {
|
||||
.into_any_element();
|
||||
|
||||
v_flex()
|
||||
.child(self.section_header("Window", cx))
|
||||
// Not "Window": Settings → Window & Tabs owns that word for the
|
||||
// window's lifecycle, and two groups called Window on two pages is
|
||||
// how a user ends up on the wrong one.
|
||||
.child(self.section_header("Transparency", cx))
|
||||
.child(self.settings_row(
|
||||
"Opacity",
|
||||
"How opaque the window background is, for every theme. Below \
|
||||
@@ -2713,12 +2820,18 @@ impl Tty7App {
|
||||
section.into_any_element()
|
||||
}
|
||||
|
||||
/// Shell section: the program tty7 launches in each new terminal, plus its
|
||||
/// launch arguments. Both apply to *newly spawned* panes/tabs — existing
|
||||
/// shells keep running until closed. An empty program falls back to the
|
||||
/// platform default (the login shell on Unix; PowerShell 7 when installed,
|
||||
/// else Windows PowerShell, on Windows).
|
||||
fn render_settings_shell(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
/// The Shell group at the top of the Terminal section: the program tty7
|
||||
/// launches in each new pane, its launch arguments, and where a fresh shell
|
||||
/// starts. All apply to *newly spawned* panes/tabs — existing shells keep
|
||||
/// running until closed. An empty program falls back to the platform default
|
||||
/// (the login shell on Unix; PowerShell 7 when installed, else Windows
|
||||
/// PowerShell, on Windows).
|
||||
///
|
||||
/// This used to be a section of its own, which left a three-row page and no
|
||||
/// way for a user to guess whether a given knob was filed under "Terminal"
|
||||
/// or under "Shell". The program a pane runs is a property of the terminal,
|
||||
/// so it opens the Terminal page instead.
|
||||
fn render_shell_group(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let muted_fg = cx.theme().muted_foreground;
|
||||
let (program_input, args_input, wd_path_input) = match self.active_settings() {
|
||||
Some(s) => (
|
||||
@@ -2797,8 +2910,6 @@ impl Tty7App {
|
||||
args_control,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Working directory", cx))
|
||||
.child(self.settings_row(
|
||||
"Start in",
|
||||
"What a fresh shell starts in: tty7's launch directory, your home folder, or a fixed path.",
|
||||
@@ -2823,11 +2934,16 @@ impl Tty7App {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Terminal section: how the terminal surface itself behaves — scrolling,
|
||||
/// mouse, links, clipboard, notifications. Plain switches and segmented
|
||||
/// controls driven straight off the `Config` global (each control's handler
|
||||
/// mutates + saves it). Small groups on purpose: each header names exactly
|
||||
/// what it contains, so it doubles as the landmark you scan for.
|
||||
/// Terminal section: what a pane runs and how the terminal surface itself
|
||||
/// behaves — the shell, scrolling, the mouse, the bell, links. Plain
|
||||
/// switches and segmented controls driven straight off the `Config` global
|
||||
/// (each control's handler mutates + saves it). Small groups on purpose:
|
||||
/// each header names exactly what it contains, so it doubles as the landmark
|
||||
/// you scan for.
|
||||
///
|
||||
/// Typing, selection and the clipboard used to live down here too, under
|
||||
/// four more headers; they moved to their own Input section, which is both
|
||||
/// findable by name and short enough to read in one screen.
|
||||
fn render_settings_terminal(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let foreground = cx.theme().foreground;
|
||||
let cfg = cx.global::<Config>();
|
||||
@@ -2835,23 +2951,9 @@ impl Tty7App {
|
||||
let ssh_loopback_forward = cfg.ssh_loopback_forward;
|
||||
let mouse_hide = cfg.mouse_hide_while_typing;
|
||||
let focus_follows = cfg.focus_follows_mouse;
|
||||
let option_as_alt = cfg.macos_option_as_alt;
|
||||
let scroll_mult = cfg.mouse_scroll_multiplier;
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
let copy_on_select = cfg.copy_on_select;
|
||||
let mouse_reporting = cfg.mouse_reporting;
|
||||
let smart_select = cfg.smart_select;
|
||||
let tab_completion = cfg.tab_completion;
|
||||
let history_search = cfg.history_search;
|
||||
let bell = cfg.bell;
|
||||
// Map the persisted threshold onto its preset radio index (nearest slot
|
||||
// for any off-preset value a hand-edit might leave).
|
||||
let threshold_idx = match cfg.notify_threshold_secs {
|
||||
n if n <= 5 => 0,
|
||||
n if n <= 10 => 1,
|
||||
n if n <= 30 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
// Map the persisted scrollback depth onto its preset radio index (default
|
||||
// to 10k's slot for any off-preset value a hand-edit might leave).
|
||||
let scrollback_idx = match cfg.scrollback_limit {
|
||||
@@ -2859,11 +2961,6 @@ impl Tty7App {
|
||||
n if n <= 10_000 => 1,
|
||||
_ => 2,
|
||||
};
|
||||
let notify_idx = match cfg.notify_on_command_finish {
|
||||
NotifyMode::Never => 0,
|
||||
NotifyMode::Unfocused => 1,
|
||||
NotifyMode::Always => 2,
|
||||
};
|
||||
let scroll_slider = match self.active_settings() {
|
||||
Some(s) => s.scroll_slider.clone(),
|
||||
None => return div().into_any_element(),
|
||||
@@ -2899,20 +2996,6 @@ impl Tty7App {
|
||||
this.set_scrollback_limit(lines, cx);
|
||||
},
|
||||
);
|
||||
let notify_radio = self.segmented(
|
||||
"term-notify",
|
||||
&["Never", "When unfocused", "Always"],
|
||||
notify_idx,
|
||||
cx,
|
||||
|this, ix, _w, cx| {
|
||||
let mode = match ix {
|
||||
0 => NotifyMode::Never,
|
||||
1 => NotifyMode::Unfocused,
|
||||
_ => NotifyMode::Always,
|
||||
};
|
||||
this.set_notify_mode(mode, cx);
|
||||
},
|
||||
);
|
||||
|
||||
let focus_switch = Switch::new("term-focus-follows")
|
||||
.checked(focus_follows)
|
||||
@@ -2924,30 +3007,10 @@ impl Tty7App {
|
||||
cx.listener(|this, on: &bool, _w, cx| this.set_mouse_hide_while_typing(*on, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
let trim_switch = Switch::new("term-clip-trim")
|
||||
.checked(clip_trim)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx)))
|
||||
.into_any_element();
|
||||
let copy_on_select_switch = Switch::new("term-copy-on-select")
|
||||
.checked(copy_on_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let mouse_report_switch = Switch::new("term-mouse-report")
|
||||
.checked(mouse_reporting)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
|
||||
.into_any_element();
|
||||
let smart_select_switch = Switch::new("term-smart-select")
|
||||
.checked(smart_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let tab_completion_switch = Switch::new("term-tab-completion")
|
||||
.checked(tab_completion)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx)))
|
||||
.into_any_element();
|
||||
let history_search_switch = Switch::new("term-history-search")
|
||||
.checked(history_search)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx)))
|
||||
.into_any_element();
|
||||
let bell_idx = match bell {
|
||||
BellMode::None => 0,
|
||||
BellMode::Visual => 1,
|
||||
@@ -2967,38 +3030,6 @@ impl Tty7App {
|
||||
this.set_bell_mode(mode, cx);
|
||||
},
|
||||
);
|
||||
let threshold_radio = self.segmented(
|
||||
"term-notify-threshold",
|
||||
&["5s", "10s", "30s", "1m"],
|
||||
threshold_idx,
|
||||
cx,
|
||||
|this, ix, _w, cx| {
|
||||
let secs = match ix {
|
||||
0 => 5,
|
||||
1 => 10,
|
||||
2 => 30,
|
||||
_ => 60,
|
||||
};
|
||||
this.set_notify_threshold(secs, cx);
|
||||
},
|
||||
);
|
||||
// macOS only: the Option/special-character split this toggle resolves
|
||||
// doesn't exist on other platforms, where Alt always carries Meta.
|
||||
let option_alt_row = cfg!(target_os = "macos").then(|| {
|
||||
let switch = Switch::new("term-option-as-alt")
|
||||
.checked(option_as_alt)
|
||||
.on_click(
|
||||
cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
self.settings_row(
|
||||
"Option (⌥) acts as Meta",
|
||||
"⌥+key sends the escape chord shells expect (⌥B = back one word) \
|
||||
instead of typing a special character (∫).",
|
||||
switch,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
// Slider + a live readout of the current multiplier beside it.
|
||||
let scroll_control = h_flex()
|
||||
.items_center()
|
||||
@@ -3015,6 +3046,8 @@ impl Tty7App {
|
||||
.into_any_element();
|
||||
|
||||
v_flex()
|
||||
.child(self.render_shell_group(cx))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Scrolling", cx))
|
||||
.child(self.settings_row(
|
||||
"Scrollback",
|
||||
@@ -3048,30 +3081,14 @@ impl Tty7App {
|
||||
mouse_report_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Smart selection",
|
||||
"Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
|
||||
smart_select_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Keyboard", cx))
|
||||
.child(self.section_header("Bell", cx))
|
||||
.child(self.settings_row(
|
||||
"Tab completion",
|
||||
"Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \
|
||||
shell's own completion instead.",
|
||||
tab_completion_switch,
|
||||
"Terminal bell",
|
||||
"How a bell (^G) is signalled: silenced, a brief flash, or the system sound.",
|
||||
bell_control,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"History search",
|
||||
"⌃R at the prompt opens tty7's fuzzy history menu. When off, ⌃R goes to the \
|
||||
shell instead — its own reverse-i-search, or whatever you've bound there \
|
||||
(fzf, percol).",
|
||||
history_search_switch,
|
||||
cx,
|
||||
))
|
||||
.when_some(option_alt_row, |v, row| v.child(row))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Links", cx))
|
||||
.child(self.settings_row(
|
||||
@@ -3094,8 +3111,94 @@ impl Tty7App {
|
||||
link_file_command_control,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Input section: everything about putting text *in* and taking text *out* —
|
||||
/// the completion and history menus at the prompt, the Option/Meta split,
|
||||
/// and how selection reaches the clipboard.
|
||||
///
|
||||
/// A section of its own because these are the settings that distinguish tty7
|
||||
/// from a plain terminal, and they were previously the last four groups of a
|
||||
/// seven-group Terminal page — findable only by scrolling past everything
|
||||
/// else, and not findable by search at all (completion and history search
|
||||
/// had no index entries).
|
||||
fn render_settings_input(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let cfg = cx.global::<Config>();
|
||||
let option_as_alt = cfg.macos_option_as_alt;
|
||||
let tab_completion = cfg.tab_completion;
|
||||
let history_search = cfg.history_search;
|
||||
let smart_select = cfg.smart_select;
|
||||
let copy_on_select = cfg.copy_on_select;
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
|
||||
let tab_completion_switch = Switch::new("term-tab-completion")
|
||||
.checked(tab_completion)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx)))
|
||||
.into_any_element();
|
||||
let history_search_switch = Switch::new("term-history-search")
|
||||
.checked(history_search)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx)))
|
||||
.into_any_element();
|
||||
let smart_select_switch = Switch::new("term-smart-select")
|
||||
.checked(smart_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let copy_on_select_switch = Switch::new("term-copy-on-select")
|
||||
.checked(copy_on_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let trim_switch = Switch::new("term-clip-trim")
|
||||
.checked(clip_trim)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx)))
|
||||
.into_any_element();
|
||||
// macOS only: the Option/special-character split this toggle resolves
|
||||
// doesn't exist on other platforms, where Alt always carries Meta.
|
||||
let option_alt_row = cfg!(target_os = "macos").then(|| {
|
||||
let switch = Switch::new("term-option-as-alt")
|
||||
.checked(option_as_alt)
|
||||
.on_click(
|
||||
cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
self.settings_row(
|
||||
"Option (⌥) acts as Meta",
|
||||
"⌥+key sends the escape chord shells expect (⌥B = back one word) \
|
||||
instead of typing a special character (∫).",
|
||||
switch,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.child(self.section_intro(
|
||||
"Prompt",
|
||||
"tty7's own menus at the shell prompt. Turn one off to hand the key back to the shell.",
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Tab completion",
|
||||
"Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \
|
||||
shell's own completion instead.",
|
||||
tab_completion_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"History search",
|
||||
"⌃R at the prompt opens tty7's fuzzy history menu. When off, ⌃R goes to the \
|
||||
shell instead — its own reverse-i-search, or whatever you've bound there \
|
||||
(fzf, percol).",
|
||||
history_search_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Clipboard", cx))
|
||||
.child(self.section_header("Selection & clipboard", cx))
|
||||
.child(self.settings_row(
|
||||
"Smart selection",
|
||||
"Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
|
||||
smart_select_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Copy on select",
|
||||
"Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed.",
|
||||
@@ -3108,28 +3211,11 @@ impl Tty7App {
|
||||
trim_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Bell", cx))
|
||||
.child(self.settings_row(
|
||||
"Terminal bell",
|
||||
"How a bell (^G) is signalled: silenced, a brief flash, or the system sound.",
|
||||
bell_control,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Notifications", cx))
|
||||
.child(self.settings_row(
|
||||
"Notify on command finish",
|
||||
"Desktop alert after a long foreground command completes.",
|
||||
notify_radio,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Notify threshold",
|
||||
"How long a command must run to qualify as \"long\".",
|
||||
threshold_radio,
|
||||
cx,
|
||||
))
|
||||
.when_some(option_alt_row, |v, row| {
|
||||
v.child(self.section_rule(cx))
|
||||
.child(self.section_header("Keyboard", cx))
|
||||
.child(row)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -3253,6 +3339,53 @@ impl Tty7App {
|
||||
crate::core::config::SidebarGrouping::Repo => 0,
|
||||
crate::core::config::SidebarGrouping::None => 1,
|
||||
};
|
||||
// Notifications are app-level, not terminal-level: the tray menu already
|
||||
// exposed the same `NotifyMode` at the top of its own menu while the
|
||||
// setting itself sat at the bottom of the Terminal page.
|
||||
let notify_idx = match cfg.notify_on_command_finish {
|
||||
NotifyMode::Never => 0,
|
||||
NotifyMode::Unfocused => 1,
|
||||
NotifyMode::Always => 2,
|
||||
};
|
||||
// Map the persisted threshold onto its preset radio index (nearest slot
|
||||
// for any off-preset value a hand-edit might leave).
|
||||
let threshold_idx = match cfg.notify_threshold_secs {
|
||||
n if n <= 5 => 0,
|
||||
n if n <= 10 => 1,
|
||||
n if n <= 30 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
let notify_radio = self.segmented(
|
||||
"wt-notify",
|
||||
// Same order and casing as the tray's Notifications submenu, which
|
||||
// writes this very setting — the two used to disagree on both.
|
||||
&["Never", "When Unfocused", "Always"],
|
||||
notify_idx,
|
||||
cx,
|
||||
|this, ix, _w, cx| {
|
||||
let mode = match ix {
|
||||
0 => NotifyMode::Never,
|
||||
1 => NotifyMode::Unfocused,
|
||||
_ => NotifyMode::Always,
|
||||
};
|
||||
this.set_notify_mode(mode, cx);
|
||||
},
|
||||
);
|
||||
let threshold_radio = self.segmented(
|
||||
"wt-notify-threshold",
|
||||
&["5s", "10s", "30s", "1m"],
|
||||
threshold_idx,
|
||||
cx,
|
||||
|this, ix, _w, cx| {
|
||||
let secs = match ix {
|
||||
0 => 5,
|
||||
1 => 10,
|
||||
2 => 30,
|
||||
_ => 60,
|
||||
};
|
||||
this.set_notify_threshold(secs, cx);
|
||||
},
|
||||
);
|
||||
|
||||
let restore_switch = Switch::new("wt-restore-session")
|
||||
.checked(restore_session)
|
||||
@@ -3337,8 +3470,12 @@ impl Tty7App {
|
||||
remember_window_switch,
|
||||
cx,
|
||||
))
|
||||
// "Session" already means "a shell running in the background" all
|
||||
// over this app; using it here for "the saved arrangement of tabs"
|
||||
// made the one word mean two things on the same page. The thing
|
||||
// being restored is the layout.
|
||||
.child(self.settings_row(
|
||||
"Restore previous session",
|
||||
"Restore last layout",
|
||||
"Reopen the last window's tabs, splits, and directories on launch. Off starts with a single fresh terminal.",
|
||||
restore_switch,
|
||||
cx,
|
||||
@@ -3371,6 +3508,20 @@ impl Tty7App {
|
||||
sidebar_grouping_radio,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Notifications", cx))
|
||||
.child(self.settings_row(
|
||||
"Notify on command finish",
|
||||
"Desktop alert after a long foreground command completes.",
|
||||
notify_radio,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Notify threshold",
|
||||
"How long a command must run to qualify as \"long\".",
|
||||
threshold_radio,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -4000,6 +4151,75 @@ impl Tty7App {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// "How sessions work": the four-line explanation of the app's own model —
|
||||
/// what closing a window does, what Stop does, what Delete does, what Quit
|
||||
/// does.
|
||||
///
|
||||
/// This is tty7's central idea and the thing that most surprises a user
|
||||
/// arriving from another terminal, and until now it was explained *only*
|
||||
/// inside the confirmation dialogs — that is, at the moment the user is
|
||||
/// already committing to an action, and never before. Stating it once, in
|
||||
/// the one page that describes what the app is, means the dialogs confirm a
|
||||
/// model the user has already met instead of teaching it under pressure.
|
||||
///
|
||||
/// Deliberately a plain definition list rather than settings rows: nothing
|
||||
/// here is configurable, and giving it switch-shaped chrome would suggest
|
||||
/// otherwise.
|
||||
fn render_session_model(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground);
|
||||
|
||||
let entry = |term: &'static str, meaning: &'static str| {
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(foreground)
|
||||
.child(term),
|
||||
)
|
||||
.child(div().text_xs().text_color(muted_fg).child(meaning))
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.mt_6()
|
||||
.gap_2()
|
||||
.child(self.section_rule(cx))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(foreground)
|
||||
.child("How sessions work"),
|
||||
)
|
||||
.child(div().text_xs().text_color(muted_fg).child(
|
||||
"Your shells run in a background daemon, not in this window. That is what lets them outlive a quit or a reboot — and it means \"close\" and \"end\" are different things here.",
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_3()
|
||||
.child(entry(
|
||||
"Closing a window (⌘W on the last tab)",
|
||||
"Detaches the workspace. Every shell keeps running; the workspace waits on the home page and in the title-bar menu.",
|
||||
))
|
||||
.child(entry(
|
||||
"Quitting tty7 (⌘Q)",
|
||||
"Same deal, for every window. Nothing running is interrupted.",
|
||||
))
|
||||
.child(entry(
|
||||
"Stop Workspace",
|
||||
"Ends that workspace's shells but keeps its layout, so you can start it again with fresh ones.",
|
||||
))
|
||||
.child(entry(
|
||||
"Delete Workspace",
|
||||
"Ends the shells and forgets the layout. The only step here you can't undo.",
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// About section: app identity and stack.
|
||||
fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
@@ -4068,6 +4288,7 @@ impl Tty7App {
|
||||
.child("Pure Rust · GPU rendering on Zed's gpui · VT core from Alacritty"),
|
||||
),
|
||||
)
|
||||
.child(self.render_session_model(cx))
|
||||
// Updates: the startup check drops a newer version here if it found
|
||||
// one. We never self-update — "Download" just opens the Releases
|
||||
// page; the toggle turns the check off (see `core::update`).
|
||||
@@ -4165,6 +4386,94 @@ impl Tty7App {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every section must carry at least one index entry, or the search box can
|
||||
/// annotate the nav with a count it can never jump to — and, worse, a whole
|
||||
/// page of settings becomes unreachable by search.
|
||||
#[test]
|
||||
fn every_section_has_search_entries() {
|
||||
for section in SettingsSection::ALL {
|
||||
let n = settings_search_entries()
|
||||
.iter()
|
||||
.filter(|e| e.section == section)
|
||||
.count();
|
||||
assert!(
|
||||
n > 0,
|
||||
"section {:?} has no search entries",
|
||||
section.profile_label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `best_matching_section` must be able to reach every section — it used to
|
||||
/// be driven by a hand-written list that had fallen behind by two.
|
||||
#[test]
|
||||
fn best_matching_section_can_reach_every_section() {
|
||||
for section in SettingsSection::ALL {
|
||||
let entry = settings_search_entries()
|
||||
.iter()
|
||||
.find(|e| e.section == section)
|
||||
.expect("checked by every_section_has_search_entries");
|
||||
let query = entry.title.to_lowercase();
|
||||
let landed = best_matching_section(&query);
|
||||
assert!(
|
||||
landed.is_some(),
|
||||
"query {query:?} matched nothing at all (section {:?})",
|
||||
section.profile_label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that had no index entry at all before this pass — searching for
|
||||
/// any of them returned an empty result on a page that plainly had the knob.
|
||||
#[test]
|
||||
fn previously_unsearchable_settings_are_findable() {
|
||||
use SettingsSection::*;
|
||||
let cases: &[(&str, SettingsSection)] = &[
|
||||
("opacity", Appearance),
|
||||
("blur", Appearance),
|
||||
("completion", Input),
|
||||
("ctrl-r", Input),
|
||||
("grouping", WindowTabs),
|
||||
("threshold", WindowTabs),
|
||||
("report mouse", Terminal),
|
||||
("open files with", Terminal),
|
||||
("bell", Terminal),
|
||||
("known_hosts", Ssh),
|
||||
("claude", Agents),
|
||||
];
|
||||
for (query, expected) in cases {
|
||||
assert_eq!(
|
||||
best_matching_section(query).map(|s| s.profile_label()),
|
||||
Some(expected.profile_label()),
|
||||
"query {query:?} should land on {:?}",
|
||||
expected.profile_label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The index names rows, so a title that no longer matches the rendered row
|
||||
/// sends the user to the right page and then leaves them hunting. This
|
||||
/// pins the ones that had drifted (the index said "Working directory"; the
|
||||
/// row says "Start in").
|
||||
#[test]
|
||||
fn index_titles_match_rendered_row_labels() {
|
||||
for title in [
|
||||
"Start in",
|
||||
"Restore last layout",
|
||||
"Terminal bell",
|
||||
"Report mouse to apps",
|
||||
"Open files with",
|
||||
"Sidebar grouping",
|
||||
"Tab completion",
|
||||
"History search",
|
||||
] {
|
||||
assert!(
|
||||
settings_search_entries().iter().any(|e| e.title == title),
|
||||
"no index entry titled {title:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_action_splits_on_capitals() {
|
||||
assert_eq!(humanize_action("NewTab"), "New Tab");
|
||||
|
||||
+5
-2
@@ -581,9 +581,12 @@ impl Tty7App {
|
||||
.menu("Stop Workspace…", Box::new(StopWorkspace))
|
||||
// The app-level entries the "⋯" used to carry.
|
||||
// Folded in here so the corner has one menu rather
|
||||
// than two adjacent ones.
|
||||
// than two adjacent ones. Deliberately just these
|
||||
// two: Help/About live in the menu bar, and
|
||||
// duplicating them here only makes this menu
|
||||
// longer without making anything reachable.
|
||||
.separator()
|
||||
.menu("Command Palette", Box::new(TogglePalette))
|
||||
.menu("Command Palette…", Box::new(TogglePalette))
|
||||
.menu("Settings…", Box::new(OpenSettings))
|
||||
},
|
||||
),
|
||||
|
||||
+125
-39
@@ -4,13 +4,17 @@
|
||||
//! `ui::presets`) and publishes the terminal-facing palette.
|
||||
|
||||
use gpui::{
|
||||
App, Background, Hsla, Menu, MenuItem, Pixels, Point, Window, WindowBackgroundAppearance,
|
||||
linear_color_stop, linear_gradient, point, px, rgb,
|
||||
App, Background, Hsla, Menu, MenuItem, OsAction, Pixels, Point, SystemMenuType, Window,
|
||||
WindowBackgroundAppearance, linear_color_stop, linear_gradient, point, px, rgb,
|
||||
};
|
||||
use gpui_component::{Theme, ThemeMode};
|
||||
|
||||
use crate::core::actions::*;
|
||||
use crate::core::config::Config;
|
||||
use crate::terminal::view::{
|
||||
ClearScrollback, CopyText, CutText, FindInTerminal, FindNext, FindPrevious, PasteText,
|
||||
RedoEdit, SelectAll, UndoEdit,
|
||||
};
|
||||
use crate::ui::presets;
|
||||
use crate::ui::presets::Fill;
|
||||
|
||||
@@ -26,47 +30,118 @@ pub(crate) fn traffic_light_position() -> Point<Pixels> {
|
||||
}
|
||||
|
||||
/// (Re)build the macOS menu bar.
|
||||
///
|
||||
/// Menu order and contents follow the macOS HIG's standard set — App, File,
|
||||
/// Edit, View, Window, Help — because that is where a Mac user's hand goes
|
||||
/// before they read a single label. The app used to ship four menus in the
|
||||
/// order App / Shell / Window / View with no Edit at all, which put Copy and
|
||||
/// Paste nowhere but a right-click and made the whole bar read as improvised.
|
||||
///
|
||||
/// Two deliberate departures from a stock bar:
|
||||
///
|
||||
/// * There is no "Shell" menu. Its contents (new/close/split/rename) are File's
|
||||
/// job everywhere else, and the name collided with Settings → Shell, which
|
||||
/// configures something entirely different — the program a pane launches.
|
||||
/// * "Restart Daemon…" lives at the bottom of Help, not near Settings. It is a
|
||||
/// break-glass repair, it ends every running shell, and it has no business
|
||||
/// one slot away from ⌘,.
|
||||
pub(crate) fn set_menus(cx: &mut App) {
|
||||
cx.set_menus([
|
||||
Menu::new("tty7").items([
|
||||
MenuItem::action("About tty7", About),
|
||||
MenuItem::action("Check for Updates…", CheckForUpdates),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Settings…", OpenSettings),
|
||||
MenuItem::separator(),
|
||||
MenuItem::os_submenu("Services", SystemMenuType::Services),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Hide tty7", HideApp),
|
||||
MenuItem::action("Hide Others", HideOthers),
|
||||
MenuItem::action("Show All", ShowAll),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Quit tty7", Quit),
|
||||
]),
|
||||
Menu::new("File").items([
|
||||
MenuItem::action("New Tab", NewTab),
|
||||
MenuItem::action("New Workspace", NewWorkspace),
|
||||
MenuItem::action("New Worktree Tab", NewWorktreeTab),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Split Right", SplitRight),
|
||||
MenuItem::action("Split Down", SplitDown),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Rename Tab…", RenameTab),
|
||||
MenuItem::action("Copy Working Directory", CopyWorkingDirectory),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Close Pane / Tab", CloseActiveTab),
|
||||
MenuItem::action("Close Other Tabs", CloseOtherTabs),
|
||||
MenuItem::action("Close Tabs to the Right", CloseTabsToTheRight),
|
||||
MenuItem::action("Reopen Closed Tab", ReopenClosedTab),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Rename Workspace…", RenameWorkspace),
|
||||
// Separated: the only item above the rule that touches running
|
||||
// sessions is none of them — closing a window or a tab leaves the
|
||||
// shells alive in the daemon. Stop ends them but keeps the layout.
|
||||
MenuItem::action("Stop Workspace…", StopWorkspace),
|
||||
// Alone at the very bottom, behind its own rule: the one
|
||||
// irreversible item in the entire menu bar. It used to sit directly
|
||||
// under Stop, distinguishable only by the verb.
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Delete Workspace…", DeleteWorkspace),
|
||||
]),
|
||||
// `os_action` routes these through the standard Cut/Copy/Paste/Select All
|
||||
// selectors, so they behave like every other Mac app's Edit menu (and stay
|
||||
// enabled via the app delegate) while still dispatching our own actions.
|
||||
// They carry no key-equivalent glyph: the chords are handled inline in
|
||||
// `terminal::view::handle_cmd_shortcut` rather than as registered
|
||||
// bindings, because ⌃C has to fall through to SIGINT when nothing is
|
||||
// selected — a registered binding would swallow it.
|
||||
Menu::new("Edit").items([
|
||||
MenuItem::os_action("Undo", UndoEdit, OsAction::Undo),
|
||||
MenuItem::os_action("Redo", RedoEdit, OsAction::Redo),
|
||||
MenuItem::separator(),
|
||||
MenuItem::os_action("Cut", CutText, OsAction::Cut),
|
||||
MenuItem::os_action("Copy", CopyText, OsAction::Copy),
|
||||
MenuItem::os_action("Paste", PasteText, OsAction::Paste),
|
||||
MenuItem::os_action("Select All", SelectAll, OsAction::SelectAll),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Find…", FindInTerminal),
|
||||
MenuItem::action("Find Next", FindNext),
|
||||
MenuItem::action("Find Previous", FindPrevious),
|
||||
]),
|
||||
Menu::new("View").items([
|
||||
MenuItem::action("Command Palette…", TogglePalette),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Increase Font Size", IncreaseFontSize),
|
||||
MenuItem::action("Decrease Font Size", DecreaseFontSize),
|
||||
MenuItem::action("Reset Font Size", ResetFontSize),
|
||||
MenuItem::separator(),
|
||||
// The three docks and the tab rail's placement — the most literally
|
||||
// "view" things in the app, and until now reachable only by chord.
|
||||
MenuItem::action("Left Sidebar", ToggleLeftPanel),
|
||||
MenuItem::action("Right Panel", ToggleRightPanel),
|
||||
MenuItem::action("Code Panel", ToggleCodePanel),
|
||||
MenuItem::action("Tab Bar Position", ToggleTabSidebar),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Focus Next Pane", FocusNextPane),
|
||||
MenuItem::action("Focus Previous Pane", FocusPrevPane),
|
||||
MenuItem::action("Zoom Pane", ToggleMaximizePane),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Clear Scrollback", ClearScrollback),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Enter Full Screen", ToggleFullscreen),
|
||||
]),
|
||||
Menu::new("Window").items(window_menu_items(cx)),
|
||||
Menu::new("Help").items([
|
||||
MenuItem::action("tty7 Documentation", OpenDocumentation),
|
||||
MenuItem::action("Keyboard Shortcuts", ShowKeyboardShortcuts),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Join the Discord", OpenDiscord),
|
||||
MenuItem::action("Report an Issue…", ReportIssue),
|
||||
MenuItem::separator(),
|
||||
// Force a fresh background daemon (so a newly granted macOS permission
|
||||
// such as Full Disk Access takes effect). The trailing "…" signals the
|
||||
// confirmation prompt; it ends every running session.
|
||||
MenuItem::action("Restart Daemon…", RestartDaemon),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Quit tty7", Quit),
|
||||
]),
|
||||
Menu::new("Shell").items([
|
||||
MenuItem::action("New Tab", NewTab),
|
||||
MenuItem::action("New Workspace", NewWorkspace),
|
||||
MenuItem::action("Split Right", SplitRight),
|
||||
MenuItem::action("Split Down", SplitDown),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Focus Next Pane", FocusNextPane),
|
||||
MenuItem::action("Focus Previous Pane", FocusPrevPane),
|
||||
MenuItem::action("Toggle Maximize Pane", ToggleMaximizePane),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Reopen Closed Tab", ReopenClosedTab),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Close Pane / Tab", CloseActiveTab),
|
||||
// Last, and separated: the only two items here that touch running
|
||||
// sessions. Everything above them — including closing the window —
|
||||
// leaves the shells alive in the daemon, so these sit apart rather
|
||||
// than a mis-click away from "Close Pane / Tab". Stop keeps the
|
||||
// layout; Delete is the only thing that discards it.
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Stop Workspace…", StopWorkspace),
|
||||
MenuItem::action("Delete Workspace…", DeleteWorkspace),
|
||||
]),
|
||||
Menu::new("Window").items(window_menu_items(cx)),
|
||||
Menu::new("View").items([
|
||||
MenuItem::action("Increase Font Size", IncreaseFontSize),
|
||||
MenuItem::action("Decrease Font Size", DecreaseFontSize),
|
||||
MenuItem::action("Reset Font Size", ResetFontSize),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Toggle Full Screen", ToggleFullscreen),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -94,7 +169,15 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
|
||||
// dispatches identically wherever it was clicked.
|
||||
let slot_action = crate::ui::tab_strip::select_workspace_action;
|
||||
|
||||
let mut items = Vec::new();
|
||||
// Minimize / Zoom first: every Mac app's Window menu opens with them, and a
|
||||
// menu that jumps straight into a bespoke list reads as if the standard ones
|
||||
// were forgotten. The workspace roster follows behind a rule.
|
||||
let mut items = vec![
|
||||
MenuItem::action("Minimize", MinimizeWindow),
|
||||
MenuItem::action("Zoom", ZoomWindow),
|
||||
MenuItem::separator(),
|
||||
];
|
||||
let workspace_start = items.len();
|
||||
let mut separated = false;
|
||||
for (i, (id, open)) in order.iter().enumerate() {
|
||||
let Some(workspace) = store.get(*id) else {
|
||||
@@ -105,7 +188,10 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
|
||||
// away. Only drawn once, and never as a leading rule.
|
||||
if !open && !separated {
|
||||
separated = true;
|
||||
if !items.is_empty() {
|
||||
// Compared against the roster's own start, not the whole menu: with
|
||||
// Minimize/Zoom above, `items` is never empty and the old check
|
||||
// would have drawn a second rule directly under the first.
|
||||
if items.len() > workspace_start {
|
||||
items.push(MenuItem::Separator);
|
||||
}
|
||||
}
|
||||
@@ -128,9 +214,9 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if items.is_empty() {
|
||||
// Never hand back an empty menu — an unclickable "Window" title reads
|
||||
// as broken. The one workspace that must exist is the current one.
|
||||
if items.len() == workspace_start {
|
||||
// Never leave the roster empty — a Window menu that lists no windows
|
||||
// reads as broken. The one workspace that must exist is the current one.
|
||||
items.push(MenuItem::action("New Workspace", NewWorkspace));
|
||||
}
|
||||
items
|
||||
|
||||
+6
-3
@@ -191,10 +191,13 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> {
|
||||
};
|
||||
items.push(SpecItem::Submenu {
|
||||
label: "Notifications".into(),
|
||||
// Weakest to strongest, matching Settings → Window & Tabs → Notify on
|
||||
// command finish, which writes the same setting. The two used to run in
|
||||
// opposite directions with different capitalisation.
|
||||
items: vec![
|
||||
notify("notify:always", "Always", NotifyMode::Always),
|
||||
notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused),
|
||||
notify("notify:never", "Never", NotifyMode::Never),
|
||||
notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused),
|
||||
notify("notify:always", "Always", NotifyMode::Always),
|
||||
],
|
||||
});
|
||||
items.push(item("settings", "Settings…".into()));
|
||||
@@ -203,7 +206,7 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> {
|
||||
items.push(item("quit", "Quit tty7".into()));
|
||||
// Plain quit leaves the daemon (and every session) running; this one
|
||||
// stops the daemon too. "Daemon" is already in the product vocabulary —
|
||||
// the app menu ships "Restart Daemon…" — and the confirm prompt spells
|
||||
// the Help menu ships "Restart Daemon…" — and the confirm prompt spells
|
||||
// out the consequences.
|
||||
items.push(item("quit-stop", "Quit and Stop Daemon…".into()));
|
||||
items
|
||||
|
||||
+4
-1
@@ -356,9 +356,12 @@ fn confirm_destructive(
|
||||
(1, _) => "1 running session will be ended.".to_string(),
|
||||
(n, _) => format!("{n} running sessions will be ended."),
|
||||
};
|
||||
// Title Case, like every other prompt title in the app — this one used to
|
||||
// lowercase "workspace" while its siblings read "Close Window?" /
|
||||
// "Quit and Stop Daemon?".
|
||||
let answer = window.prompt(
|
||||
gpui::PromptLevel::Warning,
|
||||
&format!("{verb} workspace \u{201c}{name}\u{201d}?"),
|
||||
&format!("{verb} Workspace \u{201c}{name}\u{201d}?"),
|
||||
Some(&detail),
|
||||
&["Cancel", verb],
|
||||
cx,
|
||||
|
||||
Reference in New Issue
Block a user