diff --git a/README.md b/README.md index 46425ff..7fd2e01 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A terminal UI dashboard for monitoring GitHub repositories, pull requests, and y - Open any PR or repo in your browser with a single keypress - Disk caching with configurable TTL to minimize API calls - Auto-refresh on a configurable interval +- Switch between named **profiles** (work / personal / Enterprise hosts) from inside the TUI — no restart, no config editing - Vim-style keybindings ## Installation @@ -61,11 +62,13 @@ cargo build --release ghdash resolves your GitHub token in this order: -1. `gh auth token` (GitHub CLI — recommended) +1. the environment variable named by the active profile's `token_env` (if set — see [Profiles](#profiles)) 2. `GITHUB_TOKEN` environment variable -3. `GH_TOKEN` environment variable +3. `gh auth token` (GitHub CLI — reuses your `gh` login, including Enterprise hosts) +4. `GH_TOKEN` environment variable -The easiest way is to install the [GitHub CLI](https://cli.github.com/) and run `gh auth login`. +The token is never written to config, cache, logs, or the UI. The easiest setup is +to install the [GitHub CLI](https://cli.github.com/) and run `gh auth login`. ## Configuration @@ -103,6 +106,49 @@ nav_width_percent = 30 On macOS, `~/Library/Application Support/ghdash/config.toml` is also supported. +### Profiles + +A **profile** is a named context — a set of orgs/users, an `api_url`, and its own +token and cache. This lets you juggle work vs. personal accounts, or public +GitHub vs. a GitHub Enterprise host, and switch between them from inside the TUI +with the `p` key. The active profile is always shown as a chip in the status bar. + +Add a `[[profiles]]` array to your config. If you don't define any profiles, the +top-level config is treated as a single `default` profile, so existing configs +keep working unchanged. + +```toml +# Which profile to start on (optional; defaults to the first profile). +active_profile = "work" + +[[profiles]] +name = "work" +[profiles.github] +orgs = ["my-work-org"] +api_url = "https://api.github.com/graphql" +# Name of the env var holding this profile's token — NOT the token itself. +token_env = "GHDASH_TOKEN_WORK" + +[[profiles]] +name = "acme-enterprise" +[profiles.github] +orgs = ["acme"] +api_url = "https://ghe.acme.corp/api/v3" # GitHub Enterprise host +token_env = "GHDASH_TOKEN_ACME" +``` + +**Token resolution (per profile):** the token is looked up, in order, from + +1. the environment variable named by `token_env`, +2. `GITHUB_TOKEN`, +3. `gh auth token --hostname ` (reuses your `gh` login, including per-host + Enterprise credentials), +4. `GH_TOKEN`. + +Tokens are **never** stored in the config file, written to the cache, logged, or +shown in the UI — only the env-var *name* lives in config. Each profile also gets +its own cache namespace, so switching never shows another profile's cached data. + ## Usage ```sh @@ -126,8 +172,18 @@ ghdash --help # Show all options | `r` | Refresh all data | | `o` | Open selected item in browser | | `/` | Toggle search filter | +| `p` | Switch profile (modal picker) | | `q` / `Ctrl+C` | Quit | +### In the profile picker + +| Key | Action | +| --------------- | ------------------------------------ | +| Type | Filter profiles by name | +| `Up` / `Down` | Move selection | +| `Enter` | Switch to the selected profile | +| `Esc` | Cancel without switching | + ### In search mode | Key | Action | diff --git a/src/app/actions.rs b/src/app/actions.rs index 050d989..066664b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -16,6 +16,14 @@ pub enum Action { CloseOverlay, ToggleHelp, CycleMergeFilter, + // Profile switcher (modal picker) + ToggleProfilePicker, + ProfilePickerInput(char), + ProfilePickerBackspace, + ProfilePickerUp, + ProfilePickerDown, + ProfilePickerConfirm, + ProfilePickerCancel, SearchInput(char), SearchBackspace, SearchClear, diff --git a/src/app/event_loop.rs b/src/app/event_loop.rs index 5d665af..74e0e1d 100644 --- a/src/app/event_loop.rs +++ b/src/app/event_loop.rs @@ -1,7 +1,8 @@ use std::io; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, execute, @@ -13,18 +14,76 @@ use tokio::sync::{Semaphore, mpsc}; use tracing::{debug, error}; use crate::app::actions::{Action, DataPayload, SideEffect}; -use crate::app::state::{AppState, DiffEntry, FocusedPane, Overlay, PrDetailEntry}; +use crate::app::state::{AppState, DiffEntry, FocusedPane, Overlay, PrDetailEntry, ProfileSummary}; use crate::app::update::update; use crate::app::view; use crate::cache::CacheStore; use crate::github::GithubClient; -use crate::util::config::AppConfig; +use crate::util::config::{AppConfig, Profile}; + +/// Everything needed to run against one profile: its effective config, an +/// authenticated client, the resolved viewer login, and its cache namespace. +pub struct ProfileRuntime { + pub config: AppConfig, + pub client: GithubClient, + pub viewer_login: String, + pub cache_store: Option, +} + +/// Build the runtime for a profile: resolve its token (never persisted), build a +/// client for its `api_url`, authenticate, and open its cache namespace. Used at +/// startup and on every profile switch. +pub async fn build_runtime( + profile: &Profile, + base_cache_dir: &Path, + no_cache: bool, + invalidate: bool, +) -> Result { + let token = crate::github::auth::resolve_profile_token( + profile.github.token_env.as_deref(), + &profile.github.api_url, + )?; + let client = GithubClient::new(&token, &profile.github.api_url)?; + let viewer_login = client + .fetch_viewer() + .await + .context("failed to authenticate with GitHub")?; + + let cache_store = if no_cache { + None + } else { + let store = CacheStore::new(profile.cache_dir(base_cache_dir), profile.cache.ttl_secs); + if invalidate { + store.invalidate_all()?; + } + Some(store) + }; + + Ok(ProfileRuntime { + config: profile.to_app_config(), + client, + viewer_login, + cache_store, + }) +} + +fn profile_summaries(profiles: &[Profile]) -> Vec { + profiles + .iter() + .map(|p| ProfileSummary { + name: p.name.clone(), + scope_count: p.scope_count(), + host: p.host(), + }) + .collect() +} pub async fn run( - config: AppConfig, - client: GithubClient, - viewer_login: String, - cache_store: Option, + profiles: Vec, + active_name: String, + base_cache_dir: PathBuf, + no_cache: bool, + initial: ProfileRuntime, ) -> Result<()> { // Setup terminal enable_raw_mode()?; @@ -41,7 +100,15 @@ pub async fn run( original_hook(panic_info); })); - let result = run_loop(&mut terminal, config, client, viewer_login, cache_store).await; + let result = run_loop( + &mut terminal, + profiles, + active_name, + base_cache_dir, + no_cache, + initial, + ) + .await; // Restore terminal disable_raw_mode()?; @@ -52,11 +119,19 @@ pub async fn run( async fn run_loop( terminal: &mut Terminal>, - config: AppConfig, - client: GithubClient, - viewer_login: String, - cache_store: Option, + profiles: Vec, + active_name: String, + base_cache_dir: PathBuf, + no_cache: bool, + initial: ProfileRuntime, ) -> Result<()> { + let ProfileRuntime { + mut config, + mut client, + mut viewer_login, + mut cache_store, + } = initial; + let all_owners: Vec = config .github .orgs @@ -65,6 +140,7 @@ async fn run_loop( .cloned() .collect(); let mut state = AppState::new(viewer_login.clone(), all_owners); + state.set_profiles(profile_summaries(&profiles), active_name); let (action_tx, mut action_rx) = mpsc::unbounded_channel::(); let semaphore = Arc::new(Semaphore::new(4)); @@ -220,6 +296,57 @@ async fn run_loop( } } } + + // Profile switch requested from the picker: rebuild the client for the + // target profile (new token + api_url + cache namespace), reset the data + // state, and kick off a fresh fetch. The old client + token are dropped. + if let Some(target) = state.pending_profile_switch.take() + && let Some(profile) = profiles.iter().find(|p| p.name == target) + { + state.loading = true; + match build_runtime(profile, &base_cache_dir, no_cache, false).await { + Ok(rt) => { + config = rt.config; + client = rt.client; + viewer_login = rt.viewer_login; + cache_store = rt.cache_store; + + let owners: Vec = config + .github + .orgs + .iter() + .chain(config.github.users.iter()) + .cloned() + .collect(); + let summaries = state.profiles.clone(); + state = AppState::new(viewer_login.clone(), owners); + state.set_profiles(summaries, target.clone()); + + // Old dataset is gone; disarm the overlay debounce. + armed_key = None; + pending_fetch = None; + + spawn_side_effect( + SideEffect::RefreshAll, + &config, + &client, + &viewer_login, + &cache_store, + &action_tx, + &semaphore, + ); + } + Err(e) => { + // Never surface token material; report a generic failure. + debug!(profile = %target, error = %e, "Profile switch failed"); + state.loading = false; + state.error_message = Some(format!( + "Failed to switch to profile '{}'. Check its token/host and retry.", + target + )); + } + } + } } Ok(()) @@ -244,6 +371,21 @@ fn map_event_to_action(event: &Event, state: &AppState) -> Option { }; } + // Handle the profile picker (type-to-filter modal): arrows navigate, all + // other printable keys filter, Enter switches, Esc cancels. + if state.profile_picker_active { + return match code { + KeyCode::Esc => Some(Action::ProfilePickerCancel), + KeyCode::Enter => Some(Action::ProfilePickerConfirm), + KeyCode::Up => Some(Action::ProfilePickerUp), + KeyCode::Down => Some(Action::ProfilePickerDown), + KeyCode::Backspace => Some(Action::ProfilePickerBackspace), + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit), + KeyCode::Char(c) => Some(Action::ProfilePickerInput(*c)), + _ => None, + }; + } + // Handle search mode if state.search_active { return match code { @@ -292,6 +434,7 @@ fn map_event_to_action(event: &Event, state: &AppState) -> Option { KeyCode::Char('r') => Some(Action::Refresh), KeyCode::Char('o') => Some(Action::OpenInBrowser), KeyCode::Char('f') => Some(Action::CycleMergeFilter), + KeyCode::Char('p') => Some(Action::ToggleProfilePicker), KeyCode::Char('?') => Some(Action::ToggleHelp), KeyCode::Char('/') => Some(Action::ToggleSearch), _ => None, diff --git a/src/app/state.rs b/src/app/state.rs index df6c434..7d0c473 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -78,6 +78,15 @@ pub enum ContentView { Inbox, } +/// Lightweight, display-only summary of a configured profile for the picker. +/// Deliberately carries no token or secret material. +#[derive(Debug, Clone)] +pub struct ProfileSummary { + pub name: String, + pub scope_count: usize, + pub host: String, +} + #[derive(Debug, Clone)] pub enum NavNode { Org(String), @@ -131,6 +140,18 @@ pub struct AppState { pub merge_filter: MergeFilter, pub help_open: bool, + // Profile switcher + /// All configured profiles (display metadata only, no secrets). + pub profiles: Vec, + /// Name of the currently active profile (shown in the status-bar chip). + pub active_profile: String, + pub profile_picker_active: bool, + pub profile_picker_query: String, + pub profile_picker_cursor: usize, + /// Set when the user confirms a switch; drained by the event loop, which + /// rebuilds the client and re-fetches for the target profile. + pub pending_profile_switch: Option, + // UI flags pub loading: bool, pub loading_orgs: HashSet, @@ -175,6 +196,12 @@ impl AppState { diff_scroll: 0, merge_filter: MergeFilter::All, help_open: false, + profiles: Vec::new(), + active_profile: "default".to_string(), + profile_picker_active: false, + profile_picker_query: String::new(), + profile_picker_cursor: 0, + pending_profile_switch: None, loading: true, loading_orgs: HashSet::new(), error_message: None, @@ -185,6 +212,22 @@ impl AppState { state } + /// Install the profile list and the active profile name (called at startup + /// and after a successful switch). + pub fn set_profiles(&mut self, profiles: Vec, active: String) { + self.profiles = profiles; + self.active_profile = active; + } + + /// Profiles matching the current picker query (substring, case-insensitive). + pub fn filtered_profiles(&self) -> Vec<&ProfileSummary> { + let query = self.profile_picker_query.to_lowercase(); + self.profiles + .iter() + .filter(|p| query.is_empty() || p.name.to_lowercase().contains(&query)) + .collect() + } + pub fn rebuild_nav_tree(&mut self) { let mut nodes = Vec::new(); diff --git a/src/app/update.rs b/src/app/update.rs index 20f7463..2ff5b79 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -179,6 +179,68 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { state.content_cursor = 0; vec![] } + Action::ToggleProfilePicker => { + if state.profile_picker_active { + state.profile_picker_active = false; + state.profile_picker_query.clear(); + } else { + state.profile_picker_active = true; + state.profile_picker_query.clear(); + // Start the cursor on the currently active profile. + state.profile_picker_cursor = state + .profiles + .iter() + .position(|p| p.name == state.active_profile) + .unwrap_or(0); + } + vec![] + } + Action::ProfilePickerInput(ch) => { + if state.profile_picker_active { + state.profile_picker_query.push(ch); + state.profile_picker_cursor = 0; + } + vec![] + } + Action::ProfilePickerBackspace => { + if state.profile_picker_active { + state.profile_picker_query.pop(); + state.profile_picker_cursor = 0; + } + vec![] + } + Action::ProfilePickerUp => { + if state.profile_picker_cursor > 0 { + state.profile_picker_cursor -= 1; + } + vec![] + } + Action::ProfilePickerDown => { + let len = state.filtered_profiles().len(); + if len > 0 && state.profile_picker_cursor + 1 < len { + state.profile_picker_cursor += 1; + } + vec![] + } + Action::ProfilePickerConfirm => { + let target = state + .filtered_profiles() + .get(state.profile_picker_cursor) + .map(|p| p.name.clone()); + state.profile_picker_active = false; + state.profile_picker_query.clear(); + if let Some(name) = target + && name != state.active_profile + { + state.pending_profile_switch = Some(name); + } + vec![] + } + Action::ProfilePickerCancel => { + state.profile_picker_active = false; + state.profile_picker_query.clear(); + vec![] + } Action::SearchInput(ch) => { if state.search_active { state.search_query.push(ch); diff --git a/src/app/view.rs b/src/app/view.rs index 96cac28..8d4f1e6 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -33,6 +33,7 @@ pub fn render(f: &mut Frame, state: &AppState) { widgets::render_pr_overlay(f, state); widgets::render_help_overlay(f, state); widgets::render_search_overlay(f, state); + widgets::render_profile_picker(f, state); if state.error_message.is_some() { widgets::render_error_modal(f, f.area(), state); } diff --git a/src/github/auth.rs b/src/github/auth.rs index 3b4191f..03e123f 100644 --- a/src/github/auth.rs +++ b/src/github/auth.rs @@ -2,24 +2,45 @@ use anyhow::{Result, bail}; use std::process::Command; use tracing::debug; -/// Resolve GitHub token using multiple strategies: -/// 1. `gh auth token` subprocess -/// 2. `GITHUB_TOKEN` environment variable -/// 3. `GH_TOKEN` environment variable -pub fn resolve_token() -> Result { - // Try `gh auth token` first - debug!("Attempting to resolve token via `gh auth token`"); - if let Ok(output) = Command::new("gh").args(["auth", "token"]).output() - && output.status.success() +/// Derive the `gh` CLI `--hostname` value from a GraphQL/REST `api_url`. +/// +/// `https://api.github.com/graphql` maps to `github.com` (the hostname `gh` +/// expects for public GitHub), while an Enterprise host such as +/// `https://ghe.acme.corp/api/v3` maps to `ghe.acme.corp`. +pub fn gh_hostname(api_url: &str) -> Option { + let rest = api_url + .strip_prefix("https://") + .or_else(|| api_url.strip_prefix("http://"))?; + let host = rest.split('/').next()?.split(':').next()?; + if host.is_empty() { + return None; + } + let host = if host == "api.github.com" { + "github.com" + } else { + host + }; + Some(host.to_string()) +} + +/// Resolve a profile's token WITHOUT ever persisting it. Resolution order: +/// 1. the env var named by `token_env` (never the token itself in config), +/// 2. `GITHUB_TOKEN`, +/// 3. `gh auth token --hostname ` (reuses the user's `gh` login, incl. +/// per-host Enterprise credentials). +/// +/// The returned token is never logged; only the resolution *source* is traced. +pub fn resolve_profile_token(token_env: Option<&str>, api_url: &str) -> Result { + // 1. Profile-specific env var (by name). + if let Some(var) = token_env + && let Ok(token) = std::env::var(var) + && !token.is_empty() { - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !token.is_empty() { - debug!("Token resolved via gh CLI"); - return Ok(token); - } + debug!(var = var, "Token resolved via profile token_env"); + return Ok(token); } - // Try GITHUB_TOKEN env + // 2. GITHUB_TOKEN. if let Ok(token) = std::env::var("GITHUB_TOKEN") && !token.is_empty() { @@ -27,7 +48,23 @@ pub fn resolve_token() -> Result { return Ok(token); } - // Try GH_TOKEN env + // 3. gh auth token --hostname . + if let Some(host) = gh_hostname(api_url) { + debug!(host = %host, "Attempting to resolve token via `gh auth token`"); + if let Ok(output) = Command::new("gh") + .args(["auth", "token", "--hostname", &host]) + .output() + && output.status.success() + { + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !token.is_empty() { + debug!(host = %host, "Token resolved via gh CLI"); + return Ok(token); + } + } + } + + // 4. GH_TOKEN (kept as a final fallback for existing setups). if let Ok(token) = std::env::var("GH_TOKEN") && !token.is_empty() { @@ -36,9 +73,7 @@ pub fn resolve_token() -> Result { } bail!( - "Could not resolve GitHub token. Please either:\n\ - - Run `gh auth login` to authenticate with the GitHub CLI\n\ - - Set the GITHUB_TOKEN environment variable\n\ - - Set the GH_TOKEN environment variable" + "Could not resolve a GitHub token for this profile. Set the env var named \ + by `token_env`, set GITHUB_TOKEN, or run `gh auth login` for the host." ) } diff --git a/src/github/graphql.rs b/src/github/graphql.rs index f17d9c3..dc2a31e 100644 --- a/src/github/graphql.rs +++ b/src/github/graphql.rs @@ -313,11 +313,36 @@ impl GithubClient { /// REST v3 base URL, derived from the configured GraphQL `api_url`. /// `https://api.github.com/graphql` → `https://api.github.com`; /// Enterprise `https://host/api/graphql` → `https://host/api/v3`. + /// + /// If `api_url` does not end in `/graphql`, the REST base is derived from its **own host** + /// rather than silently falling back to `api.github.com` — sending a profile's token to the + /// wrong host would be both incorrect and a token-leak risk. fn rest_base(&self) -> String { match self.api_url.strip_suffix("/graphql") { Some(base) if base.ends_with("/api") => format!("{}/v3", base), Some(base) => base.to_string(), - None => "https://api.github.com".to_string(), + None => { + let host = self + .api_url + .split_once("://") + .and_then(|(scheme, rest)| rest.split('/').next().map(|h| (scheme, h))); + match host { + // Public GitHub: REST base is the bare host. + Some(("https", "api.github.com")) => "https://api.github.com".to_string(), + // Enterprise (or any other host): keep the request ON THAT HOST. + Some((scheme, h)) => { + tracing::warn!( + api_url = %self.api_url, + "api_url does not end in /graphql; deriving Enterprise REST base from its host — set api_url to https:///api/graphql to silence this" + ); + format!("{scheme}://{h}/api/v3") + } + None => { + tracing::warn!(api_url = %self.api_url, "unparseable api_url; defaulting REST base to api.github.com"); + "https://api.github.com".to_string() + } + } + } } } @@ -439,3 +464,52 @@ fn parse_search_pr(node: &Value) -> PullRequest { labels, } } + +#[cfg(test)] +mod rest_base_tests { + use super::GithubClient; + + fn rest_base_for(api_url: &str) -> String { + GithubClient::new("test-token", api_url) + .expect("client") + .rest_base() + } + + #[test] + fn graphql_endpoints_map_to_rest_base() { + assert_eq!( + rest_base_for("https://api.github.com/graphql"), + "https://api.github.com" + ); + assert_eq!( + rest_base_for("https://ghe.acme.corp/api/graphql"), + "https://ghe.acme.corp/api/v3" + ); + } + + #[test] + fn non_graphql_enterprise_url_stays_on_its_own_host() { + // The hardening: a non-/graphql Enterprise api_url must NOT fall back to github.com + // (which would send the profile's token to the wrong host). + for u in [ + "https://ghe.acme.corp/api/v3", + "https://ghe.acme.corp", + "https://git.internal.example/api", + ] { + let base = rest_base_for(u); + assert!( + !base.contains("api.github.com"), + "{u} wrongly fell back to github.com: {base}" + ); + assert!(base.contains("ghe.acme.corp") || base.contains("git.internal.example")); + } + } + + #[test] + fn non_graphql_public_github_url_is_github() { + assert_eq!( + rest_base_for("https://api.github.com"), + "https://api.github.com" + ); + } +} diff --git a/src/main.rs b/src/main.rs index eafc1be..bd5f011 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,32 +40,42 @@ async fn main() -> Result<()> { info!("ghdash starting"); - // Resolve auth token before starting TUI - let token = match github::auth::resolve_token() { - Ok(t) => t, + // Resolve the profile list (single "default" when no [[profiles]] configured) + // and pick the active one. + let profiles = config.profiles(); + let active_name = config.active_profile_name(); + let base_cache_dir = config.cache_dir(); + + let active_profile = profiles + .iter() + .find(|p| p.name == active_name) + .cloned() + .expect("active profile always present in profile list"); + + // Build the runtime for the active profile (resolves token, authenticates, + // opens its cache namespace). Exits on auth failure, as before. + let runtime = match app::event_loop::build_runtime( + &active_profile, + &base_cache_dir, + cli.no_cache, + cli.refresh, + ) + .await + { + Ok(rt) => rt, Err(e) => { - eprintln!("Authentication error: {e}"); + eprintln!("Failed to start profile '{active_name}': {e}"); + eprintln!("Check the profile's token (token_env / GITHUB_TOKEN / `gh auth login`)."); std::process::exit(1); } }; - let client = github::GithubClient::new(&token, &config.github.api_url)?; + info!(login = %runtime.viewer_login, profile = %active_name, "Authenticated"); - // Verify auth by fetching viewer - let viewer = match client.fetch_viewer().await { - Ok(v) => v, - Err(e) => { - eprintln!("Failed to authenticate with GitHub: {e}"); - eprintln!("Please check your token and try again."); - std::process::exit(1); - } - }; - - info!(login = %viewer, "Authenticated as {}", viewer); - - if config.github.orgs.is_empty() && config.github.users.is_empty() { + if runtime.config.github.orgs.is_empty() && runtime.config.github.users.is_empty() { eprintln!( - "No organizations or users configured. Please add orgs or users to your config file.\n\ + "No organizations or users configured for profile '{active_name}'. Add orgs or users \ + to your config file.\n\ Example config (~/.config/ghdash/config.toml):\n\n\ [github]\n\ orgs = [\"my-org\"]\n\ @@ -74,19 +84,8 @@ async fn main() -> Result<()> { std::process::exit(1); } - // Build cache store - let cache_store = if cli.no_cache { - None - } else { - let store = cache::CacheStore::new(config.cache_dir(), config.cache.ttl_secs); - if cli.refresh { - store.invalidate_all()?; - } - Some(store) - }; - // Run the TUI event loop - app::event_loop::run(config, client, viewer, cache_store).await + app::event_loop::run(profiles, active_name, base_cache_dir, cli.no_cache, runtime).await } fn setup_logging( diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 7682f2c..6c99fbc 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -25,6 +25,12 @@ pub const BORDER_UNFOCUSED: Style = Style::new().fg(Color::DarkGray); pub const STATUS_BAR: Style = Style::new().fg(Color::White).bg(Color::DarkGray); +// Active-profile chip in the status bar: stands out against the status bar bg. +pub const STATUS_CHIP: Style = Style::new() + .fg(Color::Green) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD); + pub const NAV_ORG: Style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); pub const NAV_REPO: Style = Style::new().fg(Color::White); diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 6c065ce..e80559b 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -337,10 +337,13 @@ fn render_org_overview( } pub fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { + // Active-profile chip (design ④): always visible, marks the current context. + let chip = format!("⦗● {}⦆ ", state.active_profile); + let key_hints = if state.search_active { "Esc: close search | Enter: filter" } else { - "j/k: nav | Enter: select | l: log | d: diff | f: filter | /: search | r: refresh | o: open | ?: help | q: quit" + "j/k: nav | Enter: select | l: log | d: diff | f: filter | /: search | p: profiles | r: refresh | o: open | ?: help | q: quit" }; let status = if state.loading { @@ -364,10 +367,10 @@ pub fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { let right_text = format!("{}{}", rate_info, refresh_info); - // Calculate available space + // Calculate available space (chip + hints share the left region). let total_width = area.width as usize; - let left_len = key_hints.len(); - let right_len = right_text.len(); + let left_len = chip.chars().count() + key_hints.chars().count(); + let right_len = right_text.chars().count(); let center_start = left_len + 1; let center_width = total_width.saturating_sub(left_len + right_len + 2); @@ -380,6 +383,7 @@ pub fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { let padding = center_width.saturating_sub(status_truncated.len()); let line = Line::from(vec![ + Span::styled(chip, theme::STATUS_CHIP), Span::styled(key_hints, theme::STATUS_BAR), Span::styled(" ".repeat(center_start.min(1)), theme::STATUS_BAR), Span::styled( @@ -417,6 +421,67 @@ pub fn render_search_overlay(f: &mut Frame, state: &AppState) { f.render_widget(para, search_area); } +/// Profile picker overlay (design ①): a centered, type-to-filter modal listing +/// the configured profiles. Navigate with the arrow keys, `Enter` switches, +/// `Esc` cancels. Modeled on the search overlay's filtered-list pattern. +pub fn render_profile_picker(f: &mut Frame, state: &AppState) { + if !state.profile_picker_active { + return; + } + + let modal_area = overlay_area(f, 60, 50); + let block = Block::default() + .title(" Switch Profile ") + .title_bottom(Line::from(Span::styled( + " type: filter · ↑/↓: move · Enter: switch · Esc: cancel ", + theme::DIM, + ))) + .borders(Borders::ALL) + .border_style(theme::BORDER_FOCUSED); + + let filtered = state.filtered_profiles(); + + let mut lines: Vec = Vec::new(); + // Filter input line. + lines.push(Line::from(vec![ + Span::styled("> ", theme::HEADER), + Span::styled(state.profile_picker_query.clone(), theme::HEADER), + ])); + + if filtered.is_empty() { + lines.push(Line::from(Span::styled( + " (no matching profiles)", + theme::DIM, + ))); + } else { + for (i, p) in filtered.iter().enumerate() { + let marker = if p.name == state.active_profile { + "●" + } else { + " " + }; + let scope = if p.scope_count == 1 { + "1 scope".to_string() + } else { + format!("{} scopes", p.scope_count) + }; + let text = format!("{} {:<16} {} · {}", marker, p.name, scope, p.host); + let style = if i == state.profile_picker_cursor { + theme::HIGHLIGHT + } else if p.name == state.active_profile { + theme::MERGE_CLEAN + } else { + ratatui::style::Style::default() + }; + lines.push(Line::from(Span::styled(text, style))); + } + } + + f.render_widget(Clear, modal_area); + let para = Paragraph::new(lines).block(block); + f.render_widget(para, modal_area); +} + pub fn render_error_modal(f: &mut Frame, area: Rect, state: &AppState) { let Some(ref msg) = state.error_message else { return; @@ -655,7 +720,7 @@ pub fn render_help_overlay(f: &mut Frame, state: &AppState) { let area = f.area(); let modal_width = 66u16.clamp(40, area.width.saturating_sub(4)); - let modal_height = 18u16.min(area.height.saturating_sub(2)); + let modal_height = 19u16.min(area.height.saturating_sub(2)); let x = (area.width.saturating_sub(modal_width)) / 2; let y = (area.height.saturating_sub(modal_height)) / 2; let modal_area = Rect { @@ -684,6 +749,10 @@ pub fn render_help_overlay(f: &mut Frame, state: &AppState) { key("l", "git-log overlay (content pane)"), key("d", "diff overlay (content pane)"), key("f", "cycle merge filter: all -> conflicting -> clean"), + key( + "p", + "switch profile (modal picker; active shown in status bar)", + ), key("/", "search r refresh o open in browser"), key("Tab", "switch pane h / Esc back / close q quit"), Line::from(""), diff --git a/src/util/config.rs b/src/util/config.rs index 322bde5..f59e73f 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -13,6 +13,30 @@ pub struct AppConfig { pub cache: CacheConfig, #[serde(default)] pub ui: UiConfig, + /// Name of the profile to activate on startup. If unset (or it names no known + /// profile), the first profile is used. + #[serde(default)] + pub active_profile: Option, + /// Named profiles. When empty, the top-level config is treated as a single + /// default profile named `default` (back-compatible). + #[serde(default)] + pub profiles: Vec, +} + +/// A named account/instance context: a full `AppConfig`-shaped body plus a `name`. +/// Switching profiles rebuilds the GitHub client (token + `api_url`) and cache +/// namespace for that profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Profile { + pub name: String, + #[serde(default)] + pub github: GithubConfig, + #[serde(default)] + pub dashboard: DashboardConfig, + #[serde(default)] + pub cache: CacheConfig, + #[serde(default)] + pub ui: UiConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,6 +51,10 @@ pub struct GithubConfig { pub exclude_repos: Vec, #[serde(default = "default_api_url")] pub api_url: String, + /// Name of the environment variable holding this profile's token. The token + /// itself is NEVER stored in config — only the variable name. + #[serde(default)] + pub token_env: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -75,6 +103,7 @@ impl Default for GithubConfig { include_repos: Vec::new(), exclude_repos: Vec::new(), api_url: default_api_url(), + token_env: None, } } } @@ -160,4 +189,80 @@ impl AppConfig { } PathBuf::from(".local/share/ghdash/logs") } + + /// Resolve the profile list. When no `[[profiles]]` are configured, the + /// top-level config is returned as a single profile named `default`, so + /// existing single-context configs keep working unchanged. + pub fn profiles(&self) -> Vec { + if self.profiles.is_empty() { + vec![Profile { + name: "default".to_string(), + github: self.github.clone(), + dashboard: self.dashboard.clone(), + cache: self.cache.clone(), + ui: self.ui.clone(), + }] + } else { + self.profiles.clone() + } + } + + /// Name of the profile to activate: `active_profile` if it names a known + /// profile, otherwise the first profile. + pub fn active_profile_name(&self) -> String { + let profiles = self.profiles(); + if let Some(name) = &self.active_profile + && profiles.iter().any(|p| &p.name == name) + { + return name.clone(); + } + profiles + .first() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "default".to_string()) + } +} + +/// Replace filesystem-hostile characters in a profile name so it is safe to use +/// as a cache subdirectory. +fn sanitize_name(name: &str) -> String { + name.replace(['/', '\\', ':'], "_") +} + +impl Profile { + /// The profile body as an `AppConfig` (no nested profiles), used by the data + /// fetch path which reads `github`/`dashboard`/`cache`/`ui`. + pub fn to_app_config(&self) -> AppConfig { + AppConfig { + github: self.github.clone(), + dashboard: self.dashboard.clone(), + cache: self.cache.clone(), + ui: self.ui.clone(), + active_profile: None, + profiles: Vec::new(), + } + } + + /// Per-profile cache directory: the profile's own `cache.dir` (or the shared + /// default base) namespaced under the profile name, so profiles never read + /// each other's cached data. + pub fn cache_dir(&self, default_base: &Path) -> PathBuf { + let base = self + .cache + .dir + .clone() + .unwrap_or_else(|| default_base.to_path_buf()); + base.join(sanitize_name(&self.name)) + } + + /// Number of configured orgs + users (shown in the picker). + pub fn scope_count(&self) -> usize { + self.github.orgs.len() + self.github.users.len() + } + + /// Short host label for the picker (e.g. `github.com`, `ghe.acme.corp`). + pub fn host(&self) -> String { + crate::github::auth::gh_hostname(&self.github.api_url) + .unwrap_or_else(|| self.github.api_url.clone()) + } } diff --git a/tests/profile_tests.rs b/tests/profile_tests.rs new file mode 100644 index 0000000..1bffb5f --- /dev/null +++ b/tests/profile_tests.rs @@ -0,0 +1,394 @@ +//! Tests for the profile switcher: config schema + back-compat, per-profile token +//! resolution and host mapping, cache namespacing, the modal picker reducer, and +//! secret hygiene (no token leaks in serialized/rendered output). + +use std::io::Write; +use tempfile::NamedTempFile; + +use ghdash::app::actions::Action; +use ghdash::app::state::{AppState, ProfileSummary}; +use ghdash::app::update::update; +use ghdash::github::auth::{gh_hostname, resolve_profile_token}; +use ghdash::util::config::AppConfig; + +fn load(toml: &str) -> AppConfig { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(toml.as_bytes()).unwrap(); + AppConfig::load(Some(f.path())).unwrap() +} + +fn picker_state() -> AppState { + let mut state = AppState::new("me".into(), vec!["org-a".into()]); + state.set_profiles( + vec![ + ProfileSummary { + name: "work".into(), + scope_count: 2, + host: "github.com".into(), + }, + ProfileSummary { + name: "personal".into(), + scope_count: 1, + host: "github.com".into(), + }, + ProfileSummary { + name: "acme-ent".into(), + scope_count: 1, + host: "ghe.acme.corp".into(), + }, + ], + "work".into(), + ); + state +} + +// --- AC6: back-compat (no [[profiles]] behaves exactly as today) --- + +#[test] +fn no_profiles_yields_single_default_profile() { + let config = load( + r#" +[github] +orgs = ["my-org"] +users = ["me"] +api_url = "https://api.github.com/graphql" +"#, + ); + + let profiles = config.profiles(); + assert_eq!(profiles.len(), 1, "top-level config is the single profile"); + assert_eq!(profiles[0].name, "default"); + assert_eq!(profiles[0].github.orgs, vec!["my-org"]); + assert_eq!(profiles[0].github.users, vec!["me"]); + assert_eq!(config.active_profile_name(), "default"); + // The top-level config still parses/behaves as before. + assert_eq!(config.github.orgs, vec!["my-org"]); +} + +#[test] +fn existing_config_without_token_env_parses() { + // token_env is optional; omitting it must not break existing configs. + let config = load( + r#" +[github] +orgs = ["my-org"] +"#, + ); + assert!(config.github.token_env.is_none()); + assert!(config.profiles.is_empty()); +} + +// --- AC1/AC4: [[profiles]] schema, per-profile api_url (Enterprise) --- + +#[test] +fn multiple_profiles_parse_with_per_profile_fields() { + let config = load( + r#" +active_profile = "acme-ent" + +[[profiles]] +name = "work" +[profiles.github] +orgs = ["AITechCraft", "GKF-InCap"] +api_url = "https://api.github.com/graphql" +token_env = "GHDASH_TOKEN_WORK" + +[[profiles]] +name = "acme-ent" +[profiles.github] +orgs = ["acme"] +api_url = "https://ghe.acme.corp/api/v3" +token_env = "GHDASH_TOKEN_ACME" +"#, + ); + + let profiles = config.profiles(); + assert_eq!(profiles.len(), 2); + assert_eq!(config.active_profile_name(), "acme-ent"); + + let work = profiles.iter().find(|p| p.name == "work").unwrap(); + assert_eq!(work.github.orgs, vec!["AITechCraft", "GKF-InCap"]); + assert_eq!(work.github.token_env.as_deref(), Some("GHDASH_TOKEN_WORK")); + assert_eq!(work.scope_count(), 2); + + let acme = profiles.iter().find(|p| p.name == "acme-ent").unwrap(); + assert_eq!(acme.github.api_url, "https://ghe.acme.corp/api/v3"); + assert_eq!(acme.host(), "ghe.acme.corp"); +} + +#[test] +fn active_profile_falls_back_to_first_when_unset_or_unknown() { + let config = load( + r#" +active_profile = "does-not-exist" + +[[profiles]] +name = "first" +[profiles.github] +orgs = ["a"] + +[[profiles]] +name = "second" +[profiles.github] +orgs = ["b"] +"#, + ); + assert_eq!(config.active_profile_name(), "first"); +} + +// --- AC4: gh --hostname derivation --- + +#[test] +fn gh_hostname_maps_public_and_enterprise_hosts() { + assert_eq!( + gh_hostname("https://api.github.com/graphql").as_deref(), + Some("github.com"), + "public GitHub maps to github.com for `gh`" + ); + assert_eq!( + gh_hostname("https://ghe.acme.corp/api/v3").as_deref(), + Some("ghe.acme.corp") + ); + assert_eq!( + gh_hostname("https://ghe.acme.corp:8443/api/v3").as_deref(), + Some("ghe.acme.corp"), + "port is stripped" + ); + assert_eq!(gh_hostname("not-a-url").as_deref(), None); +} + +// --- AC4: token resolution order (token_env -> GITHUB_TOKEN -> gh) --- + +#[test] +fn token_resolution_order() { + // Run sequentially inside one test to control process-global env safely. + let env_var = "GHDASH_TEST_PROFILE_TOKEN"; + let secret = "ghp_profile_env_secret_ABC123"; + let github_token_val = "ghp_github_token_secret_XYZ789"; + + // Save originals so we leave the environment as we found it. + let orig_env = std::env::var(env_var).ok(); + let orig_github = std::env::var("GITHUB_TOKEN").ok(); + let orig_gh = std::env::var("GH_TOKEN").ok(); + + unsafe { + // 1. token_env wins even when GITHUB_TOKEN is also set. + std::env::set_var(env_var, secret); + std::env::set_var("GITHUB_TOKEN", github_token_val); + std::env::remove_var("GH_TOKEN"); + } + let t = resolve_profile_token(Some(env_var), "https://api.github.com/graphql").unwrap(); + assert_eq!(t, secret, "token_env takes precedence"); + + // 2. With no token_env, GITHUB_TOKEN is used (ahead of gh). + unsafe { + std::env::remove_var(env_var); + } + let t = resolve_profile_token(None, "https://api.github.com/graphql").unwrap(); + assert_eq!(t, github_token_val, "falls back to GITHUB_TOKEN"); + + // 3. A token_env pointing at an unset var also falls through to GITHUB_TOKEN. + let t = resolve_profile_token( + Some("GHDASH_UNSET_VAR_NOPE"), + "https://api.github.com/graphql", + ) + .unwrap(); + assert_eq!(t, github_token_val); + + // Restore. + unsafe { + match orig_env { + Some(v) => std::env::set_var(env_var, v), + None => std::env::remove_var(env_var), + } + match orig_github { + Some(v) => std::env::set_var("GITHUB_TOKEN", v), + None => std::env::remove_var("GITHUB_TOKEN"), + } + if let Some(v) = orig_gh { + std::env::set_var("GH_TOKEN", v); + } + } +} + +// --- AC5: per-profile cache namespace --- + +#[test] +fn profiles_have_isolated_cache_dirs() { + let config = load( + r#" +[[profiles]] +name = "work" +[profiles.github] +orgs = ["a"] + +[[profiles]] +name = "personal" +[profiles.github] +orgs = ["b"] +"#, + ); + let base = std::path::Path::new("/tmp/ghdash-cache"); + let profiles = config.profiles(); + let work = profiles.iter().find(|p| p.name == "work").unwrap(); + let personal = profiles.iter().find(|p| p.name == "personal").unwrap(); + + let work_dir = work.cache_dir(base); + let personal_dir = personal.cache_dir(base); + + assert_eq!(work_dir, base.join("work")); + assert_eq!(personal_dir, base.join("personal")); + assert_ne!( + work_dir, personal_dir, + "profiles must not share a cache namespace" + ); +} + +// --- AC1: `p` opens the picker; active profile marked --- + +#[test] +fn toggle_opens_picker_on_active_profile() { + let mut state = picker_state(); + assert!(!state.profile_picker_active); + + update(&mut state, Action::ToggleProfilePicker); + assert!(state.profile_picker_active); + // Cursor starts on the active profile ("work" is index 0 here). + assert_eq!(state.profile_picker_cursor, 0); + assert_eq!(state.filtered_profiles().len(), 3); + + update(&mut state, Action::ToggleProfilePicker); + assert!(!state.profile_picker_active, "toggles closed"); +} + +// --- AC2: type-to-filter, navigate, cancel --- + +#[test] +fn typing_filters_profiles_case_insensitive() { + let mut state = picker_state(); + update(&mut state, Action::ToggleProfilePicker); + + update(&mut state, Action::ProfilePickerInput('A')); // uppercase + update(&mut state, Action::ProfilePickerInput('c')); + let filtered = state.filtered_profiles(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "acme-ent"); + assert_eq!(state.profile_picker_cursor, 0, "cursor resets on input"); + + update(&mut state, Action::ProfilePickerBackspace); + update(&mut state, Action::ProfilePickerBackspace); + assert_eq!(state.filtered_profiles().len(), 3); +} + +#[test] +fn navigation_clamps_within_filtered_list() { + let mut state = picker_state(); + update(&mut state, Action::ToggleProfilePicker); + + update(&mut state, Action::ProfilePickerDown); + assert_eq!(state.profile_picker_cursor, 1); + update(&mut state, Action::ProfilePickerDown); + assert_eq!(state.profile_picker_cursor, 2); + update(&mut state, Action::ProfilePickerDown); + assert_eq!(state.profile_picker_cursor, 2, "clamped at end"); + + update(&mut state, Action::ProfilePickerUp); + update(&mut state, Action::ProfilePickerUp); + update(&mut state, Action::ProfilePickerUp); + assert_eq!(state.profile_picker_cursor, 0, "clamped at start"); +} + +#[test] +fn cancel_closes_without_switching() { + let mut state = picker_state(); + update(&mut state, Action::ToggleProfilePicker); + update(&mut state, Action::ProfilePickerDown); + update(&mut state, Action::ProfilePickerCancel); + + assert!(!state.profile_picker_active); + assert!(state.profile_picker_query.is_empty()); + assert!(state.pending_profile_switch.is_none()); +} + +// --- AC3: Enter requests a switch to the selected profile --- + +#[test] +fn confirm_requests_switch_to_selected_profile() { + let mut state = picker_state(); + update(&mut state, Action::ToggleProfilePicker); + update(&mut state, Action::ProfilePickerDown); // -> "personal" + update(&mut state, Action::ProfilePickerConfirm); + + assert!(!state.profile_picker_active); + assert_eq!(state.pending_profile_switch.as_deref(), Some("personal")); +} + +#[test] +fn confirm_on_active_profile_is_a_noop() { + let mut state = picker_state(); + update(&mut state, Action::ToggleProfilePicker); // cursor on active "work" + update(&mut state, Action::ProfilePickerConfirm); + + assert!(!state.profile_picker_active); + assert!( + state.pending_profile_switch.is_none(), + "no switch when selecting the already-active profile" + ); +} + +// --- AC7: no token leaks in serialized config or rendered UI --- + +#[test] +fn token_never_serialized_into_config() { + // Only the env var NAME is ever stored; the token value lives in the env. + let toml = r#" +[[profiles]] +name = "work" +[profiles.github] +orgs = ["a"] +token_env = "GHDASH_TOKEN_WORK" +"#; + let config = load(toml); + let secret = "ghp_this_must_never_appear_0000"; + + let serialized = toml::to_string(&config).unwrap(); + assert!( + !serialized.contains(secret), + "serialized config must not contain any token value" + ); + // The env var NAME is fine to persist; the secret is not. + assert!(serialized.contains("GHDASH_TOKEN_WORK")); + + // Debug formatting of the whole config must not leak a token either. + let dbg = format!("{config:?}"); + assert!(!dbg.contains(secret)); +} + +#[test] +fn rendered_ui_never_shows_a_token() { + use ghdash::app::view; + use ratatui::{Terminal, backend::TestBackend}; + + let secret = "ghp_rendered_secret_must_be_absent_9999"; + + let mut state = picker_state(); + state.loading = false; + // Open the picker so both the status-bar chip and the modal render. + update(&mut state, Action::ToggleProfilePicker); + + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| view::render(f, &state)).unwrap(); + + let rendered = format!("{}", terminal.backend()); + assert!( + !rendered.contains(secret), + "no token value may appear in rendered output" + ); + // Sanity: the active-profile chip and picker are actually on screen. + assert!(rendered.contains("work"), "active profile chip is rendered"); + assert!( + rendered.contains("Switch Profile"), + "profile picker modal is rendered" + ); +}