Remember the TUI server selection

This commit is contained in:
Georg Bauer
2026-08-05 17:50:09 +02:00
parent 61e96cc6cf
commit c1cfaa3d23
4 changed files with 101 additions and 1 deletions

View File

@@ -75,6 +75,10 @@ representative repositories.
- [ ] Server profiles can be added, authenticated, selected, edited, renamed,
and deleted. Blank token on edit preserves the existing token; removing
the last server leaves the server manager available.
- [ ] Select a different server, quit, and relaunch without `--server`; the TUI
restores that server. Relaunch with `--server NAME`; the explicit server
wins and becomes the restored server after a clean exit. Renaming or
deleting the remembered profile leaves a valid selection.
- [ ] Editors move between fields with Tab/Shift-Tab, support cursor movement,
Unicode insertion, Delete, and Backspace without navigating back, mask
tokens, save with Ctrl-S, and cancel with Esc.

View File

@@ -30,6 +30,8 @@ pub struct TuiPreferences {
pub refresh_seconds: u64,
#[serde(default)]
pub favorites: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_server: Option<String>,
}
impl Default for TuiPreferences {
@@ -37,6 +39,7 @@ impl Default for TuiPreferences {
Self {
refresh_seconds: default_refresh_seconds(),
favorites: BTreeSet::new(),
last_server: None,
}
}
}
@@ -150,6 +153,9 @@ impl Config {
if old_url != new_url {
migrate_favorites(&mut updated.tui.favorites, &old_url, new_url);
}
if updated.tui.last_server.as_deref() == Some(original_name) {
updated.tui.last_server = Some(name.into());
}
updated.save()?;
*self = updated;
Ok(())
@@ -169,6 +175,9 @@ impl Config {
{
remove_favorites(&mut updated.tui.favorites, &removed_url);
}
if updated.tui.last_server.as_deref() == Some(name) {
updated.tui.last_server = updated.servers.keys().next().cloned();
}
updated.save()?;
*self = updated;
Ok(())
@@ -182,6 +191,22 @@ impl Config {
Ok(())
}
pub fn set_tui_last_server(&mut self, name: Option<&str>) -> Result<()> {
if let Some(name) = name
&& !self.servers.contains_key(name)
{
return Err(format!("server profile {name:?} does not exist"));
}
if self.tui.last_server.as_deref() == name {
return Ok(());
}
let mut updated = self.clone();
updated.tui.last_server = name.map(str::to_owned);
updated.save()?;
*self = updated;
Ok(())
}
pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool {
self.tui
.favorites
@@ -455,9 +480,11 @@ mod tests {
.login("code.example", "secret", Provider::Forgejo)
.unwrap();
config.set_tui_refresh_seconds(9).unwrap();
config.set_tui_last_server(Some("code.example")).unwrap();
let mut loaded = Config::load_from(path).unwrap();
assert_eq!(loaded.tui.refresh_seconds, 9);
assert_eq!(loaded.tui.last_server.as_deref(), Some("code.example"));
assert_eq!(loaded.servers["code.example"].token, "secret");
assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
assert_eq!(
@@ -484,6 +511,7 @@ mod tests {
assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository));
assert!(!loaded.servers.contains_key("code.example"));
assert_eq!(loaded.servers["new.example"].token, "new-secret");
assert_eq!(loaded.tui.last_server.as_deref(), Some("new.example"));
assert!(
fs::read_to_string(&loaded.path)
.unwrap()
@@ -506,6 +534,8 @@ mod tests {
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
0o600
);
loaded.logout("new.example").unwrap();
assert_eq!(loaded.tui.last_server, None);
fs::remove_dir_all(directory).unwrap();
}

View File

@@ -257,6 +257,15 @@ impl App {
})
}
pub fn persist_selected_server(&mut self) -> Result<(), String> {
let selected = self
.config
.servers
.contains_key(&self.server_name)
.then_some(self.server_name.as_str());
self.config.set_tui_last_server(selected)
}
pub async fn handle_key(&mut self, key: KeyEvent) {
self.last_input = Instant::now();
self.status_deadline = None;
@@ -1675,6 +1684,14 @@ fn initial_selection(config: &Config, requested: Option<&str>) -> Result<Selecti
if let Some(name) = requested {
return config.select(Some(name), None);
}
if let Some(name) = config
.tui
.last_server
.as_deref()
.filter(|name| config.servers.contains_key(*name))
{
return config.select(Some(name), None);
}
config.select(None, None).or_else(|_| {
let name = config
.servers
@@ -1929,6 +1946,30 @@ fn clock() -> String {
mod tests {
use super::*;
fn config_with_servers() -> Config {
let mut config = Config::default();
config.servers = [
(
"first.example".into(),
gotcha_gitea::ServerProfile {
url: "https://first.example".into(),
token: "first".into(),
provider: Provider::Gitea,
},
),
(
"second.example".into(),
gotcha_gitea::ServerProfile {
url: "https://second.example".into(),
token: "second".into(),
provider: Provider::Forgejo,
},
),
]
.into();
config
}
#[test]
fn changed_files_open_only_their_own_diff() {
let diff = "diff --git a/one.txt b/one.txt\n--- a/one.txt\n+++ b/one.txt\n+one\ndiff --git a/two.txt b/two.txt\n--- a/two.txt\n+++ b/two.txt\n+two\n";
@@ -1941,6 +1982,29 @@ mod tests {
);
}
#[test]
fn startup_prefers_the_last_server_unless_explicitly_overridden() {
let mut config = config_with_servers();
config.tui.last_server = Some("second.example".into());
assert_eq!(
initial_selection(&config, None).unwrap().name.as_deref(),
Some("second.example")
);
assert_eq!(
initial_selection(&config, Some("first.example"))
.unwrap()
.name
.as_deref(),
Some("first.example")
);
config.tui.last_server = Some("removed.example".into());
assert_eq!(
initial_selection(&config, None).unwrap().name.as_deref(),
Some("first.example")
);
}
#[test]
fn list_clicks_follow_the_rendered_offset_and_rows() {
let area = ratatui::layout::Rect::new(10, 4, 30, 10);

View File

@@ -44,7 +44,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
let result = run(&mut terminal, &mut app).await;
execute!(io::stdout(), DisableMouseCapture)?;
ratatui::restore();
result
result?;
app.persist_selected_server()?;
Ok(())
}
async fn run(