Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: per-client active workspace views (review fixes) by Castrozan · Pull Request #7 · Castrozan/herdr · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions src/app/client_view.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
use std::time::Instant;

use super::state::{
AppState, ContextMenuState, CopyFeedback, CopyModeState, DragState, KeybindHelpState,
MenuListState, Mode, NavigatorState, PaneFocusTarget, ProductAnnouncementState,
ReleaseNotesState, SelectionAutoscroll, TabPressState, ViewState, WorkspacePressState,
WorktreeCreateState, WorktreeOpenState, WorktreeRemoveState,
};
use super::App;
use crate::layout::PaneId;
use crate::selection::Selection;

#[cfg(test)]
#[path = "client_view_tests.rs"]
mod tests;

/// Per-client, workspace-relative view state swapped into AppState around render and input.
pub(crate) struct ClientView {
pub active_workspace_id: Option<String>,
pub selected_workspace_id: Option<String>,
pub mode: Mode,
pub previous_pane_focus: Option<PaneFocusTarget>,
pub view: ViewState,
pub navigator: NavigatorState,
pub copy_mode: Option<CopyModeState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
pub drag: Option<DragState>,
pub workspace_press: Option<WorkspacePressState>,
pub tab_press: Option<TabPressState>,
pub workspace_scroll: usize,
pub tab_scroll: usize,
pub tab_scroll_follow_active: bool,
pub mobile_switcher_scroll: usize,
pub name_input: String,
pub name_input_replace_on_type: bool,
pub creating_new_tab: bool,
pub rename_pane_target: Option<PaneId>,
pub worktree_create: Option<WorktreeCreateState>,
pub worktree_open: Option<WorktreeOpenState>,
pub worktree_remove: Option<WorktreeRemoveState>,
pub release_notes: Option<ReleaseNotesState>,
pub product_announcement: Option<ProductAnnouncementState>,
pub keybind_help: KeybindHelpState,
pub global_menu: MenuListState,
pub copy_feedback: Option<CopyFeedback>,
pub agent_panel_scroll: usize,
pub collapsed_space_keys: std::collections::HashSet<String>,
pub copy_feedback_deadline: Option<Instant>,
pub selection_autoscroll_deadline: Option<Instant>,
pub selection_highlight_clear_deadline: Option<Instant>,
}

impl ClientView {
/// The earliest expiry deadline this saved view is waiting on, so the headless
/// loop keeps scheduling wake-ups for transients parked outside the loaded view.
pub(crate) fn next_transient_deadline(&self) -> Option<Instant> {
[
self.copy_feedback_deadline,
self.selection_autoscroll_deadline,
self.selection_highlight_clear_deadline,
]
.into_iter()
.flatten()
.min()
}

pub(crate) fn has_due_transient(&self, now: Instant) -> bool {
self.next_transient_deadline()
.is_some_and(|deadline| now >= deadline)
}

/// Drops the interaction state that addresses workspaces and tabs by index.
///
/// A saved view can hold an open context menu, a press, a drag, or a close
/// confirmation indefinitely, and those payloads carry raw indices that are
/// restored verbatim. Once another client mutates the workspace tree the indices
/// no longer name what the user aimed at, so the pending interaction is canceled
/// rather than replayed against a different workspace.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active_workspace_id.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}
}

impl AppState {
/// The loaded-state counterpart of [`ClientView::cancel_workspace_index_state`],
/// applied to the view that is currently swapped in.
pub(crate) fn cancel_workspace_index_state(&mut self) {
self.context_menu = None;
self.drag = None;
self.workspace_press = None;
self.tab_press = None;
if matches!(self.mode, Mode::ContextMenu | Mode::ConfirmClose) {
self.mode = if self.active.is_some() {
Mode::Terminal
} else {
Mode::Navigate
};
}
}

fn active_workspace_id(&self) -> Option<String> {
self.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.id.clone())
}

fn selected_workspace_id(&self) -> Option<String> {
self.workspaces.get(self.selected).map(|ws| ws.id.clone())
}

pub(crate) fn workspace_index_by_id(&self, workspace_id: &str) -> Option<usize> {
self.workspaces.iter().position(|ws| ws.id == workspace_id)
}

/// Whether the host should capture the mouse for a client sitting on this saved
/// view. Capture depends on the client's own mode and active workspace, so a
/// single value computed from the loaded view is wrong for everyone else.
pub(crate) fn should_capture_host_mouse_in_view(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
client_view: &ClientView,
) -> bool {
self.mouse_capture
|| self.focused_pane_requests_mouse_capture_in(
terminal_runtimes,
client_view.mode,
client_view
.active_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id)),
)
}

fn set_active_by_id(&mut self, workspace_id: Option<&str>) {
self.active = match workspace_id {
Some(workspace_id) => self
.workspace_index_by_id(workspace_id)
.or_else(|| (!self.workspaces.is_empty()).then_some(0)),
None => None,
};
}

