yoda: lecture seule par onglet, a la Ghostty (toggle_readonly)
Surveiller une session Claude Code ou Modjo sans risquer de lui taper dedans, ni de lui coller quoi que ce soit. - Terminal::read_only : input_no_scroll, input_scroll (donc paste) et la molette en mode souris n'ecrivent plus rien, et une frappe ne ramene plus la vue en bas. Couvre touches legacy et kitty, collage, clic milieu, Ctrl+C, gestionnaire de mots de passe, IME, glisser-deposer. - Terminal::respond : ecriture non bloquee pour ce que le terminal repond lui-meme (PtyWrite, couleurs, taille, rapports de focus), sinon une application attendrait une reponse qui ne vient pas (CSI ? u de Claude Code, DA, DSR...). - terminal_box : en lecture seule la souris revient au terminal (selection, copie et defilement locaux, meme dans une application qui la capture). - Case « Lecture seule » dans Affichage et dans le menu contextuel ; action ToggleReadOnly dans les raccourcis (groupe Autre, sans raccourci par defaut, comme Ghostty et Konsole) ; cadenas changes-prevent-symbolic sur l'onglet, apres les alertes. Test de bout en bout dans un vrai PTY (cat) : saisie et collage bloques, reponse transmise, saisie retablie ; il echoue si le verrou est retire. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
db9ee02d8d
commit
a035b47e98
7 changed files with 142 additions and 8 deletions
|
|
@ -140,6 +140,7 @@ save-output = Save output as text...
|
|||
monitor-activity = Monitor for activity
|
||||
monitor-silence = Monitor for silence
|
||||
monitor-process-finished = Monitor for process finishing
|
||||
read-only = Read-only
|
||||
|
||||
## Notifications
|
||||
notification-show = Show tab
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ save-output = Enregistrer la sortie comme texte...
|
|||
monitor-activity = Surveiller l'activité
|
||||
monitor-silence = Surveiller le silence
|
||||
monitor-process-finished = Surveiller la fin du processus
|
||||
read-only = Lecture seule
|
||||
|
||||
## Notifications
|
||||
notification-show = Afficher l'onglet
|
||||
|
|
|
|||
42
src/main.rs
42
src/main.rs
|
|
@ -115,6 +115,7 @@ fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> {
|
|||
Some(MonitorKind::Activity) => Some("dialog-information-symbolic"),
|
||||
Some(MonitorKind::Silence) => Some("alarm-symbolic"),
|
||||
Some(MonitorKind::ProcessFinished) => Some("emblem-ok-symbolic"),
|
||||
None if terminal.read_only => Some("changes-prevent-symbolic"),
|
||||
None if terminal.monitoring_enabled() => Some("view-reveal-symbolic"),
|
||||
None => None,
|
||||
}
|
||||
|
|
@ -390,6 +391,7 @@ pub enum Action {
|
|||
LayoutLoad,
|
||||
LayoutSave,
|
||||
Monitor(MonitorKind),
|
||||
ToggleReadOnly,
|
||||
PaneFocusDown,
|
||||
PaneFocusLeft,
|
||||
PaneFocusRight,
|
||||
|
|
@ -449,6 +451,7 @@ impl Action {
|
|||
Self::LayoutLoad => Message::LayoutLoad,
|
||||
Self::LayoutSave => Message::LayoutSave,
|
||||
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
|
||||
Self::ToggleReadOnly => Message::ReadOnlyToggle(entity_opt),
|
||||
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
|
||||
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
|
||||
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
|
||||
|
|
@ -556,6 +559,7 @@ pub enum Message {
|
|||
ShortcutSearch(String),
|
||||
MonitorTick,
|
||||
MonitorToggle(MonitorKind, Option<segmented_button::Entity>),
|
||||
ReadOnlyToggle(Option<segmented_button::Entity>),
|
||||
MouseEnter(pane_grid::Pane),
|
||||
Opacity(u8),
|
||||
PaneClicked(pane_grid::Pane),
|
||||
|
|
@ -1809,6 +1813,22 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn terminal_read_only(&self, pane: pane_grid::Pane, entity: segmented_button::Entity) -> bool {
|
||||
self.pane_model
|
||||
.panes
|
||||
.get(pane)
|
||||
.and_then(|tab_model| tab_model.data::<Mutex<Terminal>>(entity))
|
||||
.is_some_and(|terminal| terminal.lock().unwrap().read_only)
|
||||
}
|
||||
|
||||
fn active_terminal_read_only(&self) -> bool {
|
||||
let pane = self.pane_model.focused();
|
||||
self.pane_model
|
||||
.panes
|
||||
.get(pane)
|
||||
.is_some_and(|tab_model| self.terminal_read_only(pane, tab_model.active()))
|
||||
}
|
||||
|
||||
fn active_terminal_monitors(&self) -> (bool, bool, bool) {
|
||||
let pane = self.pane_model.focused();
|
||||
if let Some(tab_model) = self.pane_model.panes.get(pane) {
|
||||
|
|
@ -3034,6 +3054,20 @@ impl Application for App {
|
|||
}
|
||||
}
|
||||
}
|
||||
Message::ReadOnlyToggle(entity_opt) => {
|
||||
if let Some(tab_model) = self.pane_model.active_mut() {
|
||||
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
|
||||
let mut icon_update = None;
|
||||
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
|
||||
let mut terminal = terminal.lock().unwrap();
|
||||
terminal.read_only = !terminal.read_only;
|
||||
icon_update = Some(monitor_icon_name(&terminal));
|
||||
}
|
||||
if let Some(icon_name) = icon_update {
|
||||
monitor_update_tab_icon(tab_model, entity, icon_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::MonitorToggle(monitor_kind, entity_opt) => {
|
||||
if let Some(tab_model) = self.pane_model.active_mut() {
|
||||
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
|
||||
|
|
@ -3718,7 +3752,7 @@ impl Application for App {
|
|||
let terminal = terminal.lock().unwrap();
|
||||
let rgb = terminal.effective_color(index);
|
||||
let text = f(rgb);
|
||||
terminal.input_no_scroll(text.into_bytes());
|
||||
terminal.respond(text.into_bytes());
|
||||
}
|
||||
}
|
||||
TermEvent::CursorBlinkingChange => {
|
||||
|
|
@ -3732,7 +3766,7 @@ impl Application for App {
|
|||
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
|
||||
{
|
||||
let terminal = terminal.lock().unwrap();
|
||||
terminal.input_no_scroll(text.into_bytes());
|
||||
terminal.respond(text.into_bytes());
|
||||
}
|
||||
}
|
||||
TermEvent::ResetTitle => {
|
||||
|
|
@ -3757,7 +3791,7 @@ impl Application for App {
|
|||
{
|
||||
let terminal = terminal.lock().unwrap();
|
||||
let text = f(terminal.size().into());
|
||||
terminal.input_no_scroll(text.into_bytes());
|
||||
terminal.respond(text.into_bytes());
|
||||
}
|
||||
}
|
||||
TermEvent::Title(title) => {
|
||||
|
|
@ -4160,6 +4194,7 @@ impl Application for App {
|
|||
&self.config,
|
||||
&self.key_binds,
|
||||
self.active_terminal_monitors(),
|
||||
self.active_terminal_read_only(),
|
||||
&self.ssh_hosts,
|
||||
)]
|
||||
}
|
||||
|
|
@ -4292,6 +4327,7 @@ impl Application for App {
|
|||
entity,
|
||||
link,
|
||||
self.terminal_monitors(pane, entity),
|
||||
self.terminal_read_only(pane, entity),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub fn context_menu(
|
|||
entity: segmented_button::Entity,
|
||||
link: Option<String>,
|
||||
monitors: (bool, bool, bool),
|
||||
read_only: bool,
|
||||
) -> Vec<widget::menu::Tree<Message>> {
|
||||
let item =
|
||||
|label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action));
|
||||
|
|
@ -78,6 +79,7 @@ pub fn context_menu(
|
|||
monitors.2,
|
||||
Action::Monitor(MonitorKind::ProcessFinished),
|
||||
),
|
||||
check(fl!("read-only"), read_only, Action::ToggleReadOnly),
|
||||
MenuItem::Divider,
|
||||
item(fl!("split-horizontal"), Action::PaneSplitHorizontal),
|
||||
item(fl!("split-vertical"), Action::PaneSplitVertical),
|
||||
|
|
@ -163,6 +165,7 @@ pub fn menu_bar<'a>(
|
|||
config: &Config,
|
||||
key_binds: &HashMap<KeyBind, Action>,
|
||||
monitors: (bool, bool, bool),
|
||||
read_only: bool,
|
||||
ssh_hosts: &[String],
|
||||
) -> Element<'a, Message> {
|
||||
let mut profile_items = Vec::with_capacity(config.profiles.len());
|
||||
|
|
@ -264,6 +267,12 @@ pub fn menu_bar<'a>(
|
|||
monitors.2,
|
||||
Action::Monitor(MonitorKind::ProcessFinished),
|
||||
),
|
||||
MenuItem::CheckBox(
|
||||
fl!("read-only"),
|
||||
None,
|
||||
read_only,
|
||||
Action::ToggleReadOnly,
|
||||
),
|
||||
MenuItem::Divider,
|
||||
MenuItem::Button(
|
||||
fl!("menu-color-schemes"),
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ pub enum KeyBindAction {
|
|||
TabPrev,
|
||||
TabRename,
|
||||
ToggleFullscreen,
|
||||
ToggleReadOnly,
|
||||
WindowClose,
|
||||
WindowNew,
|
||||
ZoomIn,
|
||||
|
|
@ -132,6 +133,7 @@ impl KeyBindAction {
|
|||
Self::TabPrev => Some(Action::TabPrev),
|
||||
Self::TabRename => Some(Action::TabRename),
|
||||
Self::ToggleFullscreen => Some(Action::ToggleFullscreen),
|
||||
Self::ToggleReadOnly => Some(Action::ToggleReadOnly),
|
||||
Self::WindowClose => Some(Action::WindowClose),
|
||||
Self::WindowNew => Some(Action::WindowNew),
|
||||
Self::ZoomIn => Some(Action::ZoomIn),
|
||||
|
|
@ -295,6 +297,7 @@ pub fn action_label(action: KeyBindAction) -> String {
|
|||
KeyBindAction::TabPrev => fl!("previous-tab"),
|
||||
KeyBindAction::TabRename => fl!("rename-tab"),
|
||||
KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"),
|
||||
KeyBindAction::ToggleReadOnly => fl!("read-only"),
|
||||
KeyBindAction::WindowClose => fl!("close-window"),
|
||||
KeyBindAction::WindowNew => fl!("new-window"),
|
||||
KeyBindAction::ZoomIn => fl!("zoom-in"),
|
||||
|
|
@ -369,7 +372,11 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
|
|||
KeyBindAction::ZoomReset,
|
||||
],
|
||||
});
|
||||
let mut other_actions = vec![KeyBindAction::ClearScrollback, KeyBindAction::SaveOutput];
|
||||
let mut other_actions = vec![
|
||||
KeyBindAction::ClearScrollback,
|
||||
KeyBindAction::SaveOutput,
|
||||
KeyBindAction::ToggleReadOnly,
|
||||
];
|
||||
#[cfg(feature = "password_manager")]
|
||||
other_actions.push(KeyBindAction::PasswordManager);
|
||||
groups.push(ShortcutGroup {
|
||||
|
|
|
|||
|
|
@ -382,6 +382,9 @@ pub struct Terminal {
|
|||
pub monitor_job_running: bool,
|
||||
pub attention: Option<AttentionKind>,
|
||||
notify_last: Option<(Instant, u64)>,
|
||||
/// [yoda] Lecture seule : aucune saisie de l'utilisateur n'atteint le PTY
|
||||
/// (touches, collage, souris) ; les réponses du terminal passent toujours.
|
||||
pub read_only: bool,
|
||||
pub needs_update: bool,
|
||||
pub profile_id_opt: Option<ProfileId>,
|
||||
pub tab_title_override: Option<String>,
|
||||
|
|
@ -514,6 +517,7 @@ impl Terminal {
|
|||
monitor_job_running: false,
|
||||
attention: None,
|
||||
notify_last: None,
|
||||
read_only: false,
|
||||
mouse_reporter: Default::default(),
|
||||
needs_update: true,
|
||||
notifier,
|
||||
|
|
@ -607,7 +611,7 @@ impl Terminal {
|
|||
const FOCUS_OUT: &[u8] = b"\x1b[O";
|
||||
|
||||
let input = if is_focused { FOCUS_IN } else { FOCUS_OUT };
|
||||
self.input_no_scroll(input);
|
||||
self.respond(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -620,7 +624,18 @@ impl Terminal {
|
|||
self.with_buffer_mut(|buffer| buffer.set_redraw(redraw));
|
||||
}
|
||||
|
||||
/// Saisie de l'utilisateur vers le PTY ; ignorée en lecture seule.
|
||||
pub fn input_no_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
self.notifier.notify(input);
|
||||
}
|
||||
|
||||
/// [yoda] Réponse du terminal lui-même (rapports demandés par
|
||||
/// l'application, focus) : jamais bloquée par la lecture seule, sinon
|
||||
/// l'application attendrait une réponse qui ne vient pas.
|
||||
pub fn respond<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
|
||||
self.notifier.notify(input);
|
||||
}
|
||||
|
||||
|
|
@ -638,6 +653,10 @@ impl Terminal {
|
|||
}
|
||||
|
||||
pub fn input_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
|
||||
// En lecture seule, une frappe ne ramène pas non plus la vue en bas
|
||||
if self.read_only {
|
||||
return;
|
||||
}
|
||||
self.input_no_scroll(input);
|
||||
self.scroll(TerminalScroll::Bottom);
|
||||
}
|
||||
|
|
@ -1299,8 +1318,11 @@ impl Terminal {
|
|||
y,
|
||||
);
|
||||
|
||||
for code in codes {
|
||||
self.notifier.notify(code);
|
||||
// Saisie de l'utilisateur : rien en lecture seule
|
||||
if !self.read_only {
|
||||
for code in codes {
|
||||
self.notifier.notify(code);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.scroll_as_arrows(delta);
|
||||
|
|
@ -1513,6 +1535,61 @@ impl Drop for Terminal {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Lecture seule, de bout en bout : un vrai `cat` dans un vrai PTY. La
|
||||
/// saisie bloquée n'arrive pas ; une réponse du terminal, si ; la saisie
|
||||
/// repasse quand le verrou est levé.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_only_blocks_input_but_not_replies() {
|
||||
use std::time::Duration;
|
||||
|
||||
let (_panes, pane) = pane_grid::State::new(());
|
||||
let mut model = segmented_button::Model::<segmented_button::SingleSelect>::default();
|
||||
let entity = model.insert().id();
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let options = Options {
|
||||
shell: Some(tty::Shell::new("cat".into(), Vec::new())),
|
||||
..Options::default()
|
||||
};
|
||||
let mut terminal = Terminal::new(
|
||||
pane,
|
||||
entity,
|
||||
event_tx,
|
||||
Config::default(),
|
||||
options,
|
||||
&AppConfig::default(),
|
||||
crate::terminal_theme::cosmic_dark(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let wait_for = |terminal: &Terminal, needle: &str| {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if terminal.scrollback_text().contains(needle) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
terminal.read_only = true;
|
||||
terminal.input_scroll(&b"bloque\n"[..]);
|
||||
terminal.paste("colle\n".to_string());
|
||||
terminal.respond(&b"repond\n"[..]);
|
||||
assert!(wait_for(&terminal, "repond"));
|
||||
// Écrites avant la réponse, la saisie et le collage seraient déjà là
|
||||
let text = terminal.scrollback_text();
|
||||
assert!(!text.contains("bloque"), "{text}");
|
||||
assert!(!text.contains("colle"), "{text}");
|
||||
|
||||
terminal.read_only = false;
|
||||
terminal.input_scroll(&b"libre\n"[..]);
|
||||
assert!(wait_for(&terminal, "libre"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_fill_couvre_toute_la_plage_unicode() {
|
||||
// U+2580–U+259F : chaque élément de bloc a sa géométrie native.
|
||||
|
|
|
|||
|
|
@ -974,7 +974,10 @@ where
|
|||
let buffer_size = terminal.with_buffer(|buffer| buffer.size());
|
||||
|
||||
let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR);
|
||||
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE);
|
||||
// [yoda] En lecture seule, la souris revient au terminal : sélection et
|
||||
// défilement locaux, même dans une application qui la capture.
|
||||
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE)
|
||||
&& !terminal.read_only;
|
||||
let term_mode = *terminal.term.lock().mode();
|
||||
match event {
|
||||
Event::Window(event) => match event {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue