Files
tty7/src/ui/hints.rs
T
l0ng-aiandl0ng-ai b9956ad6af feat(switcher): two-column panel with IDEA-style Ctrl+Tab (#380)
* feat(switcher): two-column panel with IDEA-style Ctrl+Tab

The switcher listed workspaces only; reaching a tab inside one meant
opening it first. It is now two columns — workspaces on the left, the
tabs of whichever one the cursor sits on to the right — and Ctrl+Tab
raises it as a most-recently-used tab switcher that commits when the
modifier comes up.

Picking a workspace or a tab now switches this window in place. A second
window is something you ask for, with the platform modifier or "Open in
New Window", rather than what happens by default.

New workspaces get a codename ("amber-yak") instead of inheriting
whatever directory their first shell started in. The generator moves out
of worktree.rs into core::codename so both callers share it.

* fix(switcher): review fixes — platform-gate the Ctrl+Tab key test, park the new-window tab

- The key_intent test asserted only the macOS half of the Tab chord; off
  macOS Ctrl *is* the secondary modifier, the raw key falls through on
  purpose (the chord arrives as NextTab), and the test now says so
  instead of failing there.
- open_at_tab probed a freshly opened window's tabs exactly once, but a
  new window hydrates them asynchronously — route through
  activate_tree_tab so the pick parks until the tab arrives.
- Escape mid-rename backs out of the rename instead of tearing down the
  whole panel.
- A one-tab workspace reads '1 tab', not '1 tabs'.

Claude-Session: https://claude.ai/code/session_01V7xjxdUGWQp93KQDuFuh8d

* style: cargo fmt

Claude-Session: https://claude.ai/code/session_01V7xjxdUGWQp93KQDuFuh8d

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 14:30:40 +08:00

152 lines
4.6 KiB
Rust

use gpui::{Context, ModifiersChangedEvent, Window};
use crate::ui::app::Tty7App;
const BADGE_DELAY_MS: u64 = 200;
pub(crate) fn tab_badge_label(index: usize) -> String {
(index + 1).to_string()
}
impl Tty7App {
pub(crate) fn on_modifiers_changed(
&mut self,
ev: &ModifiersChangedEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let m = &ev.modifiers;
self.switcher_hold_changed(m, window, cx);
self.set_link_modifier(m.secondary(), cx);
let extra_platform = if cfg!(target_os = "macos") {
m.control
} else {
m.platform
};
let bare_secondary = m.secondary() && !m.alt && !m.shift && !extra_platform;
self.mod_hint_gen = self.mod_hint_gen.wrapping_add(1);
if !bare_secondary {
self.dismiss_mod_hint(cx);
return;
}
let generation = self.mod_hint_gen;
cx.spawn(async move |this, cx| {
smol::Timer::after(std::time::Duration::from_millis(BADGE_DELAY_MS)).await;
let _ = this.update(cx, |this, cx| {
if this.mod_hint_gen == generation {
this.mod_hint_badges = true;
cx.notify();
}
});
})
.detach();
}
pub(crate) fn set_link_modifier(&mut self, down: bool, cx: &mut Context<Self>) {
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
leaf.update(cx, |view, cx| {
view.refresh_link_hover(down, cx);
});
}
}
}
pub(crate) fn dismiss_mod_hint(&mut self, cx: &mut Context<Self>) {
self.mod_hint_gen = self.mod_hint_gen.wrapping_add(1);
if self.mod_hint_badges {
self.mod_hint_badges = false;
cx.notify();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tab_badge_label_is_the_bare_digit() {
assert_eq!(tab_badge_label(0), "1");
assert_eq!(tab_badge_label(8), "9");
}
}
#[cfg(test)]
mod gpui_tests {
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use gpui::{Modifiers, TestAppContext, VisualTestContext, WindowHandle};
fn harness(cx: &mut TestAppContext) -> (WindowHandle<Tty7App>, VisualTestContext) {
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
});
let window = cx.add_window(|window, cx| {
Tty7App::with_session(None, Some(Session::default()), window, cx)
});
window
.update(cx, |_, window, _| window.activate_window())
.unwrap();
cx.background_executor.run_until_parked();
let vcx = VisualTestContext::from_window(window.into(), cx);
(window, vcx)
}
fn badges_shown(window: &WindowHandle<Tty7App>, cx: &mut TestAppContext) -> bool {
window
.update(cx, |app, _, _| app.mod_hint_badges)
.expect("the app window stays open")
}
fn wait_for_badges(window: &WindowHandle<Tty7App>, cx: &mut TestAppContext) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
cx.background_executor.run_until_parked();
if badges_shown(window, cx) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"badges never appeared within 5s of the bare-secondary hold"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[gpui::test]
fn deactivation_dismisses_visible_badges(cx: &mut TestAppContext) {
let (window, mut vcx) = harness(cx);
vcx.simulate_modifiers_change(Modifiers::secondary_key());
wait_for_badges(&window, cx);
vcx.deactivate_window();
assert!(
!badges_shown(&window, cx),
"deactivation must dismiss the badges — the modifier release will never reach this window"
);
}
#[gpui::test]
fn deactivation_cancels_a_pending_reveal(cx: &mut TestAppContext) {
let (window, mut vcx) = harness(cx);
vcx.simulate_modifiers_change(Modifiers::secondary_key());
vcx.deactivate_window();
std::thread::sleep(std::time::Duration::from_millis(400));
cx.background_executor.run_until_parked();
assert!(
!badges_shown(&window, cx),
"a reveal scheduled before deactivation must not fire after it"
);
}
}