pub(crate) fn snapshot_client_view(&self) -> ClientView {
ClientView {
active_workspace_id: self.active_workspace_id(),
selected_workspace_id: self.selected_workspace_id(),
mode: self.mode,
previous_pane_focus: self.previous_pane_focus.clone(),
view: self.view.clone(),
navigator: self.navigator.clone(),
copy_mode: self.copy_mode.clone(),
selection: self.selection.clone(),
selection_autoscroll: self.selection_autoscroll.clone(),
context_menu: self.context_menu.clone(),
drag: self.drag.clone(),
workspace_press: self.workspace_press.clone(),
tab_press: self.tab_press.clone(),
workspace_scroll: self.workspace_scroll,
tab_scroll: self.tab_scroll,
tab_scroll_follow_active: self.tab_scroll_follow_active,
mobile_switcher_scroll: self.mobile_switcher_scroll,
name_input: self.name_input.clone(),
name_input_replace_on_type: self.name_input_replace_on_type,
creating_new_tab: self.creating_new_tab,
rename_pane_target: self.rename_pane_target,
worktree_create: self.worktree_create.clone(),
worktree_open: self.worktree_open.clone(),
worktree_remove: self.worktree_remove.clone(),
release_notes: self.release_notes.clone(),
product_announcement: self.product_announcement.clone(),
keybind_help: self.keybind_help.clone(),
global_menu: self.global_menu,
copy_feedback: self.copy_feedback.clone(),
agent_panel_scroll: self.agent_panel_scroll,
collapsed_space_keys: self.collapsed_space_keys.clone(),
copy_feedback_deadline: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
}
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.set_active_by_id(client_view.active_workspace_id.as_deref());
self.selected = client_view
.selected_workspace_id
.as_deref()
.and_then(|workspace_id| self.workspace_index_by_id(workspace_id))
.or(self.active)
.unwrap_or(0);
self.mode = client_view.mode;
if self.mode == Mode::Terminal && self.active.is_none() {
self.mode = Mode::Navigate;
}
self.previous_pane_focus = client_view.previous_pane_focus.clone();
self.view = client_view.view.clone();
self.navigator = client_view.navigator.clone();
self.copy_mode = client_view.copy_mode.clone();
self.selection = client_view.selection.clone();
self.selection_autoscroll = client_view.selection_autoscroll.clone();
self.context_menu = client_view.context_menu.clone();
self.drag = client_view.drag.clone();
self.workspace_press = client_view.workspace_press.clone();
self.tab_press = client_view.tab_press.clone();
self.workspace_scroll = client_view.workspace_scroll;
self.tab_scroll = client_view.tab_scroll;
self.tab_scroll_follow_active = client_view.tab_scroll_follow_active;
self.mobile_switcher_scroll = client_view.mobile_switcher_scroll;
self.name_input = client_view.name_input.clone();
self.name_input_replace_on_type = client_view.name_input_replace_on_type;
self.creating_new_tab = client_view.creating_new_tab;
self.rename_pane_target = client_view.rename_pane_target;
self.worktree_create = client_view.worktree_create.clone();
self.worktree_open = client_view.worktree_open.clone();
self.worktree_remove = client_view.worktree_remove.clone();
self.release_notes = client_view.release_notes.clone();
self.product_announcement = client_view.product_announcement.clone();
self.keybind_help = client_view.keybind_help.clone();
self.global_menu = client_view.global_menu;
self.copy_feedback = client_view.copy_feedback.clone();
self.agent_panel_scroll = client_view.agent_panel_scroll;
self.collapsed_space_keys = client_view.collapsed_space_keys.clone();
}
}

impl App {
/// Saves the loaded view together with the expiry deadlines of its transients.
///
/// The deadlines live on `App` rather than `AppState`, so leaving them behind
/// would let a deadline fire against another client's loaded state: the transient
/// it was meant to clear would come back on restore with nothing left to expire it.
pub(crate) fn snapshot_client_view(&self) -> ClientView {
let mut client_view = self.state.snapshot_client_view();
client_view.copy_feedback_deadline = self.copy_feedback_deadline;
client_view.selection_autoscroll_deadline = self.selection_autoscroll_deadline;
client_view.selection_highlight_clear_deadline = self.selection_highlight_clear_deadline;
client_view
}

pub(crate) fn restore_client_view(&mut self, client_view: &ClientView) {
self.state.restore_client_view(client_view);
self.copy_feedback_deadline = client_view.copy_feedback_deadline;
self.selection_autoscroll_deadline = client_view.selection_autoscroll_deadline;
self.selection_highlight_clear_deadline = client_view.selection_highlight_clear_deadline;
}
}
78 changes: 78 additions & 0 deletions src/app/client_view_tests.rs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
use super::super::state::{AppState, Mode};
use crate::workspace::Workspace;

fn app_state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
for name in names {
state.workspaces.push(Workspace::test_new(name));
}
state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
}
state
}

#[test]
fn snapshot_restore_roundtrips_active_and_selected_workspace() {
let mut state = app_state_with_workspaces(&["one", "two", "three"]);
state.active = Some(2);
state.selected = 0;
let client_view = state.snapshot_client_view();

state.active = Some(1);
state.selected = 1;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(2));
assert_eq!(state.selected, 0);
}

#[test]
fn modal_mode_stays_per_client_view() {
let mut state = app_state_with_workspaces(&["one", "two"]);
let terminal_client_view = state.snapshot_client_view();

state.mode = Mode::Navigate;
state.navigator.query = "two".to_owned();
let navigating_client_view = state.snapshot_client_view();

state.restore_client_view(&terminal_client_view);
assert_eq!(state.mode, Mode::Terminal);
assert!(state.navigator.query.is_empty());

state.restore_client_view(&navigating_client_view);
assert_eq!(state.mode, Mode::Navigate);
assert_eq!(state.navigator.query, "two");
}

#[test]
fn restore_falls_back_to_first_workspace_when_active_id_is_gone() {
let mut state = app_state_with_workspaces(&["one", "two"]);
state.switch_workspace(1);
let client_view = state.snapshot_client_view();

state.workspaces.remove(1);
state.active = Some(0);
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, Some(0));
assert_eq!(state.selected, 0);
}

#[test]
fn restore_with_no_workspaces_downgrades_terminal_mode_to_navigate() {
let mut state = app_state_with_workspaces(&["one"]);
let client_view = state.snapshot_client_view();

state.workspaces.clear();
state.active = None;
state.selected = 0;

state.restore_client_view(&client_view);
assert_eq!(state.active, None);
assert_eq!(state.mode, Mode::Navigate);
}
Loading