Synced from monorepo
Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -475,9 +475,9 @@ pub struct DashboardState {
|
|||
pub peek_reply_rect: Option<Rect>,
|
||||
/// Directory the reply's `@` file-search daemon is currently rooted
|
||||
/// at. Tracked so [`Self::ensure_peek_reply_cwd`] can skip a
|
||||
/// `retarget` (which rebuilds the daemon thread) when the peeked
|
||||
/// agent's cwd hasn't actually changed. `None` = the construction
|
||||
/// default (`.`); set to the launch cwd at dashboard open.
|
||||
/// `retarget` (which drops the daemon so the next @-use rebuilds it)
|
||||
/// when the peeked agent's cwd hasn't actually changed. `None` = the
|
||||
/// construction default (`.`); set to the launch cwd at dashboard open.
|
||||
peek_reply_cwd: Option<PathBuf>,
|
||||
/// Cwd of the currently-peeked agent, recorded by the render pass
|
||||
/// (which has the agents map). Applied lazily to the reply's `@`
|
||||
|
|
@ -1919,12 +1919,12 @@ impl DashboardState {
|
|||
/// Lazily root the reply's `@` file-search daemon at the peeked
|
||||
/// agent's cwd (recorded in [`Self::peek_reply_target_cwd`]).
|
||||
///
|
||||
/// Applied only when it differs from the daemon's current root and
|
||||
/// only at the moment the user composes into the reply — never on a
|
||||
/// bare cursor move — because `retarget` rebuilds the matcher daemon
|
||||
/// thread. So navigating past a dozen agents in other directories
|
||||
/// costs nothing; the (single) retarget happens on the first
|
||||
/// keystroke/paste into the reply, deduped by cwd.
|
||||
/// Applied only when it differs from the daemon's current root, and only
|
||||
/// when the user composes into the reply (never on a bare cursor move),
|
||||
/// because `retarget` throws away the built matcher daemon and the next
|
||||
/// @-use rebuilds it. So navigating past a dozen agents in other
|
||||
/// directories costs nothing; the single retarget happens on the first
|
||||
/// keystroke or paste into the reply, deduped by cwd.
|
||||
fn ensure_peek_reply_cwd(&mut self) {
|
||||
if let Some(target) = self.peek_reply_target_cwd.clone()
|
||||
&& self.peek_reply_cwd.as_deref() != Some(target.as_path())
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ use super::context::{self, AtContext, normalize_display_path};
|
|||
/// Top-K results to request from the fuzzy matcher.
|
||||
const MATCHER_TOP_K: usize = 1000;
|
||||
|
||||
/// Whether a new query should restart the daemon's directory walk.
|
||||
enum RestartWalk {
|
||||
/// Reuse the current walk (query changed but hidden mode did not).
|
||||
Keep,
|
||||
/// Restart the walk, including or excluding hidden entries.
|
||||
Restart { hidden: bool },
|
||||
}
|
||||
|
||||
/// Replacement to apply to the prompt text after accepting a fuzzy result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSearchReplacement {
|
||||
|
|
@ -28,18 +36,35 @@ pub struct FileSearchReplacement {
|
|||
pub text: String,
|
||||
/// Where to place the cursor after replacement.
|
||||
pub cursor: usize,
|
||||
/// Whether the @-context should be cleared (file accepted, not dir drill-down).
|
||||
/// Whether the @-context should be cleared (an already-present directory was committed).
|
||||
pub dismiss: bool,
|
||||
}
|
||||
|
||||
/// Build accepted directory replacement text: append `/` for drill-down and a
|
||||
/// trailing space when the token ends the prompt.
|
||||
fn accept_text(path: &str, at_end: bool) -> String {
|
||||
let mut text = path.to_owned();
|
||||
text.push('/');
|
||||
if at_end {
|
||||
text.push(' ');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// File search state for @-completion.
|
||||
pub struct FileSearchState {
|
||||
/// Directory the matcher walks. Mirrors the daemon's root (which is
|
||||
/// otherwise moved into its worker thread) so callers can introspect
|
||||
/// where `@`-completion is currently pointed.
|
||||
root: PathBuf,
|
||||
/// Background fuzzy matcher daemon.
|
||||
daemon: FuzzyFileMatcherDaemon,
|
||||
/// Background fuzzy matcher daemon, built lazily on first @-use. Eager
|
||||
/// construction spawns the nucleo pool and walker threads even in sessions
|
||||
/// that never open @-search; deferring it moves that thread spawn, and its
|
||||
/// EAGAIN risk, to first use rather than removing it.
|
||||
daemon: Option<FuzzyFileMatcherDaemon>,
|
||||
/// Test-only count of daemon builds, to prove reuse (no drop-and-rebuild).
|
||||
#[cfg(test)]
|
||||
daemon_builds: usize,
|
||||
/// Latest results snapshot from the daemon.
|
||||
results: FuzzyMatcherDaemonResults,
|
||||
/// Current @-context (if cursor is inside an @-token).
|
||||
|
|
@ -51,7 +76,13 @@ pub struct FileSearchState {
|
|||
hovered: Option<usize>,
|
||||
/// Scroll offset for the dropdown list.
|
||||
scroll_offset: usize,
|
||||
/// Generation counter to prevent stale results from flickering in.
|
||||
/// Floor for accepted result generations: the stale-result fence.
|
||||
///
|
||||
/// Rises monotonically and is never lowered. Each new query bumps it (see
|
||||
/// `start_query`); the daemon paces its own per-tick `generation`
|
||||
/// independently, so `poll` drops any snapshot whose `generation` predates
|
||||
/// the floor and, on accept, raises the floor to the accepted snapshot's
|
||||
/// generation. This keeps matches from a prior query from flickering in.
|
||||
min_generation: usize,
|
||||
/// Directory being drilled into; keeps the @-token alive when its name has
|
||||
/// whitespace (`my dir`). Self-validating — applies only while the path matches.
|
||||
|
|
@ -63,7 +94,9 @@ impl FileSearchState {
|
|||
pub fn new(root: &Path) -> Self {
|
||||
Self {
|
||||
root: root.to_owned(),
|
||||
daemon: FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K),
|
||||
daemon: None,
|
||||
#[cfg(test)]
|
||||
daemon_builds: 0,
|
||||
results: FuzzyMatcherDaemonResults::default(),
|
||||
context: None,
|
||||
selected: 0,
|
||||
|
|
@ -74,19 +107,11 @@ impl FileSearchState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Replace the underlying matcher with a new one rooted at `root`.
|
||||
/// Point @-completion at a new tree (e.g. after worktree creation).
|
||||
///
|
||||
/// Used after worktree creation to point @-completion at the new tree.
|
||||
/// Drops any built daemon; the next @-use rebuilds it lazily against `root`.
|
||||
pub fn retarget(&mut self, root: &Path) {
|
||||
self.root = root.to_owned();
|
||||
self.daemon = FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K);
|
||||
self.results = FuzzyMatcherDaemonResults::default();
|
||||
self.context = None;
|
||||
self.selected = 0;
|
||||
self.hovered = None;
|
||||
self.scroll_offset = 0;
|
||||
self.min_generation = 0;
|
||||
self.drill_prefix = None;
|
||||
*self = Self::new(root);
|
||||
}
|
||||
|
||||
/// The directory the matcher currently walks (the `@`-completion root).
|
||||
|
|
@ -94,6 +119,41 @@ impl FileSearchState {
|
|||
&self.root
|
||||
}
|
||||
|
||||
/// The fuzzy matcher daemon, built lazily on first use.
|
||||
///
|
||||
/// The first `@`-keystroke pays a one-time cost on the UI thread: building
|
||||
/// the daemon spawns the nucleo matcher pool and the directory walker.
|
||||
fn ensure_daemon(&mut self) -> &mut FuzzyFileMatcherDaemon {
|
||||
if self.daemon.is_none() {
|
||||
let daemon =
|
||||
FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(&self.root), MATCHER_TOP_K);
|
||||
self.daemon = Some(daemon);
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.daemon_builds += 1;
|
||||
}
|
||||
}
|
||||
self.daemon.as_mut().expect("daemon built above")
|
||||
}
|
||||
|
||||
/// Point the daemon (building it if needed) at `query`, optionally restarting
|
||||
/// the directory walk, then reset dropdown selection and scroll.
|
||||
///
|
||||
/// The matcher never filters to directories only: a trailing `/` scopes the
|
||||
/// query to a folder without hiding that folder's files.
|
||||
fn start_query(&mut self, restart: RestartWalk, query: &str) {
|
||||
let daemon = self.ensure_daemon();
|
||||
if let RestartWalk::Restart { hidden } = restart {
|
||||
daemon.restart_walk(hidden);
|
||||
}
|
||||
daemon.set_query(query, false);
|
||||
// Advance the stale-result fence past the prior query (see `min_generation`).
|
||||
self.min_generation += 1;
|
||||
self.selected = 0;
|
||||
self.hovered = None;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
// ── Visibility ──────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the dropdown should be visible.
|
||||
|
|
@ -128,13 +188,7 @@ impl FileSearchState {
|
|||
|
||||
/// Set the hovered index. Returns `true` if changed.
|
||||
pub fn set_hovered(&mut self, index: Option<usize>) -> bool {
|
||||
let clamped = index.and_then(|i| {
|
||||
if i < self.results.topk.len() {
|
||||
Some(i)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let clamped = index.filter(|&i| i < self.results.topk.len());
|
||||
let changed = clamped != self.hovered;
|
||||
self.hovered = clamped;
|
||||
changed
|
||||
|
|
@ -162,15 +216,13 @@ impl FileSearchState {
|
|||
(None, Some(ctx)) => {
|
||||
// Fresh `@` token is never a drill — drop any stale anchor.
|
||||
self.drill_prefix = None;
|
||||
// Entering @-mode: restart the directory walk.
|
||||
self.daemon.restart_walk(ctx.is_hidden_mode());
|
||||
// A trailing `/` scopes the query to a folder; it must not hide
|
||||
// that folder's files, so never filter to directories only.
|
||||
self.daemon.set_query(ctx.matcher_query(), false);
|
||||
self.min_generation += 1;
|
||||
self.selected = 0;
|
||||
self.hovered = None;
|
||||
self.scroll_offset = 0;
|
||||
// Entering @-mode always restarts the walk.
|
||||
self.start_query(
|
||||
RestartWalk::Restart {
|
||||
hidden: ctx.is_hidden_mode(),
|
||||
},
|
||||
ctx.matcher_query(),
|
||||
);
|
||||
}
|
||||
(Some(old), Some(new)) => {
|
||||
// Drop a stale anchor once the @-token's path content no longer
|
||||
|
|
@ -184,17 +236,15 @@ impl FileSearchState {
|
|||
if anchor_stale {
|
||||
self.drill_prefix = None;
|
||||
}
|
||||
// Staying in @-mode: check if hidden mode toggled (needs re-walk).
|
||||
if old.is_hidden_mode() != new.is_hidden_mode() {
|
||||
self.daemon.restart_walk(new.is_hidden_mode());
|
||||
}
|
||||
self.daemon.set_query(new.matcher_query(), false);
|
||||
self.min_generation += 1;
|
||||
// Reset selection when query changes to avoid showing stale
|
||||
// matches from an obscure position in the list.
|
||||
self.selected = 0;
|
||||
self.hovered = None;
|
||||
self.scroll_offset = 0;
|
||||
// Staying in @-mode only re-walks when hidden mode toggled.
|
||||
let restart = if old.is_hidden_mode() != new.is_hidden_mode() {
|
||||
RestartWalk::Restart {
|
||||
hidden: new.is_hidden_mode(),
|
||||
}
|
||||
} else {
|
||||
RestartWalk::Keep
|
||||
};
|
||||
self.start_query(restart, new.matcher_query());
|
||||
}
|
||||
(Some(_), None) => {
|
||||
// Leaving @-mode: clear results and the drill anchor.
|
||||
|
|
@ -207,6 +257,9 @@ impl FileSearchState {
|
|||
}
|
||||
|
||||
self.context = new_ctx;
|
||||
// Both @-mode arms build the daemon via `start_query`, so an active
|
||||
// context implies a built daemon.
|
||||
debug_assert!(self.context.is_none() || self.daemon.is_some());
|
||||
}
|
||||
|
||||
/// Clear the context (e.g., on Esc).
|
||||
|
|
@ -226,9 +279,12 @@ impl FileSearchState {
|
|||
return false;
|
||||
}
|
||||
|
||||
let results = self.daemon.get();
|
||||
// Never build the daemon on the poll path: no daemon means no results yet.
|
||||
let Some(daemon) = self.daemon.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let results = daemon.get();
|
||||
|
||||
// Check if results actually changed (pointer comparison on Arc).
|
||||
if Arc::ptr_eq(&results.topk, &self.results.topk) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -239,7 +295,6 @@ impl FileSearchState {
|
|||
if results.generation >= self.min_generation {
|
||||
self.min_generation = results.generation;
|
||||
self.results = results;
|
||||
// Clamp selection to new result count.
|
||||
if !self.results.topk.is_empty() {
|
||||
self.selected = self.selected.min(self.results.topk.len() - 1);
|
||||
}
|
||||
|
|
@ -300,55 +355,51 @@ impl FileSearchState {
|
|||
self.results.topk.get(self.selected)
|
||||
}
|
||||
|
||||
/// Compute the text replacement for accepting the currently selected result.
|
||||
/// Compute the text replacement for accepting the currently selected
|
||||
/// directory (drill-down acceptance).
|
||||
///
|
||||
/// The `src` parameter is the full prompt text (needed to detect edge cases
|
||||
/// like "replacement is a no-op" for directory drill-down).
|
||||
pub fn try_replace(&mut self, src: &str) -> Option<FileSearchReplacement> {
|
||||
/// Pure query. `dismiss` reports whether the caller should clear the
|
||||
/// context: a directory whose `/`-append matches text already present is
|
||||
/// committed (dismiss), otherwise the caller drills in and stays open. The
|
||||
/// `src` parameter is the full prompt text, needed to detect that no-op
|
||||
/// `/`-append.
|
||||
pub fn try_replace(&self, src: &str) -> Option<FileSearchReplacement> {
|
||||
let ctx = self.context.as_ref()?;
|
||||
let res = self.results.topk.get(self.selected)?;
|
||||
|
||||
let path_str = res.path.to_string();
|
||||
let mut text = normalize_display_path(&path_str).to_owned();
|
||||
|
||||
// Replace only the path portion of the @-token (preserving `@`
|
||||
// and any hidden-mode `!` marker). See `AtContext::path_range`.
|
||||
let range = ctx.path_range();
|
||||
|
||||
let mut cursor = range.start + text.len() + 1;
|
||||
let dismiss;
|
||||
|
||||
if ctx.is_dir_mode() {
|
||||
// Directory mode: append `/` and stay in completion for drill-down.
|
||||
text = format!("{text}/");
|
||||
if range.end <= src.len() && src[range.clone()] == text[..] {
|
||||
// No-op replacement (same text already there) — treat as "done".
|
||||
cursor += 1;
|
||||
if range.end == src.len() {
|
||||
text = format!("{text} ");
|
||||
}
|
||||
dismiss = true;
|
||||
} else {
|
||||
dismiss = false; // Stay in completion mode (drill-down).
|
||||
}
|
||||
} else {
|
||||
// File mode: append trailing space if at end of input.
|
||||
if range.end == src.len() {
|
||||
text = format!("{text} ");
|
||||
}
|
||||
dismiss = true;
|
||||
// Dir-only contract: this always appends `/`, so it is valid only for a
|
||||
// directory chosen in dir mode. Enforce it here so a file-selection
|
||||
// caller can never emit `some/file.rs/`.
|
||||
if !res.is_dir || !ctx.is_dir_mode() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if dismiss {
|
||||
self.context = None;
|
||||
self.drill_prefix = None;
|
||||
// Replace only the path portion of the @-token (preserving `@` and any
|
||||
// hidden-mode `!` marker). See `AtContext::path_range`.
|
||||
let range = ctx.path_range();
|
||||
let path = normalize_display_path(&res.path.to_string()).to_owned();
|
||||
let at_end = range.end == src.len();
|
||||
|
||||
// A `/`-append that matches text already present commits the dir and
|
||||
// dismisses; otherwise it drills in and stays open.
|
||||
let no_op = src.get(range.clone()) == Some(accept_text(&path, false).as_str());
|
||||
let text = accept_text(&path, no_op && at_end);
|
||||
|
||||
// Cursor sits just past the emitted text (after the trailing `/`).
|
||||
let mut cursor = range.start + text.len();
|
||||
// A committed dir that is not at the prompt end keeps its existing
|
||||
// terminator (whitespace, `,`, or `;`, possibly multibyte; see
|
||||
// `context::detect`); step past that one char so typing resumes after
|
||||
// the directory.
|
||||
if no_op && !at_end {
|
||||
cursor += src[range.end..].chars().next().map_or(1, char::len_utf8);
|
||||
}
|
||||
|
||||
Some(FileSearchReplacement {
|
||||
range,
|
||||
text,
|
||||
range,
|
||||
cursor,
|
||||
dismiss,
|
||||
dismiss: no_op,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -365,14 +416,8 @@ impl FileSearchState {
|
|||
/// Test-only: install a fake context + results snapshot so tests can drive
|
||||
/// acceptance flows without spinning up the background fuzzy daemon.
|
||||
///
|
||||
/// **Mixing with daemon polling is unsupported.** This helper assigns
|
||||
/// `generation = self.min_generation` without bumping `min_generation`,
|
||||
/// which means a real daemon poll occurring after `set_test_state` could
|
||||
/// deliver same-generation results that overwrite the seeded fake state
|
||||
/// non-deterministically. Tests that use this helper must not also drive
|
||||
/// real daemon polls; if a future test needs both, bump
|
||||
/// `self.min_generation` here so any in-flight daemon results are
|
||||
/// rejected.
|
||||
/// Bumps `min_generation` past the seeded generation so any in-flight real
|
||||
/// daemon poll is rejected and cannot clobber the seeded state.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_state(
|
||||
&mut self,
|
||||
|
|
@ -387,6 +432,113 @@ impl FileSearchState {
|
|||
status: Default::default(),
|
||||
generation: self.min_generation,
|
||||
};
|
||||
self.min_generation += 1;
|
||||
self.selected = selected;
|
||||
}
|
||||
|
||||
/// Test-only observable state: whether the lazy daemon has been built yet.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn daemon_is_built(&self) -> bool {
|
||||
self.daemon.is_some()
|
||||
}
|
||||
|
||||
/// Test-only observable state: how many times the lazy daemon has been built.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn daemon_build_count(&self) -> usize {
|
||||
self.daemon_builds
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir_result(path: &str) -> FuzzyMatchResult {
|
||||
FuzzyMatchResult {
|
||||
path: nucleo::Utf32String::from(path),
|
||||
is_dir: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_replace_commits_directory_already_present() {
|
||||
// The selected dir's `/`-append already matches the token text, so
|
||||
// acceptance commits (dismiss) rather than drilling.
|
||||
let mut state = FileSearchState::new(Path::new("."));
|
||||
|
||||
// At the prompt end: append a trailing space so typing can continue.
|
||||
let src = "@src/";
|
||||
let ctx = context::detect(src, src.len()).expect("context");
|
||||
state.set_test_state(ctx, vec![dir_result("src")], 0);
|
||||
let r = state.try_replace(src).expect("replacement");
|
||||
assert!(r.dismiss);
|
||||
assert_eq!(r.range, 1..5);
|
||||
assert_eq!(r.text, "src/ ");
|
||||
assert_eq!(r.cursor, "@src/ ".len());
|
||||
|
||||
// Mid-prompt: no appended space; step past the existing terminator.
|
||||
let src = "@src/ tail";
|
||||
let ctx = context::detect(src, 5).expect("context");
|
||||
state.set_test_state(ctx, vec![dir_result("src")], 0);
|
||||
let r = state.try_replace(src).expect("replacement");
|
||||
assert!(r.dismiss);
|
||||
assert_eq!(r.text, "src/");
|
||||
assert_eq!(r.cursor, 6);
|
||||
|
||||
// Mid-prompt with a multibyte terminator: step past the whole char.
|
||||
let src = "@src/\u{a0}tail";
|
||||
let ctx = context::detect(src, 5).expect("context");
|
||||
state.set_test_state(ctx, vec![dir_result("src")], 0);
|
||||
let r = state.try_replace(src).expect("replacement");
|
||||
assert!(r.dismiss);
|
||||
assert_eq!(r.text, "src/");
|
||||
assert_eq!(r.cursor, 5 + '\u{a0}'.len_utf8());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retarget_drops_built_daemon() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let mut state = FileSearchState::new(dir.path());
|
||||
state.update_context("@alpha", "@alpha".len());
|
||||
assert!(state.daemon_is_built());
|
||||
|
||||
state.retarget(Path::new(".."));
|
||||
assert_eq!(state.root(), Path::new(".."));
|
||||
assert!(!state.daemon_is_built());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_does_not_build_daemon() {
|
||||
let mut state = FileSearchState::new(Path::new("."));
|
||||
// With no @-context, poll returns early and never touches the daemon.
|
||||
assert!(!state.poll());
|
||||
assert!(!state.daemon_is_built());
|
||||
|
||||
// With an @-context but an unbuilt daemon, poll must not force construction.
|
||||
let ctx = context::detect("@foo", 4).expect("context");
|
||||
state.set_test_state(ctx, Vec::new(), 0);
|
||||
assert!(state.context().is_some());
|
||||
assert!(!state.poll());
|
||||
assert!(!state.daemon_is_built());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_is_built_lazily_on_first_use() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let mut state = FileSearchState::new(dir.path());
|
||||
assert!(!state.daemon_is_built());
|
||||
|
||||
// The first @-search interaction builds the daemon lazily.
|
||||
state.update_context("@alpha", "@alpha".len());
|
||||
assert!(state.daemon_is_built());
|
||||
assert!(state.context().is_some());
|
||||
|
||||
// A query edit stays in @-mode and reuses the same daemon: the build
|
||||
// count stays at 1, proving no drop-and-rebuild.
|
||||
assert_eq!(state.daemon_build_count(), 1);
|
||||
state.update_context("@alpha_marker", "@alpha_marker".len());
|
||||
assert!(state.daemon_is_built());
|
||||
assert_eq!(state.daemon_build_count(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,161 +9,418 @@ use ratatui::style::{Modifier, Style};
|
|||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Widget};
|
||||
|
||||
/// Legal line copy — used for both render spans and mouse hit width.
|
||||
const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy.";
|
||||
/// Shares its row with the buttons.
|
||||
const PRIVACY_BANNER_TITLE: &str = "Help improve Grok";
|
||||
|
||||
/// Click target for the legal line links.
|
||||
pub(crate) const PRIVACY_BANNER_LEGAL_URL: &str = "https://x.ai/legal";
|
||||
const PRIVACY_BANNER_DESC: &str = "Off by default. Opt-in to allow SpaceXAI to retain coding \
|
||||
data, e.g., prompts, traces, & metrics, for training and debugging purposes. Change \
|
||||
anytime via settings.";
|
||||
|
||||
pub(crate) const PRIVACY_BANNER_TERMS_URL: &str = "https://x.ai/legal/terms-of-service";
|
||||
pub(crate) const PRIVACY_BANNER_POLICY_URL: &str = "https://x.ai/legal/privacy-policy";
|
||||
|
||||
/// `(text, url_when_link)`.
|
||||
type LegalSegment = (&'static str, Option<&'static str>);
|
||||
|
||||
/// Widest first; the first that fits *whole* wins. A clipped line would
|
||||
/// leave hit rects over unreadable link text, and every variant keeps both
|
||||
/// links so neither document becomes unreachable.
|
||||
const PRIVACY_BANNER_LEGAL_VARIANTS: [&[LegalSegment]; 3] = [
|
||||
&[
|
||||
("Read ", None),
|
||||
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
|
||||
(" and ", None),
|
||||
("Privacy Policy", Some(PRIVACY_BANNER_POLICY_URL)),
|
||||
(".", None),
|
||||
],
|
||||
&[
|
||||
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
|
||||
(" and ", None),
|
||||
("Privacy Policy", Some(PRIVACY_BANNER_POLICY_URL)),
|
||||
],
|
||||
&[
|
||||
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
|
||||
(" & ", None),
|
||||
("Privacy", Some(PRIVACY_BANNER_POLICY_URL)),
|
||||
],
|
||||
];
|
||||
|
||||
const OPT_OUT_LABEL: &str = "[Opt out]";
|
||||
const OPT_IN_LABEL: &str = "[Opt in]";
|
||||
|
||||
/// Title + legal.
|
||||
const CHROME_ROWS: u16 = 2;
|
||||
|
||||
pub(crate) const MIN_HEIGHT: u16 = CHROME_ROWS + 1;
|
||||
|
||||
/// Caps banner growth on narrow terminals; overflow is elided with `…` so
|
||||
/// the disclosure never looks complete when it isn't.
|
||||
const MAX_BODY_ROWS: usize = 4;
|
||||
|
||||
/// Past this, the body abandons the button column for the full slot width:
|
||||
/// a shorter banner beats a tidy right edge.
|
||||
const PREFERRED_BODY_ROWS: usize = 3;
|
||||
|
||||
/// Hit rects returned by [`render`] for mouse handling.
|
||||
pub(crate) struct PrivacyBannerRects {
|
||||
pub accept: Rect,
|
||||
pub customize: Rect,
|
||||
pub legal: Rect,
|
||||
pub opt_in: Rect,
|
||||
pub opt_out: Rect,
|
||||
pub terms: Rect,
|
||||
pub policy: Rect,
|
||||
}
|
||||
|
||||
/// Render the banner: copy left, `[Customize in settings]` / `[Accept]`
|
||||
/// right, legal links on the second row. Needs `area.height >= 2`.
|
||||
/// Hover styling mirrors the plugin CTA buttons.
|
||||
impl PrivacyBannerRects {
|
||||
fn none() -> Self {
|
||||
Self {
|
||||
opt_in: Rect::default(),
|
||||
opt_out: Rect::default(),
|
||||
terms: Rect::default(),
|
||||
policy: Rect::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn button_block_width() -> u16 {
|
||||
(OPT_OUT_LABEL.len() + 1 + OPT_IN_LABEL.len()) as u16
|
||||
}
|
||||
|
||||
fn legal_width(variant: &[LegalSegment]) -> u16 {
|
||||
variant.iter().map(|(text, _)| text.len() as u16).sum()
|
||||
}
|
||||
|
||||
/// Buttons render whole or not at all, and never at the cost of the title:
|
||||
/// a clipped/overflowing `[Opt in]` must not leave a click target in the
|
||||
/// blank margin (a stray click there would silently opt the user in).
|
||||
fn buttons_fit(area_width: u16) -> bool {
|
||||
area_width >= PRIVACY_BANNER_TITLE.len() as u16 + 1 + button_block_width()
|
||||
}
|
||||
|
||||
fn title_width(area_width: u16) -> u16 {
|
||||
if buttons_fit(area_width) {
|
||||
area_width - button_block_width() - 1
|
||||
} else {
|
||||
area_width
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_to(width: usize) -> Vec<std::borrow::Cow<'static, str>> {
|
||||
if width == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let opts = textwrap::Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
|
||||
textwrap::wrap(PRIVACY_BANNER_DESC, opts)
|
||||
}
|
||||
|
||||
fn body_lines(area_width: u16) -> Vec<std::borrow::Cow<'static, str>> {
|
||||
let column = wrap_to(title_width(area_width) as usize);
|
||||
let mut lines = if column.len() <= PREFERRED_BODY_ROWS {
|
||||
column
|
||||
} else {
|
||||
let full = wrap_to(area_width as usize);
|
||||
if full.len() < column.len() {
|
||||
full
|
||||
} else {
|
||||
column
|
||||
}
|
||||
};
|
||||
if lines.len() > MAX_BODY_ROWS {
|
||||
lines.truncate(MAX_BODY_ROWS);
|
||||
if let Some(last) = lines.last_mut() {
|
||||
let mut s = last.trim_end().to_string();
|
||||
while s.chars().count() + 1 > area_width as usize {
|
||||
s.pop();
|
||||
}
|
||||
s.push('\u{2026}');
|
||||
*last = std::borrow::Cow::Owned(s);
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Rows needed at `width` — the body wraps, so both slot owners must size
|
||||
/// from this rather than a constant.
|
||||
pub(crate) fn height(width: u16) -> u16 {
|
||||
CHROME_ROWS + (body_lines(width).len() as u16).max(1)
|
||||
}
|
||||
|
||||
/// Needs `area.height >= MIN_HEIGHT`; give it [`height`] rows for the full
|
||||
/// body.
|
||||
pub(crate) fn render(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
) -> PrivacyBannerRects {
|
||||
let customize_label = "[Customize in settings]";
|
||||
let accept_label = "[Accept]";
|
||||
let right_w = (customize_label.len() + 1 + accept_label.len()) as u16;
|
||||
// Buttons render whole or not at all: a clipped/overflowing [Accept]
|
||||
// must never leave a click target in the blank margin (a stray click
|
||||
// there would silently opt the user in).
|
||||
let buttons_fit = area.width > right_w;
|
||||
let left_w = if buttons_fit {
|
||||
area.width - right_w - 1
|
||||
} else {
|
||||
area.width
|
||||
};
|
||||
|
||||
let left = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: left_w,
|
||||
height: area.height.min(2),
|
||||
};
|
||||
let right = Rect {
|
||||
x: area.x + left_w + 1,
|
||||
y: area.y,
|
||||
width: right_w,
|
||||
height: 1,
|
||||
};
|
||||
if area.height < MIN_HEIGHT || area.width == 0 {
|
||||
return PrivacyBannerRects::none();
|
||||
}
|
||||
|
||||
let hovered = |r: Rect| {
|
||||
mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my)))
|
||||
};
|
||||
|
||||
let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
|
||||
PRIVACY_BANNER_LEGAL.len()
|
||||
} else {
|
||||
"Learn more".len().min(left.width as usize)
|
||||
};
|
||||
// The legal line only exists when the slot really has a second row —
|
||||
// otherwise its rect would make the blank row below clickable.
|
||||
let legal_rect = if area.height >= 2 {
|
||||
// Figma node 8698:3806.
|
||||
buf.set_stringn(
|
||||
area.x,
|
||||
area.y,
|
||||
PRIVACY_BANNER_TITLE,
|
||||
title_width(area.width) as usize,
|
||||
Style::default().fg(theme.text_primary),
|
||||
);
|
||||
|
||||
let body_style = Style::default().fg(theme.gray_bright);
|
||||
let body_rows = area.height - CHROME_ROWS;
|
||||
let body: Vec<Line> = body_lines(area.width)
|
||||
.into_iter()
|
||||
.take(body_rows as usize)
|
||||
.map(|l| Line::styled(l.into_owned(), body_style))
|
||||
.collect();
|
||||
Paragraph::new(body).render(
|
||||
Rect {
|
||||
x: left.x,
|
||||
y: left.y.saturating_add(1),
|
||||
width: legal_w as u16,
|
||||
height: 1,
|
||||
}
|
||||
} else {
|
||||
Rect::default()
|
||||
};
|
||||
x: area.x,
|
||||
y: area.y + 1,
|
||||
width: area.width,
|
||||
height: body_rows,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
|
||||
// Figma node 8698:3806: title fg/primary, description fg/secondary,
|
||||
// legal line fg/tertiary with underlined links in the same color.
|
||||
// The whole legal line is one click target, so its links brighten together.
|
||||
let link_fg = if hovered(legal_rect) {
|
||||
theme.gray_bright
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
let link = Style::default()
|
||||
.fg(link_fg)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
// Last row, so it gets the full width — no buttons to dodge.
|
||||
let gray = Style::default().fg(theme.gray);
|
||||
let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary));
|
||||
let desc = "Allow your sessions to improve SpaceXAI's models.";
|
||||
// Drop trailing spans whole rather than clipping mid-word when narrow.
|
||||
let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() {
|
||||
Line::from(vec![
|
||||
title,
|
||||
Span::raw(" "),
|
||||
Span::styled(desc, Style::default().fg(theme.gray_bright)),
|
||||
])
|
||||
} else {
|
||||
Line::from(title)
|
||||
};
|
||||
// Span pieces must reassemble to PRIVACY_BANNER_LEGAL.
|
||||
let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
|
||||
Line::from(vec![
|
||||
Span::styled("Learn more", link),
|
||||
Span::styled(" and read ", gray),
|
||||
Span::styled("Terms", link),
|
||||
Span::styled(" and ", gray),
|
||||
Span::styled("Privacy Policy", link),
|
||||
Span::styled(".", gray),
|
||||
])
|
||||
} else {
|
||||
Line::from(Span::styled("Learn more", link))
|
||||
};
|
||||
Paragraph::new(vec![line1, line2]).render(left, buf);
|
||||
let legal_y = area.y + area.height - 1;
|
||||
let mut terms_rect = Rect::default();
|
||||
let mut policy_rect = Rect::default();
|
||||
if let Some(variant) = PRIVACY_BANNER_LEGAL_VARIANTS
|
||||
.into_iter()
|
||||
.find(|v| legal_width(v) <= area.width)
|
||||
{
|
||||
let mut x = area.x;
|
||||
let mut spans = Vec::with_capacity(variant.len());
|
||||
for (text, url) in variant {
|
||||
let w = text.len() as u16;
|
||||
let style = match url {
|
||||
None => gray,
|
||||
Some(url) => {
|
||||
let rect = Rect {
|
||||
x,
|
||||
y: legal_y,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
if *url == PRIVACY_BANNER_TERMS_URL {
|
||||
terms_rect = rect;
|
||||
} else {
|
||||
policy_rect = rect;
|
||||
}
|
||||
let fg = if hovered(rect) {
|
||||
theme.gray_bright
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
Style::default().fg(fg).add_modifier(Modifier::UNDERLINED)
|
||||
}
|
||||
};
|
||||
spans.push(Span::styled(*text, style));
|
||||
x += w;
|
||||
}
|
||||
Paragraph::new(Line::from(spans)).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: legal_y,
|
||||
width: x - area.x,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
if !buttons_fit {
|
||||
if !buttons_fit(area.width) {
|
||||
return PrivacyBannerRects {
|
||||
accept: Rect::default(),
|
||||
customize: Rect::default(),
|
||||
legal: legal_rect,
|
||||
opt_in: Rect::default(),
|
||||
opt_out: Rect::default(),
|
||||
terms: terms_rect,
|
||||
policy: policy_rect,
|
||||
};
|
||||
}
|
||||
let customize_rect = Rect {
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
width: customize_label.len() as u16,
|
||||
let opt_out_rect = Rect {
|
||||
x: area.x + area.width - button_block_width(),
|
||||
y: area.y,
|
||||
width: OPT_OUT_LABEL.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
let accept_rect = Rect {
|
||||
x: right.x + customize_label.len() as u16 + 1,
|
||||
y: right.y,
|
||||
width: accept_label.len() as u16,
|
||||
let opt_in_rect = Rect {
|
||||
x: opt_out_rect.x + opt_out_rect.width + 1,
|
||||
y: area.y,
|
||||
width: OPT_IN_LABEL.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
let customize_style = if hovered(customize_rect) {
|
||||
let opt_out_style = if hovered(opt_out_rect) {
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_hover)
|
||||
} else {
|
||||
Style::default().fg(theme.gray_bright)
|
||||
};
|
||||
let accept_style = if hovered(accept_rect) {
|
||||
let opt_in_style = if hovered(opt_in_rect) {
|
||||
Style::default().fg(theme.link_fg).bg(theme.bg_hover)
|
||||
} else {
|
||||
Style::default().fg(theme.text_primary)
|
||||
};
|
||||
buf.set_stringn(
|
||||
customize_rect.x,
|
||||
customize_rect.y,
|
||||
customize_label,
|
||||
customize_rect.width as usize,
|
||||
customize_style,
|
||||
opt_out_rect.x,
|
||||
opt_out_rect.y,
|
||||
OPT_OUT_LABEL,
|
||||
opt_out_rect.width as usize,
|
||||
opt_out_style,
|
||||
);
|
||||
buf.set_stringn(
|
||||
accept_rect.x,
|
||||
accept_rect.y,
|
||||
accept_label,
|
||||
accept_rect.width as usize,
|
||||
accept_style,
|
||||
opt_in_rect.x,
|
||||
opt_in_rect.y,
|
||||
OPT_IN_LABEL,
|
||||
opt_in_rect.width as usize,
|
||||
opt_in_style,
|
||||
);
|
||||
PrivacyBannerRects {
|
||||
accept: accept_rect,
|
||||
customize: customize_rect,
|
||||
legal: legal_rect,
|
||||
opt_in: opt_in_rect,
|
||||
opt_out: opt_out_rect,
|
||||
terms: terms_rect,
|
||||
policy: policy_rect,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Render at `width` into a buffer sized by [`height`], returning the
|
||||
/// rows (trailing blanks trimmed) and the hit rects.
|
||||
fn draw(width: u16) -> (Vec<String>, PrivacyBannerRects) {
|
||||
let h = height(width);
|
||||
let area = Rect::new(0, 0, width, h);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let rects = render(area, &mut buf, &Theme::current(), None);
|
||||
let rows = (0..h)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
(rows, rects)
|
||||
}
|
||||
|
||||
fn rows(width: u16) -> Vec<String> {
|
||||
draw(width).0
|
||||
}
|
||||
|
||||
/// The text a legal variant reassembles to.
|
||||
fn legal_text(variant: &[LegalSegment]) -> String {
|
||||
variant.iter().map(|(text, _)| *text).collect()
|
||||
}
|
||||
|
||||
/// The buffer text under `rect` on its row.
|
||||
fn text_at(rows: &[String], rect: Rect) -> String {
|
||||
let row = &rows[rect.y as usize];
|
||||
row.chars()
|
||||
.skip(rect.x as usize)
|
||||
.take(rect.width as usize)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Slot owners reserve [`height`] rows, so the last one it promises must
|
||||
/// be the legal line — not a body row pushed off the end.
|
||||
#[test]
|
||||
fn height_reserves_every_row_the_banner_paints() {
|
||||
for width in [200, 117, 110, 100, 80, 72, 60, 45, 40, 36, 30, 24, 18] {
|
||||
let rows = rows(width);
|
||||
assert_eq!(rows.len(), height(width) as usize);
|
||||
assert!(
|
||||
rows[0].starts_with(PRIVACY_BANNER_TITLE),
|
||||
"width {width}: title must never be clipped, got {:?}",
|
||||
rows[0]
|
||||
);
|
||||
let legal = rows.last().expect("legal row");
|
||||
assert!(
|
||||
PRIVACY_BANNER_LEGAL_VARIANTS
|
||||
.iter()
|
||||
.any(|v| legal_text(v) == *legal),
|
||||
"width {width}: legal line must survive whole, got {legal:?}"
|
||||
);
|
||||
assert!(
|
||||
rows[1..rows.len() - 1].iter().all(|r| !r.is_empty()),
|
||||
"width {width}: body rows must not be blank: {rows:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The row cap's elision is a narrow-terminal fallback, not the norm.
|
||||
#[test]
|
||||
fn body_copy_is_complete_at_common_widths() {
|
||||
for width in [200, 117, 100, 80, 60] {
|
||||
let body = rows(width)[1..].join(" ");
|
||||
let flattened: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(
|
||||
flattened.contains(PRIVACY_BANNER_DESC),
|
||||
"width {width}: body copy was truncated: {flattened:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buttons_drop_whole_when_the_row_is_too_narrow() {
|
||||
let width = PRIVACY_BANNER_TITLE.len() as u16 + button_block_width(); // one short
|
||||
let h = height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, h));
|
||||
let rects = render(Rect::new(0, 0, width, h), &mut buf, &Theme::current(), None);
|
||||
assert_eq!(rects.opt_in, Rect::default());
|
||||
assert_eq!(rects.opt_out, Rect::default());
|
||||
assert_ne!(rects.terms, Rect::default(), "terms link still clickable");
|
||||
assert_ne!(rects.policy, Rect::default(), "policy link still clickable");
|
||||
|
||||
let rects = {
|
||||
let width = width + 1;
|
||||
let h = height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, h));
|
||||
render(Rect::new(0, 0, width, h), &mut buf, &Theme::current(), None)
|
||||
};
|
||||
assert_eq!(rects.opt_out.width, OPT_OUT_LABEL.len() as u16);
|
||||
assert_eq!(rects.opt_in.width, OPT_IN_LABEL.len() as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_below_min_height_arms_no_hit_rects() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, MIN_HEIGHT));
|
||||
let rects = render(
|
||||
Rect::new(0, 0, 100, MIN_HEIGHT - 1),
|
||||
&mut buf,
|
||||
&Theme::current(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(rects.opt_in, Rect::default());
|
||||
assert_eq!(rects.opt_out, Rect::default());
|
||||
assert_eq!(rects.terms, Rect::default());
|
||||
assert_eq!(rects.policy, Rect::default());
|
||||
}
|
||||
|
||||
/// The two links open different documents, so an off-by-one rect sends
|
||||
/// the user to the wrong page.
|
||||
#[test]
|
||||
fn each_legal_link_hits_its_own_words() {
|
||||
for width in [200, 117, 80, 60, 40, 30, 24, 18] {
|
||||
let (rows, rects) = draw(width);
|
||||
assert_eq!(
|
||||
text_at(&rows, rects.terms),
|
||||
"Terms",
|
||||
"width {width}: terms rect is off its word: {rows:?}"
|
||||
);
|
||||
let policy = text_at(&rows, rects.policy);
|
||||
assert!(
|
||||
policy == "Privacy Policy" || policy == "Privacy",
|
||||
"width {width}: policy rect is off its word, got {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
rects.terms.right() <= rects.policy.x,
|
||||
"width {width}: link rects must not overlap"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2016,10 +2016,11 @@ impl PromptWidget {
|
|||
// try_replace appends `/` and stays open. A file selected in dir-mode
|
||||
// falls through to the ref branch (see FileSearchState::try_replace).
|
||||
if let Some(r) = self.file_search.try_replace(self.textarea.text()) {
|
||||
let dismiss = r.dismiss;
|
||||
self.textarea.replace_range(r.range, &r.text);
|
||||
self.textarea.set_cursor(r.cursor);
|
||||
if !dismiss {
|
||||
if r.dismiss {
|
||||
self.file_search.clear_context();
|
||||
} else {
|
||||
// Anchor the drilled child so a whitespace name stays open
|
||||
// (reuse the buffer; only a `./` prefix re-allocs).
|
||||
let mut p = res.path.to_string();
|
||||
|
|
|
|||
|
|
@ -179,8 +179,12 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin
|
|||
SettingsKeyOutcome::Changed
|
||||
}
|
||||
// `d` reset: close picker, revert preview if applicable,
|
||||
// then open the reset-confirm overlay.
|
||||
KeyCode::Char('d') if key.modifiers.is_empty() => {
|
||||
// then open the reset-confirm overlay. Consent choosers opt out of
|
||||
// this entirely (no footer hint, no hidden shortcut) — reset stays
|
||||
// reachable from the browse row.
|
||||
KeyCode::Char('d')
|
||||
if key.modifiers.is_empty() && !crate::settings::is_consent_chooser(setting_key) =>
|
||||
{
|
||||
state.transition_to_browse();
|
||||
if supports_preview
|
||||
&& let SettingValue::Enum(orig) = &original_value
|
||||
|
|
@ -732,6 +736,14 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO
|
|||
Some((_, meta)) if matches!(meta.kind, SettingKind::Group { .. }) => {
|
||||
SettingsKeyOutcome::Unchanged
|
||||
}
|
||||
// A locked row isn't the user's to change, by `d` any more
|
||||
// than by Enter (which `try_enter_picking_enum` refuses).
|
||||
// The dispatch-time guard would catch it either way, but
|
||||
// only after walking the user through a confirm dialog for
|
||||
// a change that cannot happen.
|
||||
Some((key, _meta)) if state.row_lock(key).is_some() => {
|
||||
SettingsKeyOutcome::Unchanged
|
||||
}
|
||||
Some((key, _meta)) => SettingsKeyOutcome::Action(Action::OpenResetConfirm { key }),
|
||||
// Focused row is a header (or out-of-bounds) — `d`
|
||||
// has nothing to reset. Unchanged so the user can
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use unicode_width::UnicodeWidthStr;
|
|||
use super::state::{
|
||||
CONTENT_MIN_WIDTH, MAX_THOUGHTS_WIDTH_WIDENED_MARGIN, MODAL_TITLE, RowEntry,
|
||||
STANDARD_MAX_WIDTH, SettingsModalState, SettingsMode, SettingsModeKind,
|
||||
TITLE_LEADING_DECORATION_W, effective_enum_choices, group_children,
|
||||
TITLE_LEADING_DECORATION_W, effective_enum_choices, group_children, mode_is_consent_chooser,
|
||||
};
|
||||
use crate::render::line_utils::truncate_str;
|
||||
use crate::settings::{
|
||||
|
|
@ -122,10 +122,12 @@ pub fn render_settings_modal(
|
|||
footer_lines: 2,
|
||||
}
|
||||
.with_compact(compact);
|
||||
// Must agree with the `docs_footer_area` split below — a mismatch
|
||||
// would reserve a row nothing paints (or paint into the body).
|
||||
let has_tip_footer = !matches!(
|
||||
state.state.mode_kind(),
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt
|
||||
);
|
||||
) && !mode_is_consent_chooser(&state.state.mode);
|
||||
let footer_lines = if has_tip_footer {
|
||||
modal_window::footer_lines_with_tip_gap(full_area, &sizing, shortcuts)
|
||||
} else {
|
||||
|
|
@ -170,6 +172,8 @@ pub fn render_settings_modal(
|
|||
|
||||
let (inner_area, docs_footer_area) = match state.state.mode_kind() {
|
||||
SettingsModeKind::EditingString | SettingsModeKind::EditingInt => (content_area, None),
|
||||
// A consent chooser shows the disclosure and the choices only.
|
||||
_ if mode_is_consent_chooser(&state.state.mode) => (content_area, None),
|
||||
_ => modal_window::split_content_for_tip_footer(content_area),
|
||||
};
|
||||
|
||||
|
|
@ -2719,6 +2723,12 @@ fn render_setting_group_row(
|
|||
pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'static>> {
|
||||
match &state.state.mode {
|
||||
SettingsMode::Browse => {
|
||||
// A locked row (ZDR / team-managed) accepts neither the edit keys
|
||||
// nor `d`, so it advertises neither. `→ expand` stays — that is
|
||||
// how the user reads the lock reason.
|
||||
let locked = state
|
||||
.focused_setting()
|
||||
.is_some_and(|(key, _)| state.row_lock(key).is_some());
|
||||
let enter_label = match state.focused_setting() {
|
||||
Some((_, meta)) if matches!(meta.kind, SettingKind::Bool { .. }) => "Enter toggle",
|
||||
_ => "Enter edit",
|
||||
|
|
@ -2734,16 +2744,20 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
];
|
||||
if !locked {
|
||||
shortcuts.push(Shortcut {
|
||||
label: "Space toggle",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
});
|
||||
shortcuts.push(Shortcut {
|
||||
label: enter_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
shortcuts.extend([
|
||||
Shortcut {
|
||||
label: "\u{2192} expand",
|
||||
clickable: false,
|
||||
|
|
@ -2754,17 +2768,19 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
]);
|
||||
if !locked {
|
||||
shortcuts.push(Shortcut {
|
||||
label: "d reset",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
label: "F2/Esc close",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
shortcuts.push(Shortcut {
|
||||
label: "F2/Esc close",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
});
|
||||
// Browse is nav mode (filter inactive), so append `i search` last
|
||||
// (matching the shared pickers).
|
||||
modal_window::push_vim_nav_search_hint(&mut shortcuts, false);
|
||||
|
|
@ -2799,6 +2815,7 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
],
|
||||
SettingsMode::PickingEnum {
|
||||
supports_preview: sp,
|
||||
key,
|
||||
..
|
||||
} => {
|
||||
// Labels depend on whether the Enum supports live preview.
|
||||
|
|
@ -2808,14 +2825,18 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
"\u{2191}/\u{2193} nav"
|
||||
};
|
||||
let esc_label = if *sp { "Esc revert" } else { "Esc cancel" };
|
||||
vec![
|
||||
let consent = crate::settings::is_consent_chooser(key);
|
||||
let mut shortcuts = vec![
|
||||
Shortcut {
|
||||
label: nav_label,
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
// A chooser picks one of the offered answers, so Enter
|
||||
// "selects". The filter bar and the value editors, where
|
||||
// Enter really does commit typed input, keep that wording.
|
||||
Shortcut {
|
||||
label: "Enter commit",
|
||||
label: "Enter select",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
|
|
@ -2824,12 +2845,17 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
|
|||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
Shortcut {
|
||||
];
|
||||
// Consent choosers hide reset; the key is disabled there too, so
|
||||
// this stays a description of what actually works on the pane.
|
||||
if !consent {
|
||||
shortcuts.push(Shortcut {
|
||||
label: "d reset",
|
||||
clickable: false,
|
||||
id: 0,
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
shortcuts
|
||||
}
|
||||
|
||||
SettingsMode::EditingInt { min, max, .. } => {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,14 @@ pub(super) enum SettingsMode {
|
|||
},
|
||||
}
|
||||
|
||||
/// Is the open sub-pane a [`crate::settings::is_consent_chooser`] pane?
|
||||
pub(super) fn mode_is_consent_chooser(mode: &SettingsMode) -> bool {
|
||||
matches!(
|
||||
mode,
|
||||
SettingsMode::PickingEnum { key, .. } if crate::settings::is_consent_chooser(key)
|
||||
)
|
||||
}
|
||||
|
||||
/// Settings modal state. Boxed inside `ActiveModal::Settings` to
|
||||
/// avoid clippy `large_enum_variant`.
|
||||
pub struct SettingsModalState {
|
||||
|
|
@ -819,7 +827,7 @@ pub(super) fn setting_row_visible(
|
|||
}
|
||||
|
||||
fn build_rows(registry: &SettingsRegistry) -> Vec<RowEntry> {
|
||||
let kitty_releases = crate::app::kitty_flags_pushed();
|
||||
let kitty_releases = crate::app::kitty_releases_reported();
|
||||
let minimal = crate::app::minimal_mode_active();
|
||||
let voice_mode = crate::app::voice_mode_enabled();
|
||||
// Keys that belong to a group sub-sheet are rendered only inside that
|
||||
|
|
@ -1095,7 +1103,7 @@ pub(super) fn effective_enum_choices<'a>(
|
|||
choices: &'a [EnumChoice],
|
||||
snapshot: &PagerLocalSnapshot,
|
||||
) -> Vec<&'a EnumChoice> {
|
||||
let kitty_releases = crate::app::kitty_flags_pushed();
|
||||
let kitty_releases = crate::app::kitty_releases_reported();
|
||||
choices
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
|
|
|
|||
|
|
@ -4154,14 +4154,11 @@ fn advance_next_recovers_when_selection_is_hidden() {
|
|||
#[test]
|
||||
fn advance_prev_recovers_when_selection_is_hidden() {
|
||||
let mut s = make_state();
|
||||
// Apply a filter matching only show_timestamps and simple_mode.
|
||||
// "mode" matches both: compact_mode label, simple_mode label
|
||||
// AND show_timestamps via... actually let's pick a more reliable
|
||||
// filter — use individual keywords. "simple" matches simple_mode
|
||||
// only. Let's use that and corrupt selected to compact_mode
|
||||
// (hidden). Up should land on the LAST visible setting which
|
||||
// is simple_mode.
|
||||
s.set_query("simple");
|
||||
// The filter must match exactly one setting, so the "LAST visible"
|
||||
// target is unambiguous. `ascii` is a simple_mode keyword and hits
|
||||
// nothing else (settings_e2e pins that). Corrupt `selected` to the
|
||||
// now-hidden compact_mode; Up must land on simple_mode.
|
||||
s.set_query("ascii");
|
||||
let compact_idx = s
|
||||
.rows
|
||||
.iter()
|
||||
|
|
@ -4616,8 +4613,8 @@ fn pathologically_narrow_truncates_label_with_ellipsis() {
|
|||
/// Two-line rows expand `state.row_rects` to span BOTH lines so
|
||||
/// mouse clicks on either line trigger the same default action.
|
||||
///
|
||||
/// `coding_data_sharing`: label 19 + value "Opt out" 7 + chevron
|
||||
/// 2 + chrome 4 = 32 cells one-line. We render at width=28 so
|
||||
/// `coding_data_sharing`'s label plus the value "Opt out", the chevron,
|
||||
/// and the row chrome are far wider than the width=28 we render at, so
|
||||
/// the row drops to two lines.
|
||||
#[test]
|
||||
fn two_line_row_hit_rect_spans_both_lines() {
|
||||
|
|
@ -4685,7 +4682,7 @@ fn two_line_row_hit_rect_spans_both_lines() {
|
|||
#[test]
|
||||
fn two_line_row_with_expansion_renders_three_segments() {
|
||||
let mut s = make_state();
|
||||
// Coding data sharing's label + value (with chevron) won't
|
||||
// The coding-data row's label + value (with chevron) won't
|
||||
// fit on a 28-col line, forcing two-line layout.
|
||||
let row_idx = s
|
||||
.rows
|
||||
|
|
@ -4711,11 +4708,22 @@ fn two_line_row_with_expansion_renders_three_segments() {
|
|||
"expanded two-line row must allocate ≥2 lines for the row itself, got height={}",
|
||||
rect.height
|
||||
);
|
||||
// The row label is on line 1.
|
||||
// The row label is on line 1. A 28-col row truncates a long label, so
|
||||
// match the head of the live copy rather than the whole string.
|
||||
let label_line = buf_row_text(&buf, rect.y, area.x, area.width);
|
||||
let label = s
|
||||
.registry
|
||||
.find("coding_data_sharing")
|
||||
.expect("registered")
|
||||
.label;
|
||||
let head: String = label
|
||||
.split_whitespace()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert!(
|
||||
label_line.contains("Coding data sharing"),
|
||||
"line 1 must contain the row label: {label_line:?}"
|
||||
label_line.contains(&head),
|
||||
"line 1 must contain the row label (head {head:?}): {label_line:?}"
|
||||
);
|
||||
// The value (display: "Opt out" or similar) is on line 2.
|
||||
let value_line = buf_row_text(&buf, rect.y + 1, area.x, area.width);
|
||||
|
|
@ -6391,6 +6399,66 @@ fn hover_breadcrumb_flips_state_and_returns_changed() {
|
|||
);
|
||||
}
|
||||
|
||||
/// `d` must be inert on a consent chooser, not merely hidden from the
|
||||
/// footer.
|
||||
#[test]
|
||||
fn consent_chooser_drops_tip_and_reset() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 40,
|
||||
};
|
||||
let screen = |s: &mut SettingsModalState| {
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_settings_modal(&mut buf, area, s, false, None);
|
||||
(0..area.height)
|
||||
.map(|y| buf_row_text(&buf, y, area.x, area.width))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let mut consent = enter_picker_for("coding_data_sharing");
|
||||
let text = screen(&mut consent);
|
||||
assert!(
|
||||
!text.contains("Ask Grok"),
|
||||
"consent chooser must not render the docs tip:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("d reset"),
|
||||
"consent chooser must not offer reset:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Enter select"),
|
||||
"the other footer hints must survive:\n{text}"
|
||||
);
|
||||
|
||||
let outcome = handle_settings_key(
|
||||
&mut consent,
|
||||
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(
|
||||
matches!(outcome, SettingsKeyOutcome::Unchanged),
|
||||
"`d` must be inert on a consent chooser, got {outcome:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(consent.mode(), SettingsModalMode::PickingEnum { .. }),
|
||||
"`d` must leave the chooser open, got {:?}",
|
||||
consent.mode()
|
||||
);
|
||||
|
||||
let mut ordinary = enter_picker_for("theme");
|
||||
let text = screen(&mut ordinary);
|
||||
assert!(
|
||||
text.contains("d reset") && text.contains("Ask Grok"),
|
||||
"ordinary pickers keep the tip and the reset hint:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Enter select") && !text.contains("Enter commit"),
|
||||
"every chooser selects an answer rather than committing a value:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The row-list-with-search-bar layout reserves row 1 (below
|
||||
/// the search bar) for a `─` divider in `gray_dim` — palette
|
||||
/// parity.
|
||||
|
|
@ -7545,6 +7613,86 @@ fn locked_coding_data_sharing_row_does_not_open_picker() {
|
|||
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
|
||||
}
|
||||
|
||||
/// `d` on a locked row must not open the confirm dialog: the dispatch-time
|
||||
/// guard would refuse the reset anyway, but only after walking the user
|
||||
/// through a confirmation for a change that cannot happen.
|
||||
#[test]
|
||||
fn locked_coding_data_sharing_row_refuses_reset() {
|
||||
for lock in [
|
||||
CodingDataSharingLock::Zdr,
|
||||
CodingDataSharingLock::TeamManaged,
|
||||
] {
|
||||
let mut s = make_locked_state(lock);
|
||||
s.selected = coding_data_sharing_row_idx(&s);
|
||||
let out = handle_settings_key(
|
||||
&mut s,
|
||||
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(
|
||||
matches!(out, SettingsKeyOutcome::Unchanged),
|
||||
"`d` on a locked row must be a no-op ({lock:?}), got {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Control arm: no lock → `d` still opens the confirm dialog.
|
||||
let mut s = make_state();
|
||||
s.selected = coding_data_sharing_row_idx(&s);
|
||||
let out = handle_settings_key(
|
||||
&mut s,
|
||||
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
out,
|
||||
SettingsKeyOutcome::Action(Action::OpenResetConfirm {
|
||||
key: "coding_data_sharing"
|
||||
})
|
||||
),
|
||||
"`d` on an unlocked row must still offer reset, got {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `→ expand` stays on a locked row — that is how the lock reason is read.
|
||||
#[test]
|
||||
fn locked_row_footer_drops_the_keys_it_refuses() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 40,
|
||||
};
|
||||
let screen = |s: &mut SettingsModalState| {
|
||||
let mut buf = Buffer::empty(area);
|
||||
render_settings_modal(&mut buf, area, s, false, None);
|
||||
(0..area.height)
|
||||
.map(|y| buf_row_text(&buf, y, area.x, area.width))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let mut locked = make_locked_state(CodingDataSharingLock::Zdr);
|
||||
locked.selected = coding_data_sharing_row_idx(&locked);
|
||||
let text = screen(&mut locked);
|
||||
for hint in ["d reset", "Enter edit", "Space toggle"] {
|
||||
assert!(
|
||||
!text.contains(hint),
|
||||
"a locked row must not advertise `{hint}`:\n{text}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
text.contains("expand"),
|
||||
"`→ expand` reads the lock reason and must survive:\n{text}"
|
||||
);
|
||||
|
||||
let mut unlocked = make_state();
|
||||
unlocked.selected = coding_data_sharing_row_idx(&unlocked);
|
||||
let text = screen(&mut unlocked);
|
||||
assert!(
|
||||
text.contains("d reset") && text.contains("Enter edit"),
|
||||
"an unlocked row keeps the full footer:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Locked rows drop the `›` enter-affordance and render a per-variant
|
||||
/// value: ZDR replaces opt-in/out with "ZDR"; team-managed keeps the
|
||||
/// value with an " · Admin Managed" suffix. Unlocked rows keep the plain
|
||||
|
|
@ -7642,8 +7790,19 @@ fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
|
|||
text.contains("Managed by your team admin."),
|
||||
"expanded locked row must show the lock reason: {text:?}"
|
||||
);
|
||||
// Token from the live description so this survives copy edits.
|
||||
let desc = s
|
||||
.registry
|
||||
.find("coding_data_sharing")
|
||||
.expect("registered")
|
||||
.description;
|
||||
let desc_head: String = desc
|
||||
.split_whitespace()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert!(
|
||||
!text.contains("Controls whether"),
|
||||
!text.contains(&desc_head),
|
||||
"locked expansion must replace the description, not append to it: {text:?}"
|
||||
);
|
||||
|
||||
|
|
@ -7655,7 +7814,7 @@ fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
|
|||
render_rows(&mut buf, area, &mut s, &theme);
|
||||
let text = flatten(&buf);
|
||||
assert!(
|
||||
text.contains("Controls whether"),
|
||||
text.contains(&desc_head),
|
||||
"expanded row must render the registry description: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -117,9 +117,10 @@ pub struct WelcomeRenderResult {
|
|||
pub announcement_rect: Option<Rect>,
|
||||
/// Hit-test rect for the promo upgrade CTA `[label]` button (click → open).
|
||||
pub upgrade_cta_rect: Option<Rect>,
|
||||
pub privacy_banner_accept_rect: Option<Rect>,
|
||||
pub privacy_banner_customize_rect: Option<Rect>,
|
||||
pub privacy_banner_legal_rect: Option<Rect>,
|
||||
pub privacy_banner_opt_in_rect: Option<Rect>,
|
||||
pub privacy_banner_opt_out_rect: Option<Rect>,
|
||||
pub privacy_banner_terms_rect: Option<Rect>,
|
||||
pub privacy_banner_policy_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
use hero_box::HERO_BOX_MIN_WIDTH;
|
||||
|
|
@ -729,9 +730,10 @@ pub fn render_welcome(
|
|||
announcement_truncated: false,
|
||||
announcement_rect: None,
|
||||
upgrade_cta_rect: None,
|
||||
privacy_banner_accept_rect: None,
|
||||
privacy_banner_customize_rect: None,
|
||||
privacy_banner_legal_rect: None,
|
||||
privacy_banner_opt_in_rect: None,
|
||||
privacy_banner_opt_out_rect: None,
|
||||
privacy_banner_terms_rect: None,
|
||||
privacy_banner_policy_rect: None,
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating { auth_url, mode, .. } => {
|
||||
|
|
@ -764,9 +766,10 @@ pub fn render_welcome(
|
|||
announcement_truncated: false,
|
||||
announcement_rect: None,
|
||||
upgrade_cta_rect: None,
|
||||
privacy_banner_accept_rect: None,
|
||||
privacy_banner_customize_rect: None,
|
||||
privacy_banner_legal_rect: None,
|
||||
privacy_banner_opt_in_rect: None,
|
||||
privacy_banner_opt_out_rect: None,
|
||||
privacy_banner_terms_rect: None,
|
||||
privacy_banner_policy_rect: None,
|
||||
}
|
||||
}
|
||||
AuthState::Done if params.is_zdr_blocked => {
|
||||
|
|
@ -800,9 +803,10 @@ pub fn render_welcome(
|
|||
announcement_truncated: false,
|
||||
announcement_rect: None,
|
||||
upgrade_cta_rect: None,
|
||||
privacy_banner_accept_rect: None,
|
||||
privacy_banner_customize_rect: None,
|
||||
privacy_banner_legal_rect: None,
|
||||
privacy_banner_opt_in_rect: None,
|
||||
privacy_banner_opt_out_rect: None,
|
||||
privacy_banner_terms_rect: None,
|
||||
privacy_banner_policy_rect: None,
|
||||
}
|
||||
}
|
||||
// Folder-trust question: shown after auth, before any session is
|
||||
|
|
@ -1719,14 +1723,18 @@ fn render_welcome_done(
|
|||
});
|
||||
let has_update_tip = p.pending_update_version.is_some();
|
||||
let has_resume_tip = !has_update_tip && p.foreign_resume_hint.is_some();
|
||||
// Tip slot precedence: pending update > privacy banner (2 rows) > resume
|
||||
// hint > random tip. The update outranks the upsell so a ready update is
|
||||
// never invisible; the banner takes the slot back once it's applied.
|
||||
// Tip slot precedence: pending update > privacy banner (wraps, so its
|
||||
// height depends on width) > resume hint > random tip. The update
|
||||
// outranks the upsell so a ready update is never invisible; the banner
|
||||
// takes the slot back once it's applied.
|
||||
let tip_height = if !show_picker {
|
||||
if has_update_tip {
|
||||
1u16
|
||||
} else if p.privacy_banner {
|
||||
2u16
|
||||
// Same inset the banner paint below uses, so the reserved rows
|
||||
// and the wrapped row count can't drift.
|
||||
let inset = prompt::prompt_inset(p.compact);
|
||||
crate::views::privacy_banner::height(content_area.width.saturating_sub(inset * 2))
|
||||
} else if has_resume_tip {
|
||||
1u16
|
||||
} else if let Some(tip_text) = p.tip {
|
||||
|
|
@ -1941,9 +1949,10 @@ fn render_welcome_done(
|
|||
// shortcuts are rendered inside the picker content area.
|
||||
let mut refresh_hit_rect: Option<Rect> = None;
|
||||
let mut gate_url_hit_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_accept_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_customize_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_legal_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_opt_in_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_opt_out_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_terms_rect: Option<Rect> = None;
|
||||
let mut privacy_banner_policy_rect: Option<Rect> = None;
|
||||
let (cursor_pos, post_flush_escapes) = if show_picker {
|
||||
(None, None)
|
||||
} else if !p.has_access {
|
||||
|
|
@ -2071,9 +2080,10 @@ fn render_welcome_done(
|
|||
height: tip_centered.height,
|
||||
};
|
||||
let rects = crate::views::privacy_banner::render(tip_inset, buf, theme, p.mouse_pos);
|
||||
privacy_banner_accept_rect = Some(rects.accept);
|
||||
privacy_banner_customize_rect = Some(rects.customize);
|
||||
privacy_banner_legal_rect = Some(rects.legal);
|
||||
privacy_banner_opt_in_rect = Some(rects.opt_in);
|
||||
privacy_banner_opt_out_rect = Some(rects.opt_out);
|
||||
privacy_banner_terms_rect = Some(rects.terms);
|
||||
privacy_banner_policy_rect = Some(rects.policy);
|
||||
} else if let Some(ver) = p.pending_update_version
|
||||
&& layout.tip.height > 0
|
||||
{
|
||||
|
|
@ -2212,9 +2222,10 @@ fn render_welcome_done(
|
|||
announcement_truncated,
|
||||
announcement_rect,
|
||||
upgrade_cta_rect,
|
||||
privacy_banner_accept_rect,
|
||||
privacy_banner_customize_rect,
|
||||
privacy_banner_legal_rect,
|
||||
privacy_banner_opt_in_rect,
|
||||
privacy_banner_opt_out_rect,
|
||||
privacy_banner_terms_rect,
|
||||
privacy_banner_policy_rect,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue