From 7e5fd9967caa11121d1293ccc100005ac6c42902 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:58:46 -0500 Subject: [PATCH 01/10] ci(release): replace cross with native cargo builds and musl-tools --- .github/workflows/bin.yml | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/.github/workflows/bin.yml b/.github/workflows/bin.yml index 3ddd71f..8e2c1c6 100644 --- a/.github/workflows/bin.yml +++ b/.github/workflows/bin.yml @@ -34,7 +34,6 @@ jobs: architecture: x86_64 binary-postfix: "" binary-name: vimstoat - use-cross: false - os: macos-latest os-name: macos @@ -42,7 +41,6 @@ jobs: architecture: arm64 binary-postfix: "" binary-name: vimstoat - use-cross: false - os: ubuntu-latest os-name: linux @@ -50,7 +48,6 @@ jobs: architecture: x86_64 binary-postfix: "" binary-name: vimstoat - use-cross: false - os: ubuntu-latest os-name: linux @@ -58,7 +55,6 @@ jobs: architecture: x86_64-musl binary-postfix: "" binary-name: vimstoat - use-cross: true - os: ubuntu-24.04-arm os-name: linux @@ -66,7 +62,6 @@ jobs: architecture: arm64 binary-postfix: "" binary-name: vimstoat - use-cross: false - os: ubuntu-24.04-arm os-name: linux @@ -74,7 +69,6 @@ jobs: architecture: arm64-musl binary-postfix: "" binary-name: vimstoat - use-cross: true - os: windows-latest os-name: windows @@ -82,7 +76,6 @@ jobs: architecture: x86_64 binary-postfix: ".exe" binary-name: vimstoat - use-cross: false - os: windows-latest os-name: windows @@ -90,7 +83,6 @@ jobs: architecture: arm64 binary-postfix: ".exe" binary-name: vimstoat - use-cross: true steps: - name: Checkout repository @@ -103,20 +95,13 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: Install cross - if: matrix.use-cross - uses: taiki-e/install-action@v2 - with: - tool: cross + - name: Install musl-tools + if: matrix.os-name == 'linux' && contains(matrix.target, 'musl') && !matrix.use-cross + run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Cargo build shell: bash - run: | - if [ "${{ matrix.use-cross }}" = "true" ]; then - cross build --release --target ${{ matrix.target }} - else - cargo build --release --target ${{ matrix.target }} - fi + run: cargo build --release --target ${{ matrix.target }} - name: Packaging final binary shell: bash From 8dc3be90504598bf6496150576a664027589cc04 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:01:25 -0500 Subject: [PATCH 02/10] ci: run cargo test with headless gnome-keyring in dbus session --- .github/workflows/rust.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c0d076c..6bf8c94 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -57,8 +57,21 @@ jobs: - name: Lint with Clippy run: cargo clippy -- -D warnings - - name: Run Tests - run: cargo test + - name: Install Keyring & D-Bus dependencies + run: | + sudo apt-get update + sudo apt-get install -y gnome-keyring dbus dbus-x11 + + - name: Run Tests with Keyring + env: + STOAT_TEST_TOKEN: ${{ secrets.STOAT_TEST_TOKEN }} + run: | + dbus-run-session -- bash -c ' + set -e + echo -n "ci-keyring-pwd" | gnome-keyring-daemon --components=secrets --unlock & + sleep 1 + cargo test + ' publish-cargo: name: Publishing to Cargo From 14cad2afb33a6e0ffc563e7da7983b46bddf62f6 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:34:21 -0500 Subject: [PATCH 03/10] test(logger): add unit tests and make log path logic injectable --- src/logger.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/src/logger.rs b/src/logger.rs index 0faafa9..5b9f425 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,20 +1,28 @@ -use std::{fs, path::PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use crate::Result; pub const LOG_FILE: &str = "logs"; -fn create_log_file() -> Result { - let mut path = if let Some(mut p) = dirs::cache_dir() { +fn log_path(cache_dir: Option) -> PathBuf { + let mut path = if let Some(mut p) = cache_dir { p.push(env!("CARGO_PKG_NAME")); p } else { PathBuf::new() }; - fs::create_dir_all(&path)?; - path.push(LOG_FILE); + path +} + +fn open_log_file(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } Ok(fs::OpenOptions::new() .create(true) @@ -23,6 +31,10 @@ fn create_log_file() -> Result { .expect("Failed to open log file")) } +fn create_log_file() -> Result { + open_log_file(&log_path(dirs::cache_dir())) +} + pub fn init() -> Result<()> { let log_file = create_log_file()?; @@ -35,3 +47,66 @@ pub fn init() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn log_path_uses_package_name_and_log_file() { + let base = PathBuf::from("/some/cache"); + let path = log_path(Some(base.clone())); + + assert_eq!(path, base.join(env!("CARGO_PKG_NAME")).join(LOG_FILE)); + } + + #[test] + fn log_path_without_cache_dir_is_relative() { + assert_eq!(log_path(None), PathBuf::from(LOG_FILE)); + } + + #[test] + fn open_log_file_creates_missing_directories() { + let tmp = TempDir::new().unwrap(); + let path = log_path(Some(tmp.path().join("nested").join("cache"))); + assert!(!path.parent().unwrap().exists()); + + open_log_file(&path).unwrap(); + + assert!(path.is_file()); + } + + #[test] + fn open_log_file_appends_across_opens() { + let tmp = TempDir::new().unwrap(); + let path = log_path(Some(tmp.path().to_path_buf())); + + let mut f = open_log_file(&path).unwrap(); + f.write_all(b"a").unwrap(); + drop(f); + + let mut f = open_log_file(&path).unwrap(); + f.write_all(b"b").unwrap(); + drop(f); + + assert_eq!(fs::read_to_string(&path).unwrap(), "ab"); + } + + #[test] + fn open_log_file_preserves_existing_content() { + let tmp = TempDir::new().unwrap(); + let path = log_path(Some(tmp.path().to_path_buf())); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "existing\n").unwrap(); + + let mut f = open_log_file(&path).unwrap(); + f.write_all(b"new\n").unwrap(); + drop(f); + + assert_eq!(fs::read_to_string(&path).unwrap(), "existing\nnew\n"); + } +} From 6ed7bd6d25c558a67a56c8c93f32f3c4b53ef8a6 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:49:13 -0500 Subject: [PATCH 04/10] test(api): auth validate tokens and keyring against the real stoat api --- src/api/auth.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 9 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 1fa828f..c0cf851 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -51,6 +51,77 @@ impl Auth { mod tests { use super::*; + /// Reads the user's real session token from the keyring (read-only). + /// Panics if the keyring or a stored token is unavailable. + async fn real_token() -> String { + let auth = Auth::new().expect("keyring must be available to run auth tests"); + + let token = auth + .token_entry + .get_secret() + .await + .expect("a vimstoat session token must be stored in the keyring"); + + assert!(!token.is_empty(), "stored keyring token is empty"); + token + } + + /// Builds an `Auth` backed by a test-specific keyring entry. + /// Never touches the real vimstoat entry. + fn test_auth(id: &str) -> Auth { + Auth { + token_entry: KeyringEntry::try_new(id).expect("Failed to create test keyring entry"), + } + } + + #[tokio::test] + async fn test_validate_token_accepts_real_token() { + let token = real_token().await; + let auth = Auth::new().unwrap(); + + let client = auth + .validate_token(&token, None) + .await + .expect("real keyring token should validate against the Stoat API"); + + assert_eq!(client.clone_token(), token); + } + + #[tokio::test] + async fn test_validate_token_rejects_invalid_token() { + let auth = test_auth("vimstoat_test_invalid_token"); + + let err = auth + .validate_token("definitely_not_a_real_session_token", None) + .await + .expect_err("bogus token should be rejected by the Stoat API"); + + assert!( + matches!( + err.downcast_ref::(), + Some(AuthError::InvalidToken(_)) + ), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn test_real_token_works_with_authenticated_endpoint() { + let token = real_token().await; + + let client = ApiClient::new(token, None); + let me = client + .get::(Endpoint::CurrentUser) + .await + .expect("GET /users/@me should succeed with the real token"); + + assert!(me.get("_id").is_some(), "no _id in response: {me}"); + assert!( + me.get("username").is_some(), + "no username in response: {me}" + ); + } + #[tokio::test] async fn test_keyring_store_and_get() { // Use a test-specific ID so we don't overwrite the actual vimstoat token during tests @@ -62,15 +133,9 @@ mod tests { let test_token = "test_secret_token_12345"; // Test storing the token - let store_result = auth.store_token(test_token).await; - // The test might fail on CI or headless systems without a secret service, so we handle it gracefully - if let Err(e) = store_result { - println!( - "Skipping keyring test because the environment doesn't support it: {}", - e - ); - return; - } + auth.store_token(test_token) + .await + .expect("keyring must be available to run auth tests"); // Test getting the token let retrieved_token = auth @@ -86,4 +151,27 @@ mod tests { // Clean up let _ = auth.token_entry.delete_secret().await; } + + #[tokio::test] + async fn test_keyring_store_overwrites_existing_token() { + let auth = test_auth("vimstoat_test_keyring_overwrite"); + + auth.store_token("first_token").await.unwrap(); + auth.store_token("second_token").await.unwrap(); + + let retrieved = auth.token_entry.get_secret().await.unwrap(); + assert_eq!(retrieved, "second_token"); + + let _ = auth.token_entry.delete_secret().await; + } + + #[tokio::test] + async fn test_keyring_get_after_delete_fails() { + let auth = test_auth("vimstoat_test_keyring_delete"); + + auth.store_token("temp_token").await.unwrap(); + auth.token_entry.delete_secret().await.unwrap(); + + assert!(auth.token_entry.get_secret().await.is_err()); + } } From c257e05eeb573b7ec8c043e15ea6742caa33b5b7 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:02:10 -0500 Subject: [PATCH 05/10] ci: seed keyring with STOAT_TEST_TOKEN via secret-tool before cargo test --- .github/workflows/rust.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6bf8c94..bc94a49 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -60,7 +60,7 @@ jobs: - name: Install Keyring & D-Bus dependencies run: | sudo apt-get update - sudo apt-get install -y gnome-keyring dbus dbus-x11 + sudo apt-get install -y gnome-keyring dbus dbus-x11 libsecret-tools - name: Run Tests with Keyring env: @@ -70,6 +70,9 @@ jobs: set -e echo -n "ci-keyring-pwd" | gnome-keyring-daemon --components=secrets --unlock & sleep 1 + test -n "$STOAT_TEST_TOKEN" || { echo "STOAT_TEST_TOKEN secret is not set"; exit 1; } + printf "%s" "$STOAT_TEST_TOKEN" | secret-tool store --label=vimstoat \ + service keyring-lib username vimstoat target default application rust-keyring cargo test ' From 87bef7f1988c4c7449214c50c418de31c292dc7d Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:26:42 -0500 Subject: [PATCH 06/10] refactor(api): drop Debug from ApiClient and ClientEvent to prevent token leaks --- src/api/auth.rs | 13 ++++++++++--- src/api/client.rs | 6 +++++- src/api/events.rs | 6 +++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index c0cf851..0ffac48 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -84,17 +84,24 @@ mod tests { .await .expect("real keyring token should validate against the Stoat API"); - assert_eq!(client.clone_token(), token); + // Not assert_eq!: on failure it would print the real token. + assert!( + client.clone_token() == token, + "validated client token does not match the keyring token" + ); } #[tokio::test] async fn test_validate_token_rejects_invalid_token() { let auth = test_auth("vimstoat_test_invalid_token"); - let err = auth + // Not expect_err: it requires `ApiClient: Debug`, which is deliberately absent. + let Err(err) = auth .validate_token("definitely_not_a_real_session_token", None) .await - .expect_err("bogus token should be rejected by the Stoat API"); + else { + panic!("bogus token should be rejected by the Stoat API"); + }; assert!( matches!( diff --git a/src/api/client.rs b/src/api/client.rs index 3d9a2d6..992f88d 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -44,7 +44,11 @@ impl Endpoint { } } -#[derive(Debug, Clone)] +// SECURITY: intentionally does NOT implement `Debug`. This type holds the user's +// session token, and a `{:?}`, `dbg!`, or derived `Debug` on a parent struct would +// write it to the log file or terminal. Do not add `Debug` here (or derive it on any +// type that contains an `ApiClient`) unless the token is redacted. +#[derive(Clone)] pub struct ApiClient { client: Client, token: String, diff --git a/src/api/events.rs b/src/api/events.rs index 55ea1dc..47d844e 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -#[derive(Debug, Clone, Serialize)] +// SECURITY: intentionally does NOT implement `Debug`. `Authenticate` carries the +// user's session token, and a `{:?}`, `dbg!`, or derived `Debug` would write it to +// the log file or terminal. Do not add `Debug` here (or derive it on any type that +// contains a `ClientEvent`) unless the token is redacted. +#[derive(Clone, Serialize)] #[serde(tag = "type")] #[allow(unused)] pub enum ClientEvent { From 3ab5be2f26e63108a3e8f54127e35155e7aa1c5b Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:54:52 -0500 Subject: [PATCH 07/10] test(api): add live shape tests for /users/@me payload --- src/api/user.rs | 159 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/api/user.rs b/src/api/user.rs index 4f4923d..e056fb9 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -71,3 +71,162 @@ pub async fn fetch_current_user(api_client: &ApiClient) -> Result { let val: serde_json::Value = api_client.get(Endpoint::CurrentUser).await?; parse_user(&val).ok_or_else(|| anyhow!("Failed to parse current user from API response")) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{api::auth::Auth, cache::Id}; + use serde_json::Value; + use tokio::sync::OnceCell; + + /// Presence strings the Stoat API sends; each must be handled in `parse_user`. + const KNOWN_PRESENCES: [&str; 5] = ["Online", "Idle", "Focus", "Busy", "Invisible"]; + + static REAL_ME: OnceCell = OnceCell::const_new(); + + async fn real_client() -> ApiClient { + let auth = Auth::new().expect("keyring must be available to run user tests"); + let token = auth + .token_entry + .get_secret() + .await + .expect("a vimstoat session token must be stored in the keyring"); + + ApiClient::new(token, None) + } + + /// The real `/users/@me` payload, fetched once per test run. + async fn real_me() -> &'static Value { + REAL_ME + .get_or_init(|| async { + real_client() + .await + .get::(Endpoint::CurrentUser) + .await + .expect("GET /users/@me should succeed with the real token") + }) + .await + } + + fn field<'a>(obj: &'a Value, key: &str) -> &'a Value { + obj.get(key) + .unwrap_or_else(|| panic!("missing field `{key}`")) + } + + fn str_field<'a>(obj: &'a Value, key: &str) -> &'a str { + field(obj, key) + .as_str() + .unwrap_or_else(|| panic!("`{key}` is not a string")) + } + + /// An optional key's value, treating absent and `null` the same. + fn optional<'a>(obj: &'a Value, key: &str) -> Option<&'a Value> { + obj.get(key).filter(|v| !v.is_null()) + } + + #[tokio::test] + async fn test_parse_user_real_payload() { + let me = real_me().await; + + let user = parse_user(me).expect("real /users/@me payload should parse"); + + assert_eq!(user.id, str_field(me, "_id")); + assert_eq!(user.username, str_field(me, "username")); + } + + #[tokio::test] + async fn test_fetch_user_wrappers_agree() { + let client = real_client().await; + + let current = fetch_current_user(&client).await.unwrap(); + let by_id = fetch_user(&client, ¤t.id).await.unwrap(); + + assert!(!current.username.is_empty()); + assert_eq!(by_id, current); + } + + #[tokio::test] + async fn test_users_me_identity_shape() { + let me = real_me().await; + + assert!( + Id::::new(str_field(me, "_id")).is_ok(), + "`_id` is not a valid id" + ); + assert!(!str_field(me, "username").is_empty(), "`username` is empty"); + + let discriminator = str_field(me, "discriminator"); + assert!( + discriminator.len() == 4 && discriminator.chars().all(|c| c.is_ascii_digit()), + "`discriminator` is not 4 digits" + ); + assert_eq!(str_field(me, "relationship"), "User"); + } + + #[tokio::test] + async fn test_users_me_presence_shape() { + let me = real_me().await; + + assert!(field(me, "online").is_boolean(), "`online` is not a bool"); + + if let Some(status) = optional(me, "status") { + assert!(status.is_object(), "`status` is not an object"); + + if let Some(presence) = optional(status, "presence") { + let presence = presence + .as_str() + .expect("`status.presence` is not a string"); + assert!( + KNOWN_PRESENCES.contains(&presence), + "`status.presence` {presence:?} is not handled by parse_user" + ); + } + if let Some(text) = optional(status, "text") { + assert!(text.is_string(), "`status.text` is not a string"); + } + } + } + + #[tokio::test] + async fn test_users_me_avatar_shape() { + let me = real_me().await; + + // Avatar is optional; an account without one passes vacuously. + let Some(avatar) = optional(me, "avatar") else { + return; + }; + + for key in ["_id", "tag", "filename", "content_type"] { + assert!( + !str_field(avatar, key).is_empty(), + "`avatar.{key}` is empty" + ); + } + assert!( + field(avatar, "size").is_u64(), + "`avatar.size` is not a number" + ); + str_field(field(avatar, "metadata"), "type"); + } + + #[tokio::test] + async fn test_users_me_relations_shape() { + let me = real_me().await; + + let Some(relations) = optional(me, "relations") else { + return; + }; + let relations = relations.as_array().expect("`relations` is not an array"); + + for relation in relations { + assert!( + Id::::new(str_field(relation, "_id")).is_ok(), + "`relations[]._id` is not a valid id" + ); + assert!( + !str_field(relation, "status").is_empty(), + "`relations[].status` is empty" + ); + } + } +} From 094e25886dca75622489e2a98f305808b32e2ade Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:22:23 -0500 Subject: [PATCH 08/10] docs: mark unimplemented features as planned and document auth, presence, unreads, cache and events --- docs/vimstoat/src/features.md | 97 ++++++++++++++++++++++++++++++----- docs/vimstoat/src/keybinds.md | 69 +++++++++++++++++++------ 2 files changed, 136 insertions(+), 30 deletions(-) diff --git a/docs/vimstoat/src/features.md b/docs/vimstoat/src/features.md index 40cd001..1a018c0 100644 --- a/docs/vimstoat/src/features.md +++ b/docs/vimstoat/src/features.md @@ -1,20 +1,91 @@ # Features -VimStoat is currently in early development. Here are the features implemented so far: +VimStoat is currently in early development. Here are the features implemented so far. Anything described as **Planned** is not implemented yet. -## Real-time Messaging -- **WebSocket Integration**: Listens for real-time `Message`, `MessageUpdate`, and `MessageDelete` events from the server. -- **Dynamic Author Resolution**: Transparently fetches and caches unknown user profiles to display real usernames instead of raw IDs in the UI. -- **In-Memory Cache**: Uses an in-memory cache synchronized to disk upon application exit for maximum performance. +## Authentication +- **Session token**: VimStoat logs in with a Stoat session token rather than a username and password. +- **OS keyring storage**: The token is stored in your operating system's keyring (under the name `vimstoat`). It is never written to the cache or the log file. +- **First run**: If no token is stored, VimStoat asks you to paste one and press `Enter`. The input is masked (`•`). The token is checked against the Stoat API *before* it is saved, so a bad token is never stored. `Esc` quits. +- **Later runs**: The stored token is read from the keyring and validated against the API on startup, so you go straight to the server list. +- **Errors**: If the keyring is unavailable, the server can't be reached, or the token is rejected, an error screen is shown (titled *Keyring*, *Connection*, or *Authentication* error). Press a character key, `Enter`, or `Esc` to return to the token prompt. A rejected token stays in the keyring until you enter a valid one, which replaces it. + +## Server List +- **Landing screen**: After logging in you see the server list. The first row is always **Direct Messages**; the servers you are a member of follow it. +- **Relative line numbers**: Like Vim's `relativenumber`, the selected row shows its own index and every other row shows its distance from the selection. +- **Navigation**: `j`/`k` (or the arrow keys) move the selection, `gg` jumps to the top, and `Enter` opens the selected item. +- **Servers are not browsable yet**: Servers are listed, but opening one does nothing. Server channels are **Planned**. ## Direct Messages -- **DM List View**: Displays all active Direct Message channels. -- **Unread Indicators**: Channels with new messages are automatically marked with a red `[*]` unread indicator. -- **Message Previews**: The DM list shows a truncated preview of the latest message for each channel. -- **Chat Interface**: View message history and send new messages seamlessly via the REST API. +- **DM List View**: Displays all active Direct Message channels, including Saved Messages and group DMs. +- **Chat Interface**: View message history (the latest 50 messages) and send new messages via the REST API. +- **Message content**: System messages are shown as `[System message: ]`, and anything VimStoat can't display as `[Unsupported message]`. + +## Presence +- **Status bubble**: Each DM shows a coloured bubble after the name, in both the DM list and the conversation title: + + | Bubble | Status | + | ------ | -------------- | + | `●` | Online | + | `◐` | Idle | + | `◆` | Focus | + | `■` | Do Not Disturb | + | `○` | Offline / Invisible | + +- **Status text**: A user's custom status text is shown after the bubble (` - text`), unless they are offline. +- **Always live**: Presence comes from the real-time connection. The `Ready` event sets everyone's status when VimStoat connects, and `UserUpdate` events keep it current from then on, so the bubble reflects the latest state. A user who disconnects is shown as offline. +- **Group DMs**: Group DMs have no single recipient, so they don't show a bubble. + +## Typing Indicators +- **Incoming typing**: When someone in the open conversation is typing, the title of the message box reads `… - is typing...`. Several people are listed separated by commas. +- **Not sent yet**: VimStoat does not yet tell other people when *you* are typing (**Planned**). + +## Unread Indicators +- **Marker**: Channels with unread messages are marked with a red `[*]` before the name. +- **Synced with the server**: On startup VimStoat fetches your unread state from Stoat. A channel is unread if you were mentioned in it or its latest message is newer than the last one you read. If the unread state can't be fetched, nothing is marked unread. +- **Live**: A message arriving in a conversation you are not viewing sets the marker. +- **Acknowledged on read**: Opening a conversation clears its marker and tells the server you've read it, and messages that arrive while it is open are acknowledged as they come in. Reading a channel on another device clears the marker here too. +- **No mention marker**: Mentions and ordinary unread messages look the same. + +## Real-time Events +VimStoat keeps a WebSocket connection to Stoat open (a ping is sent every 20 seconds to keep it alive) and reacts to these events: + +| Event | Effect | +| ------------------------------------- | ---------------------------------------------------------------- | +| `Ready` | Loads the initial users (and their presence) and your servers | +| `Message` | Appends the message if the conversation is open; otherwise marks the channel unread | +| `MessageUpdate` | Applies the edited text (open conversation only) | +| `MessageDelete` | Removes the message (open conversation only) | +| `UserUpdate` | Updates a user's presence and status text | +| `ChannelStartTyping` / `ChannelStopTyping` | Shows or hides the typing indicator | +| `ChannelAck` | Clears the unread marker when another client read the channel | + +Every other event (reactions, channel, server and member changes, …) is received but ignored; it is only recorded in the log file. This means a few things are **not** reflected until you restart: DMs created after startup, username changes, and reactions. + +If the connection to Stoat drops, VimStoat does not reconnect yet. The app keeps running but stops receiving updates, so restart it to resume live updates. + +## Caching +- **Dynamic Author Resolution**: Transparently fetches and caches unknown user profiles to display real usernames instead of raw IDs in the UI. +- **Persistent cache**: User profiles and DM message history are kept in a cache file in your OS cache directory (on Linux, `~/.cache/vimstoat/cache.db`). The cache is held in memory while the app runs and is written to disk every 10 minutes and when you quit, so a crash can lose up to 10 minutes of cache updates. It is safe to delete the file; it is rebuilt as you use the app. +- **Instant conversations**: Opening a DM shows the cached history immediately while the latest 50 messages are fetched, then replaces it with the fresh copy. + +### The cache is only a starting point +What you see in the UI is the live state built from the server and the real-time events above, not the cache file. The cache only fills in the first screen while fresh data loads: + +- **Message history** is fetched from the server every time you open a conversation, so the cached copy is on screen only until that request completes. +- **Presence** is replaced by the `Ready` event as soon as VimStoat connects. +- **Failed fetches**: if fetching a conversation's history fails (for example, you are offline), the conversation shows as empty rather than falling back to the cached copy. ## Vim-Native Interface -- **Mode-Specific UI Themes**: Border colors dynamically shift based on the current mode (e.g., Yellow for Insert mode, Blue for UI/Normal mode). -- **Cursor Shaping**: Hardware cursor shape changes depending on mode (e.g., blinking bar in Insert mode, block in Normal mode). -- **Input Motions**: Features robust vim-like text composition motions like `i`, `I`, `a`, `A`, `o`, `O`, `dd` (delete line), and an in-memory yank buffer. -- **Screen Navigation**: Use `q` or `:q` to navigate back through screens, and `:qa` to quit globally. +- **Mode-Specific UI Themes**: Border colors dynamically shift based on the current mode (Blue for UI/Normal, Yellow for Insert, Green for Command). +- **Cursor Shaping**: Hardware cursor shape changes depending on mode (blinking bar in Insert and Command mode, block otherwise). +- **Input Motions**: Features vim-like text composition motions like `i`, `I`, `a`, `A`, `o`, `O`, and `dd` (delete line), with an in-memory yank buffer. Pasting from the yank buffer is **Planned**. +- **Multi-line messages**: `Shift+Enter` or `Alt+Enter` inserts a newline; `Enter` sends. +- **Screen Navigation**: `:q` goes back one screen at a time, `q` goes back from a conversation, and `:qa` quits globally. See [Keybinds](./keybinds.md). + +## Planned +- Fuzzy finder (`fs`, `fc`, `ff`, `fm`) +- Search (`/`) +- Visual mode (`v`) +- Pasting the yank buffer +- Browsing server channels +- Broadcasting your own typing indicator diff --git a/docs/vimstoat/src/keybinds.md b/docs/vimstoat/src/keybinds.md index 7b16319..06a9881 100644 --- a/docs/vimstoat/src/keybinds.md +++ b/docs/vimstoat/src/keybinds.md @@ -1,13 +1,19 @@ # Keybinds +VimStoat has three screens you navigate with the keyboard: the **server list**, the **DM list**, and a **conversation**. The list screens use *UI mode*; a conversation starts in *Normal mode* so you can edit your message like a Vim buffer. Both look the same (blue border). + +Keys marked **(Planned)** are not implemented yet. + ## Basic Navigation -| Key | Action | -| --- | ---------- | -| `h` | Move left | -| `j` | Move down | -| `k` | Move up | -| `l` | Move right | +| Key | Action | +| --------- | --------------------------------------------------------------- | +| `h` / `←` | Move left (in the message box) | +| `j` / `↓` | Move down (in lists, or down a line in the message box) | +| `k` / `↑` | Move up (in lists, or up a line in the message box) | +| `l` / `→` | Move right (in the message box) | +| `gg` | Go to the top of the list (server list and DM list only) | +| `Enter` | Open the selected item in a list; send the message in a conversation | ## Mode Switching @@ -19,22 +25,51 @@ | `A` | Enter Insert mode, at the end of the line | | `o` | Enter Insert mode, and create a new line below the current line. | | `O` | Enter Insert mode, and create a new line above the current line. | -| `dd` | Delete the current line | -| `gg` | Go to the top of the UI list | -| `q` | Go back or Quit | | `:` | Enter Command mode | -| `/` | Search (enters Command mode with `/` prefix) | -| `v` | Enter Visual mode (future) | -| `Esc` | Enter Normal mode | +| `Esc` | Leave Insert or Command mode | +| `/` | Search **(Planned)** | +| `v` | Enter Visual mode **(Planned)** | + +## Editing (Normal mode) + +| Key | Action | +| ---- | -------------------------------------------------------------------------------------------- | +| `dd` | Delete the current line of the message. The line is saved to the yank buffer (`p` to paste it back is **Planned**). | + +## Insert Mode + +| Key | Action | +| ------------------------ | ------------------------------------------- | +| any character | Insert it at the cursor | +| `Backspace` | Delete the character before the cursor | +| `Enter` | Send the message | +| `Shift+Enter`, `Alt+Enter` | Insert a newline without sending | +| `←` `→` `↑` `↓` | Move the cursor | +| `Esc` | Return to Normal mode | + +## Going Back and Quitting + +| Key | Where | Action | +| ---- | ---------------------- | --------------------------------------------- | +| `q` | Conversation (Normal) | Go back to the DM list | +| `q` | Server list, DM list | Quit the application | + +`:q` always goes back exactly one screen (conversation → DM list → server list), and quits from the server list. See the commands below. ## Commands (Command Mode) -| Command | Action | -| -------------- | ---------------------------------------------------------------- | -| `:q`, `:quit` | Go back to the previous screen, or quit if at the top level | -| `:qa`, `:qall` | Quit the application immediately | +Press `:` to enter Command mode, type a command, then press `Enter`. `Esc` cancels and `Backspace` edits the command. + +| Command | Action | +| ------------------------------------ | ----------------------------------------------------------- | +| `:q`, `:quit`, `:q!` | Go back to the previous screen, or quit if at the top level | +| `:qa`, `:qall`, `:qa!`, `:qall!` | Quit the application immediately | + +Unknown commands are ignored (a warning is written to the log file). + +## Fuzzy Finder (Planned) -## Fuzzy Finder +None of these are implemented yet. | Key | Action | | ---- | ------------ | From 8ae24a0bd64e668f2fc25205cee1febaa7008384 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:27:19 -0500 Subject: [PATCH 09/10] feat(input): add G to jump to the bottom of lists --- docs/vimstoat/src/features.md | 2 +- docs/vimstoat/src/keybinds.md | 1 + src/action.rs | 4 +--- src/input.rs | 4 ++++ src/views/dm_list.rs | 3 +++ src/views/server_list.rs | 3 +++ 6 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/vimstoat/src/features.md b/docs/vimstoat/src/features.md index 1a018c0..142fafb 100644 --- a/docs/vimstoat/src/features.md +++ b/docs/vimstoat/src/features.md @@ -12,7 +12,7 @@ VimStoat is currently in early development. Here are the features implemented so ## Server List - **Landing screen**: After logging in you see the server list. The first row is always **Direct Messages**; the servers you are a member of follow it. - **Relative line numbers**: Like Vim's `relativenumber`, the selected row shows its own index and every other row shows its distance from the selection. -- **Navigation**: `j`/`k` (or the arrow keys) move the selection, `gg` jumps to the top, and `Enter` opens the selected item. +- **Navigation**: `j`/`k` (or the arrow keys) move the selection, `gg` jumps to the top, `G` jumps to the bottom, and `Enter` opens the selected item. - **Servers are not browsable yet**: Servers are listed, but opening one does nothing. Server channels are **Planned**. ## Direct Messages diff --git a/docs/vimstoat/src/keybinds.md b/docs/vimstoat/src/keybinds.md index 06a9881..4d6f88e 100644 --- a/docs/vimstoat/src/keybinds.md +++ b/docs/vimstoat/src/keybinds.md @@ -13,6 +13,7 @@ Keys marked **(Planned)** are not implemented yet. | `k` / `↑` | Move up (in lists, or up a line in the message box) | | `l` / `→` | Move right (in the message box) | | `gg` | Go to the top of the list (server list and DM list only) | +| `G` | Go to the bottom of the list (server list and DM list only) | | `Enter` | Open the selected item in a list; send the message in a conversation | ## Mode Switching diff --git a/src/action.rs b/src/action.rs index 04a1c96..9ae0263 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,8 +1,7 @@ #[derive(Debug, Clone, Copy, PartialEq)] pub enum Action { - #[allow(unused)] GoToTopUI, - #[allow(unused)] + GoToBottomUI, AppendCharacter(char), RemoveCharacter, EnterCommandMode, @@ -19,6 +18,5 @@ pub enum Action { CursorUp, CursorDown, Escape, - #[allow(unused)] Quit, } diff --git a/src/input.rs b/src/input.rs index 9a74432..5f82f90 100644 --- a/src/input.rs +++ b/src/input.rs @@ -124,6 +124,10 @@ impl Default for KeyMaps { ], Action::GoToTopUI, ), + ( + vec![KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)], + Action::GoToBottomUI, + ), ( vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], Action::Quit, diff --git a/src/views/dm_list.rs b/src/views/dm_list.rs index d2416d7..e2a66ab 100644 --- a/src/views/dm_list.rs +++ b/src/views/dm_list.rs @@ -157,6 +157,9 @@ pub fn handle(app: &mut App, key: KeyEvent) { Some(Action::GoToTopUI) => { app.selected_dm_index = 0; } + Some(Action::GoToBottomUI) => { + app.selected_dm_index = app.store.dm_channels.len().saturating_sub(1); + } Some(Action::Enter) if !app.store.dm_channels.is_empty() => { let channel_id = app.store.dm_channels[app.selected_dm_index].id.clone(); let last_msg_id = app.store.dm_channels[app.selected_dm_index] diff --git a/src/views/server_list.rs b/src/views/server_list.rs index 8de34de..aaf5cee 100644 --- a/src/views/server_list.rs +++ b/src/views/server_list.rs @@ -149,6 +149,9 @@ pub fn handle(app: &mut App, key: KeyEvent) { Some(Action::GoToTopUI) => { app.selected_index = 0; } + Some(Action::GoToBottomUI) => { + app.selected_index = app.store.servers.len(); + } _ => {} } } From c13662a69ddcd417c9b59312ba0d5c4793a9f266 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:38:04 -0500 Subject: [PATCH 10/10] ci(release): fix asset naming and checkout on dispatch, drop duplicate release trigger --- .github/workflows/bin.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bin.yml b/.github/workflows/bin.yml index 8e2c1c6..2717ded 100644 --- a/.github/workflows/bin.yml +++ b/.github/workflows/bin.yml @@ -3,8 +3,6 @@ name: Binaries Publisher run-name: Binaries Publisher | ${{ github.event_name }} by ${{ github.actor }} on '${{ github.ref_name }}' on: - release: - types: [published] push: tags: - "v*" @@ -87,6 +85,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -96,7 +96,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install musl-tools - if: matrix.os-name == 'linux' && contains(matrix.target, 'musl') && !matrix.use-cross + if: matrix.os-name == 'linux' && contains(matrix.target, 'musl') run: sudo apt-get update && sudo apt-get install -y musl-tools - name: Cargo build @@ -109,7 +109,7 @@ jobs: cd target/${{ matrix.target }}/release BINARY_NAME="${{ matrix.binary-name }}${{ matrix.binary-postfix }}" - RELEASE_NAME="${{ matrix.binary-name }}-${{ github.ref_name }}-${{ matrix.os-name }}-${{ matrix.architecture }}" + RELEASE_NAME="${{ matrix.binary-name }}-${{ inputs.tag || github.ref_name }}-${{ matrix.os-name }}-${{ matrix.architecture }}" # Create archive with binary, README, and LICENSE cp ../../../README.md . 2>/dev/null || true