Compare commits

..

21 commits

Author SHA1 Message Date
b6b0b1bf9a
build(docker): strip debug symbols from binary after build 2025-08-27 14:05:28 +01:00
430ba54185
feat: top-level panic recovery mechanism 2025-08-27 13:37:09 +01:00
e7caac4dd7
feat(www)!: update ssh command to send $TERM_PROGRAM 2025-08-27 12:49:38 +01:00
4aebf61533
build(rs): make patching crates unique to blog feature for now 2025-08-27 12:47:15 +01:00
019af6bb36
build: apply optimizations in release mode
TODO: Potentially highlight more performance hotspots with
`#[optimize(speed)]`.
2025-08-27 12:46:23 +01:00
8bb45bf0fb
chore(pkg): add repository field 2025-08-27 12:11:16 +01:00
3e44d24f9b
feat!: persist ssh keys across builds by writing them in data dir 2025-08-27 10:34:50 +01:00
7a55e0defd
build(docker): finalize dockerization
* Fix broken build process
* Remove unneeded separate install phase
* Include dockerignore
* Actually build the website in the image separately

thank you @lukadev-0 for help :D
2025-08-26 17:33:42 +01:00
49941da089
build(rs): include support for SKIP_PATCH_CRATE env var
Setting `$SKIP_PATCH_CRATE` skips checking the patch application stage
in build.rs which is triggered when the patch directory is changed.
2025-08-26 17:31:57 +01:00
a6174fbb90
chore(pkg): remove unused features in deps 2025-08-26 17:31:27 +01:00
ded2e7218e
build(docker): copy rust-toolchain into install phase 2025-08-25 18:38:01 +01:00
99c990fb4e
build(docker): run patch-crate before other vendoring steps 2025-08-25 18:35:31 +01:00
eae85408f7
build(docker): lib.rs file to be valid rust project during install 2025-08-25 18:29:41 +01:00
db460ca871
build(docker): use scratch instead of rust image for runner layer 2025-08-25 17:44:07 +01:00
b9fe0f42ec
build: dockerize the project 2025-08-25 17:42:02 +01:00
cc3d64ed8e
feat!: use port 22 as the default instead of 2222 for ssh 2025-08-25 17:40:06 +01:00
d74f27ca83
fix: feature gate required items and fix build without blog 2025-08-25 15:29:01 +01:00
f5fe0f52e6
fix: squash some small bugs
* Fix text not being wrapped for the blog in some cases
* Make `App` not indefinitely block for `TerminalInfo` to be probed
2025-08-19 07:22:20 +01:00
3c089bbdc3
style: apply rustfmt formatting to all files
Also moves rust toolchain to a nightly equivalent of stable 1.87, in
order to allow for existing rustfmt configs such as imports granularity
to be used.
2025-08-19 07:01:31 +01:00
ee524dc160
feat(blog): implement proper blog pages with full rendering
* Render blog headers using a patched `ratatui-image` to export internal
  `Picker` fields which we use to construct our own picker based on
  guesses for what capabilities a terminal might have based on
  `$TERM_PROGRAM` values
* Move truncate implementation into `content` module and have other
  modules import it
* Add `terminal` module under `tui` for classifying different terminals
  and storing information regarding them
* Update trait `Component::init` to supply a `TerminalInfo`, to help
  components adapt themselves to terminal emulator capabilities
* Move rust toolchain back to stable, now version 1.87
* Increase rustfmt max width and chain width to 95
2025-08-18 19:10:47 +01:00
b878008c25 fix(logging): not logging error when creating app (#2)
Reviewed-on: DevComp/ssh-portfolio#2
Co-authored-by: LukaDev <hi@lukadev.me>
Co-committed-by: LukaDev <hi@lukadev.me>
2025-08-18 18:18:00 +01:00
30 changed files with 2994 additions and 753 deletions

View file

@ -1,4 +1,11 @@
{ {
// Paths to SSH private keys to use for encryption
"private_keys": {
"ssh-rsa": "$DATA_DIR/ssh/id_rsa",
"ecdsa-sha2-nistp256": "$DATA_DIR/ssh/id_ecdsa",
"ssh-ed25519": "$DATA_DIR/ssh/id_ed25519"
},
"keybindings": { "keybindings": {
"Home": { "Home": {
"<q>": "Quit", // Quit the application "<q>": "Quit", // Quit the application

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
Dockerfile
target/
node_modules/
/www/build

2403
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,30 @@ name = "ssh-portfolio"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "no" description = "no"
repository = "https://git.devcomp.xyz/DevComp/ssh-portfolio"
authors = ["Erica Marigold <hi@devcomp.xyz>"] authors = ["Erica Marigold <hi@devcomp.xyz>"]
build = "build.rs" build = "build.rs"
# Taken from https://github.com/microsoft/edit/blob/a3a6f5f/Cargo.toml#L20-L31
# We use `opt-level = "s"` as it significantly reduces binary size.
# We could then use the `#[optimize(speed)]` attribute for spot optimizations.
# Unfortunately, that attribute currently doesn't work on intrinsics such as memset.
[profile.release]
codegen-units = 1 # Reduces binary size by ~2%
debug = "full" # No one needs an undebuggable release binary
lto = true # Reduces binary size by ~14%
opt-level = "s" # Reduces binary size by ~25%
split-debuginfo = "packed" # Generates a separate *.dwp/*.dSYM so the binary can get stripped
strip = "symbols" # See split-debuginfo - allows us to drop the size by ~65%
[profile.dev]
opt-level = 2 # Optimize more than default, see https://github.com/RustCrypto/RSA/issues/144
incremental = true # Improves re-compile times
[features] [features]
# TODO: CLI feature
default = ["blog"] default = ["blog"]
blog = [ blog = [
# Main deps
"dep:atrium-api", "dep:atrium-api",
"dep:atrium-xrpc", "dep:atrium-xrpc",
"dep:atrium-xrpc-client", "dep:atrium-xrpc-client",
@ -17,9 +34,21 @@ blog = [
"dep:reqwest", "dep:reqwest",
"dep:ipld-core", "dep:ipld-core",
"dep:tui-markdown", "dep:tui-markdown",
"dep:chrono" "dep:chrono",
"dep:ratatui-image",
"dep:image",
# Build deps
"dep:atrium-codegen",
"dep:patch-crate"
] ]
[package.metadata.patch]
crates = ["ratatui-image"]
[patch.crates-io]
ratatui-image = { path = "./target/patch/ratatui-image-8.0.1" }
[dependencies] [dependencies]
actix-web = "4.11.0" actix-web = "4.11.0"
anyhow = "1.0.90" anyhow = "1.0.90"
@ -27,7 +56,7 @@ async-trait = "0.1.85"
atrium-api = { version = "0.25.4", optional = true } atrium-api = { version = "0.25.4", optional = true }
atrium-common = { version = "0.1.2", optional = true } atrium-common = { version = "0.1.2", optional = true }
atrium-xrpc = { version = "0.12.3", optional = true } atrium-xrpc = { version = "0.12.3", optional = true }
atrium-xrpc-client = { version = "0.5.14", optional = true } atrium-xrpc-client = { version = "0.5.14", optional = true, default-features = false, features = ["reqwest"] }
better-panic = "0.3.0" better-panic = "0.3.0"
bstr = "1.11.3" bstr = "1.11.3"
chrono = { version = "0.4.41", optional = true } chrono = { version = "0.4.41", optional = true }
@ -42,11 +71,13 @@ clap = { version = "4.5.20", features = [
color-eyre = "0.6.3" color-eyre = "0.6.3"
config = "0.15.14" config = "0.15.14"
crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } crossterm = { version = "0.28.1", features = ["serde", "event-stream"] }
default_variant = "0.1.0"
derive_deref = "1.1.1" derive_deref = "1.1.1"
directories = "5.0.1" directories = "5.0.1"
figlet-rs = "0.1.5" figlet-rs = "0.1.5"
futures = "0.3.31" futures = "0.3.31"
human-panic = "2.0.2" human-panic = "2.0.2"
image = { version = "0.25.6", optional = true }
indoc = "2.0.5" indoc = "2.0.5"
ipld-core = { version = "0.4.2", optional = true } ipld-core = { version = "0.4.2", optional = true }
json5 = "0.4.1" json5 = "0.4.1"
@ -55,11 +86,13 @@ libc = "0.2.161"
mime_guess = "2.0.5" mime_guess = "2.0.5"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
ratatui = { version = "0.29.0", features = ["serde", "macros"] } ratatui = { version = "0.29.0", features = ["serde", "macros"] }
reqwest = { version = "0.12", features = ["rustls-tls"], optional = true } ratatui-image = { version = "8.0.1", optional = true }
reqwest = { version = "0.12", optional = true, default-features = false, features = ["charset", "http2", "rustls-tls"] }
russh = "0.49.2" russh = "0.49.2"
rust-embed = { version = "8.7.2", features = ["actix"] } rust-embed = { version = "8.7.2", features = ["actix"] }
serde = { version = "1.0.211", features = ["derive"] } serde = { version = "1.0.211", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
signal-hook = "0.3.17" signal-hook = "0.3.17"
strip-ansi-escapes = "0.2.0" strip-ansi-escapes = "0.2.0"
strum = { version = "0.26.3", features = ["derive"] } strum = { version = "0.26.3", features = ["derive"] }
@ -69,9 +102,10 @@ tracing = "0.1.40"
tracing-error = "0.2.0" tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
tui-markdown = { version = "0.3.5", optional = true } tui-markdown = { version = "0.3.5", optional = true }
shellexpand = "3.1.1"
[build-dependencies] [build-dependencies]
anyhow = "1.0.90" anyhow = "1.0.90"
atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213" } atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213", optional = true }
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] } patch-crate = { version = "0.1.13", optional = true }
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }

40
Dockerfile Normal file
View file

@ -0,0 +1,40 @@
FROM oven/bun:1.2-alpine AS www
WORKDIR /usr/src/www
COPY www/package.json www/bun.lock .
RUN bun install
COPY www .
RUN bun run build
FROM rust:1.87-alpine3.21 AS builder
RUN apk add --no-cache \
build-base \
git \
pkgconfig \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static
RUN cargo install patch-crate --locked
ARG CARGO_FEATURES="blog"
WORKDIR /usr/src/app
COPY rust-toolchain Cargo.toml Cargo.lock .
COPY patches patches
RUN mkdir src \
&& touch src/lib.rs \
&& echo "fn main() {}" > build.rs \
&& cargo patch-crate \
&& cargo build --locked --release --no-default-features --features $CARGO_FEATURES \
&& strip ./target/release/ssh-portfolio
COPY . .
COPY --from=www /usr/src/www/build www/build
RUN touch build.rs \
&& SKIP_PATCH_CRATE=1 cargo build --locked --release --no-default-features --features $CARGO_FEATURES
FROM scratch AS runner
EXPOSE 80/tcp 22/tcp
COPY --from=builder /usr/src/app/target/release/ssh-portfolio /usr/local/bin/ssh-portfolio
CMD ["/usr/local/bin/ssh-portfolio", "--host", "0.0.0.0"]

View file

@ -1,40 +1,23 @@
use std::env;
use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder};
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
const ATPROTO_LEXICON_DIR: &str = "src/atproto/lexicons"; const ATPROTO_LEXICON_DIR: &str = "src/atproto/lexicons";
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
const ATPROTO_CLIENT_DIR: &str = "src/atproto"; const ATPROTO_CLIENT_DIR: &str = "src/atproto";
const SSH_KEY_ALGOS: &[(&str, Algorithm)] = &[
("rsa.pem", Algorithm::Rsa { hash: None }),
("ed25519.pem", Algorithm::Ed25519),
("ecdsa.pem", Algorithm::Ecdsa { curve: EcdsaCurve::NistP256 }),
];
fn main() -> Result<()> { fn main() -> Result<()> {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/atproto/lexicons"); println!("cargo:rerun-if-changed=src/atproto/lexicons");
println!("cargo:rerun-if-changed=Cargo.toml");
// Generate openSSH host keys #[cfg(feature = "blog")]
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); {
let mut rng = rand_core::OsRng; println!("cargo:rerun-if-changed=patches/");
for (file_name, algo) in SSH_KEY_ALGOS { println!("cargo:rerun-if-env-changed=SKIP_PATCH_CRATE");
let path = out_dir.join(file_name); if std::env::var("SKIP_PATCH_CRATE").is_err() {
if path.exists() { patch_crate::run().expect("Failed while patching");
println!(
"cargo:warning=Skipping existing host key: {:?}",
path.file_stem().unwrap()
);
continue;
} }
let key = PrivateKey::random(&mut rng, algo.to_owned())
.map_err(anyhow::Error::from)?;
key.write_openssh_file(&path, LineEnding::default())?;
} }
// Generate ATProto client with lexicon validation // Generate ATProto client with lexicon validation

View file

@ -0,0 +1,21 @@
diff --git a/src/picker.rs b/src/picker.rs
index a8f4889..b5eaf5a 100644
--- a/src/picker.rs
+++ b/src/picker.rs
@@ -44,11 +44,11 @@ const DEFAULT_BACKGROUND: Rgba<u8> = Rgba([0, 0, 0, 0]);
#[derive(Clone, Debug)]
pub struct Picker {
- font_size: FontSize,
- protocol_type: ProtocolType,
- background_color: Rgba<u8>,
- is_tmux: bool,
- capabilities: Vec<Capability>,
+ pub font_size: FontSize,
+ pub protocol_type: ProtocolType,
+ pub background_color: Rgba<u8>,
+ pub is_tmux: bool,
+ pub capabilities: Vec<Capability>,
}
/// Serde-friendly protocol-type enum for [Picker].

View file

@ -1 +1,3 @@
nightly-2025-07-30 [toolchain]
channel = "nightly-2025-03-28"
components = ["clippy", "rust-analyzer", "cargo", "rustc"]

View file

@ -1,6 +1,6 @@
edition = "2021" edition = "2021"
use_small_heuristics = "Max" use_small_heuristics = "Max"
max_width = 80 max_width = 95
newline_style = "Unix" newline_style = "Unix"
indent_style = "Block" indent_style = "Block"
@ -8,5 +8,6 @@ hard_tabs = false
format_strings = true format_strings = true
brace_style = "PreferSameLine" brace_style = "PreferSameLine"
chain_width = 95
imports_granularity = "Module" imports_granularity = "Module"

View file

@ -1,6 +1,7 @@
use std::fmt; use std::fmt;
use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize}; use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use strum::Display; use strum::Display;
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)]
@ -30,8 +31,8 @@ pub enum Action {
impl<'de> Deserialize<'de> for Action { impl<'de> Deserialize<'de> for Action {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de> where
{ D: Deserializer<'de>, {
struct ActionVisitor; struct ActionVisitor;
impl<'de> Visitor<'de> for ActionVisitor { impl<'de> Visitor<'de> for ActionVisitor {
@ -42,8 +43,8 @@ impl<'de> Deserialize<'de> for Action {
} }
fn visit_str<E>(self, v: &str) -> Result<Action, E> fn visit_str<E>(self, v: &str) -> Result<Action, E>
where E: de::Error where
{ E: de::Error, {
if v == "Continue" { if v == "Continue" {
Ok(Action::Continue(None)) Ok(Action::Continue(None))
} else { } else {

View file

@ -1,5 +1,6 @@
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use color_eyre::{eyre, Result}; use color_eyre::{eyre, Result};
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
@ -14,15 +15,17 @@ use tokio_util::sync::CancellationToken;
use tracing::debug; use tracing::debug;
use crate::action::Action; use crate::action::Action;
use crate::components::*; use crate::{components::*, CONFIG};
use crate::config::Config; use crate::config::Config;
use crate::keycode::KeyCodeExt; use crate::keycode::KeyCodeExt;
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
use crate::tui::{Event, Terminal, Tui}; use crate::tui::{Event, Terminal, Tui};
pub struct App { pub struct App {
config: Config, config: Config,
tick_rate: f64, tick_rate: f64,
frame_rate: f64, frame_rate: f64,
terminal_info: Arc<RwLock<TerminalInfo>>,
should_quit: bool, should_quit: bool,
should_suspend: bool, should_suspend: bool,
@ -44,9 +47,7 @@ pub struct App {
blog_posts: Arc<Mutex<BlogPosts>>, blog_posts: Arc<Mutex<BlogPosts>>,
} }
#[derive( #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub enum Mode { pub enum Mode {
#[default] #[default]
Home, Home,
@ -56,6 +57,7 @@ impl App {
pub const MIN_TUI_DIMS: (u16, u16) = (105, 25); pub const MIN_TUI_DIMS: (u16, u16) = (105, 25);
pub fn new( pub fn new(
terminal_info: Arc<RwLock<TerminalInfo>>,
tick_rate: f64, tick_rate: f64,
frame_rate: f64, frame_rate: f64,
keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>, keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>,
@ -81,13 +83,14 @@ impl App {
))); )));
Ok(Self { Ok(Self {
terminal_info,
tick_rate, tick_rate,
frame_rate, frame_rate,
should_quit: false, should_quit: false,
should_suspend: false, should_suspend: false,
needs_resize: false, needs_resize: false,
config: Config::new()?, config: CONFIG.clone(),
mode: Mode::Home, mode: Mode::Home,
last_tick_key_events: Vec::new(), last_tick_key_events: Vec::new(),
action_tx, action_tx,
@ -104,6 +107,7 @@ impl App {
}) })
} }
#[optimize(speed)]
pub async fn run( pub async fn run(
&mut self, &mut self,
term: Arc<Mutex<Terminal>>, term: Arc<Mutex<Terminal>>,
@ -111,9 +115,7 @@ impl App {
) -> Result<()> { ) -> Result<()> {
let mut tui = tui.write().await; let mut tui = tui.write().await;
let tui = tui.get_or_insert( let tui = tui.get_or_insert(
Tui::new(term)? Tui::new(term)?.tick_rate(self.tick_rate).frame_rate(self.frame_rate),
.tick_rate(self.tick_rate)
.frame_rate(self.frame_rate),
); );
// Force the dimensions to be validated before rendering anything by sending a `Resize` event // Force the dimensions to be validated before rendering anything by sending a `Resize` event
@ -125,43 +127,36 @@ impl App {
tui.enter()?; tui.enter()?;
// Register action handlers // Register action handlers
self.tabs self.tabs.try_lock()?.register_action_handler(self.action_tx.clone())?;
.try_lock()? self.content.try_lock()?.register_action_handler(self.action_tx.clone())?;
.register_action_handler(self.action_tx.clone())?; self.cat.try_lock()?.register_action_handler(self.action_tx.clone())?;
self.content
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
self.cat
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
self.blog_posts self.blog_posts.try_lock()?.register_action_handler(self.action_tx.clone())?;
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
// Register config handlers // Register config handlers
self.tabs self.tabs.try_lock()?.register_config_handler(self.config.clone())?;
.try_lock()? self.content.try_lock()?.register_config_handler(self.config.clone())?;
.register_config_handler(self.config.clone())?; self.cat.try_lock()?.register_config_handler(self.config.clone())?;
self.content
.try_lock()?
.register_config_handler(self.config.clone())?;
self.cat
.try_lock()?
.register_config_handler(self.config.clone())?;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
self.blog_posts self.blog_posts.try_lock()?.register_config_handler(self.config.clone())?;
.try_lock()?
.register_config_handler(self.config.clone())?; for _ in 1..50 {
if matches!(
self.terminal_info.blocking_read().kind(),
TerminalKind::Unsupported(UnsupportedReason::Unprobed)
) {
tracing::debug!("Waiting for 5s for terminal info to be probed");
std::thread::sleep(Duration::from_millis(100));
}
}
// Initialize components // Initialize components
let size = tui.terminal.try_lock()?.size()?; let size = tui.terminal.try_lock()?.size()?;
self.tabs.try_lock()?.init(size)?; self.tabs.try_lock()?.init(self.terminal_info.clone(), size)?;
self.content.try_lock()?.init(size)?; self.content.try_lock()?.init(self.terminal_info.clone(), size)?;
self.cat.try_lock()?.init(self.terminal_info.clone(), size)?;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
self.cat.try_lock()?.init(size)?; self.blog_posts.try_lock()?.init(self.terminal_info.clone(), size)?;
self.blog_posts.try_lock()?.init(size)?;
Ok::<_, eyre::Error>(()) Ok::<_, eyre::Error>(())
})?; })?;
@ -258,17 +253,15 @@ impl App {
Action::Tick => { Action::Tick => {
self.last_tick_key_events.drain(..); self.last_tick_key_events.drain(..);
} }
Action::Quit => { #[cfg(feature = "blog")]
if !self.blog_posts.try_lock()?.is_in_post() { Action::Quit => self.should_quit = !self.blog_posts.try_lock()?.is_in_post(),
self.should_quit = true; #[cfg(not(feature = "blog"))]
} Action::Quit => self.should_quit = true,
}
Action::Suspend => self.should_suspend = true, Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false, Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.try_lock()?.clear()?, Action::ClearScreen => tui.terminal.try_lock()?.clear()?,
Action::Resize(w, h) => { Action::Resize(w, h) => {
self.needs_resize = self.needs_resize = w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1;
w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1;
self.resize(tui, w, h)?; self.resize(tui, w, h)?;
} }
Action::Render => self.render(tui)?, Action::Render => self.render(tui)?,
@ -276,14 +269,10 @@ impl App {
} }
// Update each component // Update each component
if let Some(action) = if let Some(action) = self.tabs.try_lock()?.update(action.clone())? {
self.tabs.try_lock()?.update(action.clone())?
{
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
if let Some(action) = if let Some(action) = self.content.try_lock()?.update(action.clone())? {
self.content.try_lock()?.update(action.clone())?
{
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
if let Some(action) = self.cat.try_lock()?.update(action.clone())? { if let Some(action) = self.cat.try_lock()?.update(action.clone())? {
@ -291,9 +280,7 @@ impl App {
} }
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
if let Some(action) = if let Some(action) = self.blog_posts.try_lock()?.update(action.clone())? {
self.blog_posts.try_lock()?.update(action.clone())?
{
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
} }
@ -345,8 +332,9 @@ impl App {
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget( frame.render_widget(
Paragraph::new( Paragraph::new(
Line::from(error_message.clone()) Line::from(error_message.clone()).style(
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
) )
.alignment(Alignment::Center) .alignment(Alignment::Center)
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
@ -360,9 +348,7 @@ impl App {
term.try_draw(|frame| { term.try_draw(|frame| {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
[Constraint::Length(3), Constraint::Min(0)].as_ref(),
)
.split(frame.area()); .split(frame.area());
// Render the domain name text // Render the domain name text
@ -373,17 +359,11 @@ impl App {
frame.render_widget( frame.render_widget(
title, title,
Rect { Rect { x: chunks[0].x + 2, y: chunks[0].y + 2, width: 14, height: 1 },
x: chunks[0].x + 2,
y: chunks[0].y + 2,
width: 14,
height: 1,
},
); );
// Render the tabs // Render the tabs
let mut tabs = let mut tabs = self.tabs.try_lock().map_err(std::io::Error::other)?;
self.tabs.try_lock().map_err(std::io::Error::other)?;
tabs.draw( tabs.draw(
frame, frame,
@ -439,14 +419,10 @@ impl App {
// If blog feature is not enabled, render a placeholder // If blog feature is not enabled, render a placeholder
content_rect.height = 1; content_rect.height = 1;
let placeholder = Paragraph::new( let placeholder = Paragraph::new(
"Blog feature is disabled. Enable the `blog` feature \ "Blog feature is disabled. Enable the `blog` feature to view this \
to view this tab.", tab.",
) )
.style( .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(placeholder, content_rect); frame.render_widget(placeholder, content_rect);
} }

View file

@ -28,30 +28,20 @@ pub mod blog {
lazy_static! { lazy_static! {
static ref POSTS_CACHE_STORE: MemoryStore<usize, (Instant, com::whtwnd::blog::entry::Record)> = static ref POSTS_CACHE_STORE: MemoryStore<usize, (Instant, com::whtwnd::blog::entry::Record)> =
MemoryStore::default(); MemoryStore::default();
static ref AGENT: Agent< static ref AGENT: Agent<CredentialSession<MemoryStore<(), Object<SessionOutputData>>, ReqwestClient>> =
CredentialSession< Agent::new(CredentialSession::new(
MemoryStore<(), Object<SessionOutputData>>, ReqwestClient::new("https://bsky.social"),
ReqwestClient, MemorySessionStore::default(),
>, ));
> = Agent::new(CredentialSession::new(
ReqwestClient::new("https://bsky.social"),
MemorySessionStore::default(),
));
} }
#[instrument(level = "debug")] #[instrument(level = "debug")]
pub async fn get_all_posts() -> Result<Vec<com::whtwnd::blog::entry::Record>> pub async fn get_all_posts() -> Result<Vec<com::whtwnd::blog::entry::Record>> {
{
let mut i = 0; let mut i = 0;
let mut posts = Vec::new(); let mut posts = Vec::new();
while let Some((cache_creation_time, post)) = while let Some((cache_creation_time, post)) = POSTS_CACHE_STORE.get(&i).await? {
POSTS_CACHE_STORE.get(&i).await?
{
if cache_creation_time.elapsed() > CACHE_INVALIDATION_PERIOD { if cache_creation_time.elapsed() > CACHE_INVALIDATION_PERIOD {
tracing::info!( tracing::info!("Cache for post #{} is stale, fetching new posts", i);
"Cache for post #{} is stale, fetching new posts",
i
);
POSTS_CACHE_STORE.clear().await?; POSTS_CACHE_STORE.clear().await?;
return fetch_posts_into_cache().await; return fetch_posts_into_cache().await;
} }
@ -62,9 +52,7 @@ pub mod blog {
} }
if posts.is_empty() { if posts.is_empty() {
tracing::info!( tracing::info!("No blog posts found in cache, fetching from ATProto");
"No blog posts found in cache, fetching from ATProto"
);
return fetch_posts_into_cache().await; return fetch_posts_into_cache().await;
} }
@ -72,8 +60,7 @@ pub mod blog {
} }
#[instrument(level = "trace")] #[instrument(level = "trace")]
async fn fetch_posts_into_cache( async fn fetch_posts_into_cache() -> Result<Vec<com::whtwnd::blog::entry::Record>> {
) -> Result<Vec<com::whtwnd::blog::entry::Record>> {
let records = &AGENT let records = &AGENT
.api .api
.com .com
@ -100,9 +87,7 @@ pub mod blog {
.map(|elem| { .map(|elem| {
if let Unknown::Object(btree_map) = &elem.data.value { if let Unknown::Object(btree_map) = &elem.data.value {
let ser = serde_json::to_string(&btree_map)?; let ser = serde_json::to_string(&btree_map)?;
let des = serde_json::from_str::< let des = serde_json::from_str::<com::whtwnd::blog::entry::Record>(&ser)?;
com::whtwnd::blog::entry::Record,
>(&ser)?;
return Ok(des); return Ok(des);
} }

View file

@ -18,7 +18,7 @@ pub struct Cli {
#[arg(short = 'H', long, value_name = "ADDRESS", default_value_t = String::from("127.0.0.1"))] #[arg(short = 'H', long, value_name = "ADDRESS", default_value_t = String::from("127.0.0.1"))]
pub host: String, pub host: String,
/// The port to start the SSH server on /// The port to start the SSH server on
#[arg(short = 'P', long, value_name = "PORT", default_value_t = 2222)] #[arg(short = 'P', long, value_name = "PORT", default_value_t = 22)]
pub ssh_port: u16, pub ssh_port: u16,
/// The port to start the web server on /// The port to start the web server on
#[arg(short = 'p', long, value_name = "PORT", default_value_t = 80)] #[arg(short = 'p', long, value_name = "PORT", default_value_t = 80)]

View file

@ -1,11 +1,15 @@
use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent}; use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::{Rect, Size}; use ratatui::layout::{Rect, Size};
use ratatui::Frame; use ratatui::Frame;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action; use crate::action::Action;
use crate::config::Config; use crate::config::Config;
use crate::tui::terminal::TerminalInfo;
use crate::tui::Event; use crate::tui::Event;
// //
@ -41,10 +45,7 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<()>` - An Ok result or an error. /// * `Result<()>` - An Ok result or an error.
fn register_action_handler( fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
let _ = tx; // to appease clippy let _ = tx; // to appease clippy
Ok(()) Ok(())
} }
@ -61,7 +62,7 @@ pub trait Component: Send {
let _ = config; // to appease clippy let _ = config; // to appease clippy
Ok(()) Ok(())
} }
/// Initialize the component with a specified area if necessary. /// Initialize the component with a specified area and terminal kind if necessary.
/// ///
/// # Arguments /// # Arguments
/// ///
@ -70,8 +71,8 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<()>` - An Ok result or an error. /// * `Result<()>` - An Ok result or an error.
fn init(&mut self, area: Size) -> Result<()> { fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, area: Size) -> Result<()> {
let _ = area; // to appease clippy let _ = (area, term_info); // to appease clippy
Ok(()) Ok(())
} }
/// Handle incoming events and produce actions if necessary. /// Handle incoming events and produce actions if necessary.
@ -83,15 +84,10 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<Option<Action>>` - An action to be processed or none. /// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_events( fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> {
&mut self,
event: Option<Event>,
) -> Result<Option<Action>> {
let action = match event { let action = match event {
Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
Some(Event::Mouse(mouse_event)) => { Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?,
self.handle_mouse_event(mouse_event)?
}
_ => None, _ => None,
}; };
Ok(action) Ok(action)
@ -118,10 +114,7 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<Option<Action>>` - An action to be processed or none. /// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_mouse_event( fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
&mut self,
mouse: MouseEvent,
) -> Result<Option<Action>> {
let _ = mouse; // to appease clippy let _ = mouse; // to appease clippy
Ok(None) Ok(None)
} }

View file

@ -1,19 +1,30 @@
use std::io::{BufReader, Cursor};
use std::sync::Arc; use std::sync::Arc;
use color_eyre::eyre::eyre;
use color_eyre::Result; use color_eyre::Result;
use ratatui::widgets::Widget; use image::{ImageReader, Rgba};
use ratatui::layout::{Constraint, Flex, Layout, Rect, Size};
use ratatui::prelude::*;
use ratatui::widgets::*;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use ratatui_image::{Resize, StatefulImage};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action; use crate::action::Action;
use crate::com; use crate::com;
use crate::com::whtwnd::blog::defs::Ogp;
use crate::components::{Component, SelectionList}; use crate::components::{Component, SelectionList};
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason, DEFAULT_FONT_SIZE};
pub type Post = Arc<com::whtwnd::blog::entry::Record>; pub type Post = Arc<com::whtwnd::blog::entry::Record>;
#[derive(Debug)]
pub struct BlogPosts { pub struct BlogPosts {
list: SelectionList<Post>, list: SelectionList<Post>,
posts: Vec<Post>, posts: Vec<Post>,
in_post: Option<usize>, image_renderer: Option<Picker>,
in_post: (Option<StatefulProtocol>, Option<usize>),
} }
impl BlogPosts { impl BlogPosts {
@ -21,28 +32,75 @@ impl BlogPosts {
let posts_ref = posts.to_vec(); let posts_ref = posts.to_vec();
Self { Self {
list: SelectionList::new(posts), list: SelectionList::new(posts),
image_renderer: Some(Picker {
font_size: DEFAULT_FONT_SIZE,
protocol_type: ProtocolType::Halfblocks,
background_color: Rgba([0, 0, 0, 0]),
// NOTE: Multiplexers such as tmux are currently unsupported, we ensure that we have an
// xterm based terminal emulator in ssh.rs, if not, we reject the conection to begin with
is_tmux: false,
capabilities: vec![],
}),
posts: posts_ref, posts: posts_ref,
in_post: None, in_post: (None, None),
} }
} }
pub fn is_in_post(&self) -> bool { pub fn is_in_post(&self) -> bool {
self.in_post.is_some() self.in_post.1.is_some()
}
async fn header_image(&self, img: Ogp) -> Result<StatefulProtocol> {
if let Some(picker) = &self.image_renderer {
let img_blob = reqwest::get(img.url)
.await?
.bytes()
.await?
.iter()
.cloned()
.collect::<Vec<u8>>();
let dyn_img = ImageReader::new(BufReader::new(Cursor::new(img_blob)))
.with_guessed_format()?
.decode()?;
let sized_img = picker.new_resize_protocol(dyn_img);
return Ok(sized_img);
}
Err(eyre!("No image supported renderer initialized"))
} }
} }
impl Component for BlogPosts { impl Component for BlogPosts {
fn register_config_handler( fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, _: Size) -> Result<()> {
&mut self, let locked_info = term_info.blocking_read().clone();
config: crate::config::Config,
) -> Result<()> { if matches!(locked_info.kind(), TerminalKind::Unsupported(UnsupportedReason::Unsized))
{
self.image_renderer = None;
}
if let Some(picker) = &mut self.image_renderer {
picker.capabilities = locked_info.kind().capabilities();
picker.protocol_type = locked_info.kind().as_protocol();
picker.font_size = locked_info.font_size();
tracing::info!(
"Using {:?} rendering protocol for blog image renderer, font size: {:?}",
picker.protocol_type(),
picker.font_size(),
);
}
Ok(())
}
fn register_config_handler(&mut self, config: crate::config::Config) -> Result<()> {
self.list.register_config_handler(config) self.list.register_config_handler(config)
} }
fn register_action_handler( fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.list.register_action_handler(tx) self.list.register_action_handler(tx)
} }
@ -51,11 +109,11 @@ impl Component for BlogPosts {
// safe to unwrap, guaranteed to not be `None` // safe to unwrap, guaranteed to not be `None`
Action::Tick => {} Action::Tick => {}
Action::Render => {} Action::Render => {}
Action::Quit | Action::PrevTab | Action::NextTab => self.in_post = (None, None),
Action::Quit | Action::PrevTab | Action::NextTab => { // FIXME: This makes it possible to scroll through the list with arrow keys even
self.in_post = None // when it is not rendered, which is not ideal; should probably fix later, minor bug
} Action::Continue(post_id) => self.in_post.1 = post_id,
Action::Continue(post_id) => self.in_post = post_id,
_ => {} _ => {}
}; };
@ -67,16 +125,69 @@ impl Component for BlogPosts {
frame: &mut ratatui::Frame, frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect, area: ratatui::prelude::Rect,
) -> Result<()> { ) -> Result<()> {
if let Some(post_id_inner) = self.in_post { if let Some(post_id_inner) = self.in_post.1 {
let post_body = self let post = self
.posts .posts
.get(post_id_inner) .get(post_id_inner)
.map_or(String::from("404 - Blog not found!"), |post| { .ok_or(eyre!("Current post apparently doesn't exist"))?;
post.content.clone()
});
let post_widget = tui_markdown::from_str(&post_body); let post_body = post.title.clone().map_or(post.content.clone(), |title| {
post_widget.render(area, frame.buffer_mut()); format!("# {}\n\n{}", title, post.content)
});
let post_body_widget =
Paragraph::new(tui_markdown::from_str(&post_body)).wrap(Wrap { trim: true });
// FIXME: content in the body often overlaps with the `Cat` component and gets
// formatted weirdly. maybe deal with that at some point? real solution is probably a
// refactor to use `Layout`s instead of rolling our own layout logic
if let Some(img) = self.in_post.0.as_mut() {
// Render prefetched image on current draw call
let [image_area, text_area] =
Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
.flex(Flex::SpaceBetween)
.vertical_margin(2)
.areas(area);
let resized_img = img.size_for(Resize::Fit(None), image_area);
let [image_area] = Layout::horizontal([Constraint::Length(resized_img.width)])
.flex(Flex::Center)
.areas(image_area);
frame.render_stateful_widget(StatefulImage::default(), image_area, img);
frame.render_widget(post_body_widget, text_area);
} else if self.image_renderer.is_some() {
// Image not cached, load image and skip rendering for current draw call
if let Some(ref post_ogp) = post.ogp {
let rt = tokio::runtime::Handle::current();
let img =
rt.block_on(async { self.header_image(post_ogp.clone()).await })?;
self.in_post.0 = Some(img);
} else {
frame.render_widget(
post_body_widget,
Rect::new(area.x + 1, area.y + 1, area.width, area.height),
);
}
} else if let Some(ref post_ogp) = post.ogp {
// No image rendering capabilities, only display text
let img_url = super::truncate(&post_ogp.url, area.width as usize / 3);
let url_widget = Line::from(img_url).centered().style(
Style::default()
.add_modifier(Modifier::BOLD | Modifier::ITALIC)
.fg(Color::Yellow),
);
frame.render_widget(
url_widget,
Rect::new(area.x + 1, area.y + 1, area.width, area.height),
);
frame.render_widget(
post_body_widget,
Rect::new(area.x + 3, area.y + 3, area.width, area.height),
);
}
} else { } else {
self.list.draw(frame, area)?; self.list.draw(frame, area)?;
} }

View file

@ -28,10 +28,7 @@ impl Cat {
} }
impl Component for Cat { impl Component for Cat {
fn register_action_handler( fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx); self.command_tx = Some(tx);
Ok(()) Ok(())
} }
@ -57,12 +54,7 @@ impl Component for Cat {
.fg(Color::Magenta) .fg(Color::Magenta)
.add_modifier(Modifier::SLOW_BLINK | Modifier::BOLD), .add_modifier(Modifier::SLOW_BLINK | Modifier::BOLD),
), ),
Rect { Rect { x: area.width - 17, y: area.height - 4, width: 16, height: 6 },
x: area.width - 17,
y: area.height - 4,
width: 16,
height: 6,
},
); );
Ok(()) Ok(())

View file

@ -14,6 +14,13 @@ use crate::action::Action;
use crate::components::Post; use crate::components::Post;
use crate::config::Config; use crate::config::Config;
#[allow(dead_code)]
pub(super) fn truncate(s: &str, max: usize) -> String {
s.char_indices()
.find(|(idx, ch)| idx + ch.len_utf8() > max)
.map_or(s.to_string(), |(idx, _)| s[..idx].to_string() + "...")
}
#[derive(Default)] #[derive(Default)]
pub struct Content { pub struct Content {
command_tx: Option<UnboundedSender<Action>>, command_tx: Option<UnboundedSender<Action>>,
@ -35,11 +42,8 @@ impl Content {
.ok_or(eyre!("Failed to create figlet header for about page"))? .ok_or(eyre!("Failed to create figlet header for about page"))?
.to_string(); .to_string();
let lines: Vec<String> = greetings_header let lines: Vec<String> =
.trim_end_matches('\n') greetings_header.trim_end_matches('\n').split('\n').map(String::from).collect();
.split('\n')
.map(String::from)
.collect();
let mut content = lines let mut content = lines
.iter() .iter()
@ -54,10 +58,7 @@ impl Content {
"she/they", "she/they",
Style::default().add_modifier(Modifier::ITALIC), Style::default().add_modifier(Modifier::ITALIC),
), ),
Span::from( Span::from("), and I make scalable systems or something. IDFK."),
"), and I make scalable systems or something. \
IDFK.",
),
]); ]);
} }
Line::raw(format!(" {}", line)) Line::raw(format!(" {}", line))
@ -69,9 +70,7 @@ impl Content {
Line::from(""), Line::from(""),
Line::from(vec![ Line::from(vec![
Span::from(" "), Span::from(" "),
Span::from( Span::from("I specialize in systems programming, primarily in "),
"I specialize in systems programming, primarily in ",
),
Span::styled( Span::styled(
"Rust 🦀", "Rust 🦀",
Style::default() Style::default()
@ -89,98 +88,79 @@ impl Content {
]), ]),
Line::from(""), Line::from(""),
Line::from( Line::from(
" I am an avid believer of open-source software, and \ " I am an avid believer of open-source software, and contribute to a few \
contribute to a few projects such as:", projects such as:",
), ),
]); ]);
let projects = vec![ let projects = vec![
( (
Style::default() Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
"lune-org/lune: A standalone Luau runtime", "lune-org/lune: A standalone Luau runtime",
), ),
( (
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
"DiscordLuau/discord-luau: A Luau library for creating \ "DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered \
Discord bots, powered by Lune", by Lune",
), ),
( (
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
"pesde-pkg/pesde: A package manager for the Luau programming \ "pesde-pkg/pesde: A package manager for the Luau programming language, \
language, supporting multiple runtimes including Roblox and \ supporting multiple runtimes including Roblox and Lune",
Lune",
), ),
]; ];
for (style, project) in projects { for (style, project) in projects {
let parts: Vec<&str> = project.splitn(2, ':').collect(); let parts: Vec<&str> = project.splitn(2, ':').collect();
let (left, right) = if parts.len() == 2 { let (left, right) =
(parts[0], parts[1]) if parts.len() == 2 { (parts[0], parts[1]) } else { (project, "") };
} else {
(project, "")
};
let formatted_left = Span::styled(left, style); let formatted_left = Span::styled(left, style);
let bullet = ""; let bullet = "";
let indent = " "; let indent = " ";
let first_line = let first_line = if project.len() > area.width as usize - bullet.len() {
if project.len() > area.width as usize - bullet.len() { let split_point = project
let split_point = project .char_indices()
.char_indices() .take_while(|(i, _)| *i < area.width as usize - bullet.len())
.take_while(|(i, _)| { .last()
*i < area.width as usize - bullet.len() .map(|(i, _)| i)
}) .unwrap_or(project.len());
.last() let (first, rest) = project.split_at(split_point);
.map(|(i, _)| i) content.push(Line::from(vec![
.unwrap_or(project.len()); Span::from(bullet),
let (first, rest) = project.split_at(split_point); formatted_left,
content.push(Line::from(vec![ Span::from(":"),
Span::from(bullet), Span::styled(
formatted_left, first.trim_start_matches(format!("{left}:").as_str()).to_string(),
Span::from(":"), Style::default().fg(Color::White),
Span::styled( ),
first ]));
.trim_start_matches(format!("{left}:").as_str()) rest.to_string()
.to_string(), } else {
Style::default().fg(Color::White), content.push(Line::from(vec![
), Span::from(bullet),
])); formatted_left,
rest.to_string() Span::from(":"),
} else { Span::styled(right.to_string(), Style::default().fg(Color::White)),
content.push(Line::from(vec![ ]));
Span::from(bullet), String::new()
formatted_left, };
Span::from(":"),
Span::styled(
right.to_string(),
Style::default().fg(Color::White),
),
]));
String::new()
};
let mut remaining_text = first_line; let mut remaining_text = first_line;
while !remaining_text.is_empty() { while !remaining_text.is_empty() {
if remaining_text.len() > area.width as usize - indent.len() { if remaining_text.len() > area.width as usize - indent.len() {
let split_point = remaining_text let split_point = remaining_text
.char_indices() .char_indices()
.take_while(|(i, _)| { .take_while(|(i, _)| *i < area.width as usize - indent.len())
*i < area.width as usize - indent.len()
})
.last() .last()
.map(|(i, _)| i) .map(|(i, _)| i)
.unwrap_or(remaining_text.len()); .unwrap_or(remaining_text.len());
let (first, rest) = remaining_text.split_at(split_point); let (first, rest) = remaining_text.split_at(split_point);
content.push(Line::from(vec![ content.push(Line::from(vec![
Span::from(indent), Span::from(indent),
Span::styled( Span::styled(first.to_string(), Style::default().fg(Color::White)),
first.to_string(),
Style::default().fg(Color::White),
),
])); ]));
remaining_text = rest.to_string(); remaining_text = rest.to_string();
} else { } else {
@ -199,8 +179,8 @@ impl Content {
content.extend(vec![ content.extend(vec![
Line::from(""), Line::from(""),
Line::from( Line::from(
" I am also a fan of the 8 bit aesthetic and think seals are \ " I am also a fan of the 8 bit aesthetic and think seals are super adorable \
super adorable :3", :3",
), ),
]); ]);
@ -224,10 +204,7 @@ impl Content {
} }
impl Component for Content { impl Component for Content {
fn register_action_handler( fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx); self.command_tx = Some(tx);
Ok(()) Ok(())
} }
@ -257,9 +234,7 @@ impl Component for Content {
// Create the border lines // Create the border lines
let mut border_top = Line::default(); let mut border_top = Line::default();
border_top border_top.spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
let devcomp_width = 13; let devcomp_width = 13;
border_top.spans.push(Span::styled( border_top.spans.push(Span::styled(
@ -271,17 +246,11 @@ impl Component for Content {
let mut current_pos = 1 + devcomp_width; let mut current_pos = 1 + devcomp_width;
for (i, &tab) in tabs.iter().enumerate() { for (i, &tab) in tabs.iter().enumerate() {
let (char, style) = let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) {
if i == self.selected_tab.load(Ordering::Relaxed) { ("", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
( } else {
"", ("", Style::default().fg(Color::DarkGray))
Style::default() };
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
("", Style::default().fg(Color::DarkGray))
};
let default_style = Style::default().fg(Color::DarkGray); let default_style = Style::default().fg(Color::DarkGray);
@ -299,19 +268,15 @@ impl Component for Content {
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
border_top border_top.spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
let border_bottom = Line::from(Span::styled( let border_bottom = Line::from(Span::styled(
"".to_owned() + &"".repeat(area.width as usize - 2) + "", "".to_owned() + &"".repeat(area.width as usize - 2) + "",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
let border_left = let border_left = Span::styled("", Style::default().fg(Color::DarkGray));
Span::styled("", Style::default().fg(Color::DarkGray)); let border_right = Span::styled("", Style::default().fg(Color::DarkGray));
let border_right =
Span::styled("", Style::default().fg(Color::DarkGray));
// Render the content // Render the content
let content_widget = Paragraph::new(content) let content_widget = Paragraph::new(content)
@ -336,12 +301,7 @@ impl Component for Content {
frame.render_widget( frame.render_widget(
Paragraph::new(border_bottom), Paragraph::new(border_bottom),
Rect { Rect { x: area.x, y: area.y + area.height - 1, width: area.width, height: 1 },
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
},
); );
for i in 1..area.height - 1 { for i in 1..area.height - 1 {
@ -352,12 +312,7 @@ impl Component for Content {
frame.render_widget( frame.render_widget(
Paragraph::new(Line::from(border_right.clone())), Paragraph::new(Line::from(border_right.clone())),
Rect { Rect { x: area.x + area.width - 1, y: area.y + i, width: 1, height: 1 },
x: area.x + area.width - 1,
y: area.y + i,
width: 1,
height: 1,
},
); );
} }

View file

@ -1,20 +1,13 @@
use chrono::DateTime; use chrono::DateTime;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::style::{Color, Modifier, Style}; use ratatui::prelude::*;
use ratatui::text::{Line, Span}; use ratatui::widgets::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action; use crate::action::Action;
use crate::components::{Component, Post}; use crate::components::{Component, Post};
use crate::config::Config; use crate::config::Config;
fn truncate(s: &str, max: usize) -> String {
s.char_indices()
.find(|(idx, ch)| idx + ch.len_utf8() > max)
.map_or(s.to_string(), |(idx, _)| s[..idx].to_string() + "...")
}
#[derive(Debug)] #[derive(Debug)]
pub struct SelectionList<T> { pub struct SelectionList<T> {
config: Config, config: Config,
@ -106,8 +99,13 @@ impl Component for SelectionList<Post> {
]; ];
let subtitle_span = Span::raw( let subtitle_span = Span::raw(
[" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))] [
.concat(), " ",
post.subtitle
.as_ref()
.unwrap_or(&super::truncate(post.content.as_ref(), 40)),
]
.concat(),
); );
list_content.push(Line::from([line_format.as_slice(), &[subtitle_span]].concat())); list_content.push(Line::from([line_format.as_slice(), &[subtitle_span]].concat()));

View file

@ -19,10 +19,7 @@ pub struct Tabs {
} }
impl Tabs { impl Tabs {
pub fn new( pub fn new(tabs: Vec<&'static str>, selected_tab: Arc<AtomicUsize>) -> Self {
tabs: Vec<&'static str>,
selected_tab: Arc<AtomicUsize>,
) -> Self {
Self { tabs, selected_tab, ..Default::default() } Self { tabs, selected_tab, ..Default::default() }
} }
@ -44,10 +41,7 @@ impl Tabs {
} }
impl Component for Tabs { impl Component for Tabs {
fn register_action_handler( fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx); self.command_tx = Some(tx);
Ok(()) Ok(())
} }
@ -84,17 +78,13 @@ impl Component for Tabs {
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
tab_lines[1] tab_lines[1].spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
tab_lines[1].spans.push(Span::styled(format!(" {} ", tab), style)); tab_lines[1].spans.push(Span::styled(format!(" {} ", tab), style));
tab_lines[1] tab_lines[1].spans.push(Span::styled("", Style::default().fg(Color::DarkGray)));
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
} }
let tabs_widget = Paragraph::new(tab_lines) let tabs_widget =
.block(Block::default().borders(Borders::NONE)); Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE));
frame.render_widget( frame.render_widget(
tabs_widget, tabs_widget,

View file

@ -1,18 +1,19 @@
#![allow(dead_code)] // Remove this once you start using the code #![allow(dead_code)] // Remove this once you start using the code
use std::collections::HashMap; use std::collections::HashMap;
use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use std::{env, io};
use color_eyre::Result; use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use derive_deref::{Deref, DerefMut}; use derive_deref::{Deref, DerefMut};
use directories::ProjectDirs; use directories::{ProjectDirs, UserDirs};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use serde::de::Deserializer; use serde::{de::Deserializer, Deserialize};
use serde::Deserialize; use ssh_key::{rand_core, Algorithm, LineEnding, PrivateKey};
use tracing::error; use tracing::{debug, error, info, info_span};
use crate::action::Action; use crate::action::Action;
use crate::app::Mode; use crate::app::Mode;
@ -31,6 +32,8 @@ pub struct AppConfig {
pub struct Config { pub struct Config {
#[serde(default, flatten)] #[serde(default, flatten)]
pub config: AppConfig, pub config: AppConfig,
#[serde(default, deserialize_with = "private_key_deserialize")]
pub private_keys: Vec<PrivateKey>,
#[serde(default)] #[serde(default)]
pub keybindings: KeyBindings, pub keybindings: KeyBindings,
#[serde(default)] #[serde(default)]
@ -38,16 +41,11 @@ pub struct Config {
} }
lazy_static! { lazy_static! {
pub static ref PROJECT_NAME: String = pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
env!("CARGO_CRATE_NAME").to_uppercase().to_string(); pub static ref DATA_DIR: Option<PathBuf> =
pub static ref DATA_FOLDER: Option<PathBuf> = env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from);
env::var(format!("{}_DATA", PROJECT_NAME.clone())) pub static ref CONFIG_DIR: Option<PathBuf> =
.ok() env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from);
.map(PathBuf::from);
pub static ref CONFIG_FOLDER: Option<PathBuf> =
env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
} }
impl Config { impl Config {
@ -55,6 +53,10 @@ impl Config {
let default_config: Config = json5::from_str(CONFIG).unwrap(); let default_config: Config = json5::from_str(CONFIG).unwrap();
let data_dir = get_data_dir(); let data_dir = get_data_dir();
let config_dir = get_config_dir(); let config_dir = get_config_dir();
info!("Using data directory: {}", data_dir.display());
info!("Using config directory: {}", config_dir.display());
let mut builder = config::Config::builder() let mut builder = config::Config::builder()
.set_default("data_dir", data_dir.to_str().unwrap())? .set_default("data_dir", data_dir.to_str().unwrap())?
.set_default("config_dir", config_dir.to_str().unwrap())?; .set_default("config_dir", config_dir.to_str().unwrap())?;
@ -68,19 +70,15 @@ impl Config {
]; ];
let mut found_config = false; let mut found_config = false;
for (file, format) in &config_files { for (file, format) in &config_files {
let source = config::File::from(config_dir.join(file)) let source =
.format(*format) config::File::from(config_dir.join(file)).format(*format).required(false);
.required(false);
builder = builder.add_source(source); builder = builder.add_source(source);
if config_dir.join(file).exists() { if config_dir.join(file).exists() {
found_config = true found_config = true
} }
} }
if !found_config { if !found_config {
error!( error!("No configuration file found. Application may not behave as expected");
"No configuration file found. Application may not behave as \
expected"
);
} }
let mut cfg: Self = builder.build()?.try_deserialize()?; let mut cfg: Self = builder.build()?.try_deserialize()?;
@ -97,13 +95,18 @@ impl Config {
user_styles.entry(style_key.clone()).or_insert(*style); user_styles.entry(style_key.clone()).or_insert(*style);
} }
} }
if cfg.private_keys.is_empty() {
for key in default_config.private_keys {
cfg.private_keys.push(key);
}
}
Ok(cfg) Ok(cfg)
} }
} }
pub fn get_data_dir() -> PathBuf { pub fn get_data_dir() -> PathBuf {
let directory = if let Some(s) = DATA_FOLDER.clone() { let directory = if let Some(s) = DATA_DIR.clone() {
s s
} else if let Some(proj_dirs) = project_directory() { } else if let Some(proj_dirs) = project_directory() {
proj_dirs.data_local_dir().to_path_buf() proj_dirs.data_local_dir().to_path_buf()
@ -114,7 +117,7 @@ pub fn get_data_dir() -> PathBuf {
} }
pub fn get_config_dir() -> PathBuf { pub fn get_config_dir() -> PathBuf {
let directory = if let Some(s) = CONFIG_FOLDER.clone() { let directory = if let Some(s) = CONFIG_DIR.clone() {
s s
} else if let Some(proj_dirs) = project_directory() { } else if let Some(proj_dirs) = project_directory() {
proj_dirs.config_local_dir().to_path_buf() proj_dirs.config_local_dir().to_path_buf()
@ -125,7 +128,61 @@ pub fn get_config_dir() -> PathBuf {
} }
fn project_directory() -> Option<ProjectDirs> { fn project_directory() -> Option<ProjectDirs> {
ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME")) ProjectDirs::from("xyz", "devcomp", env!("CARGO_PKG_NAME"))
}
#[optimize(speed)]
fn private_key_deserialize<'de, D>(deserializer: D) -> Result<Vec<PrivateKey>, D::Error>
where
D: Deserializer<'de>, {
let keys = HashMap::<String, String>::deserialize(deserializer)?
.into_iter()
.map(|(pem_type, pem_or_path)| {
debug!("Loading {} private key", pem_type);
PrivateKey::from_openssh(pem_or_path.as_bytes()).or_else(|_| {
debug!("Failed to parse {} PEM from string, trying as file path", pem_type);
let ud = UserDirs::new().ok_or(ssh_key::Error::Io(io::ErrorKind::NotFound))?;
let expanded_path = PathBuf::from(&*shellexpand::full_with_context_no_errors(
&pem_or_path,
|| ud.home_dir().to_str(),
|var| match var {
"DATA_DIR" => get_data_dir().to_str().map(|s| s.to_string()),
"CONFIG_DIR" => get_config_dir().to_str().map(|s| s.to_string()),
"HOME" => Some(String::from("~")),
_ => None,
},
));
if !expanded_path.exists() {
let span = info_span!("host_keygen", algo = %pem_type, path = %expanded_path.display());
let _lock = span.enter();
if let Some(parent) = expanded_path.parent() {
std::fs::create_dir_all(parent)?;
}
info!("Generating key... (this may take a while)");
let algo = Algorithm::from_str(&pem_type)?;
let key = PrivateKey::random(&mut rand_core::OsRng, algo.to_owned())?;
key.write_openssh_file(&expanded_path, LineEnding::default())?;
return Ok(key);
}
PrivateKey::read_openssh_file(&expanded_path)
})
})
.collect::<Result<Vec<PrivateKey>, ssh_key::Error>>()
.map_err(serde::de::Error::custom)
.inspect_err(|err| error!("Loading private keys error: {}", err))?;
if keys.is_empty() {
return Err(serde::de::Error::custom("No valid private keys found in configuration"))
.inspect_err(|err| error!("{}", err));
}
Ok(keys)
} }
#[derive(Clone, Debug, Default, Deref, DerefMut)] #[derive(Clone, Debug, Default, Deref, DerefMut)]
@ -135,18 +192,14 @@ impl<'de> Deserialize<'de> for KeyBindings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: Deserializer<'de>, { D: Deserializer<'de>, {
let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize( let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
deserializer,
)?;
let keybindings = parsed_map let keybindings = parsed_map
.into_iter() .into_iter()
.map(|(mode, inner_map)| { .map(|(mode, inner_map)| {
let converted_inner_map = inner_map let converted_inner_map = inner_map
.into_iter() .into_iter()
.map(|(key_str, cmd)| { .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
(parse_key_sequence(&key_str).unwrap(), cmd)
})
.collect(); .collect();
(mode, converted_inner_map) (mode, converted_inner_map)
}) })
@ -301,9 +354,7 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String {
} }
pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> { pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
if raw.chars().filter(|c| *c == '>').count() if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
!= raw.chars().filter(|c| *c == '<').count()
{
return Err(format!("Unable to parse `{}`", raw)); return Err(format!("Unable to parse `{}`", raw));
} }
let raw = if !raw.contains("><") { let raw = if !raw.contains("><") {
@ -336,9 +387,7 @@ impl<'de> Deserialize<'de> for Styles {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: Deserializer<'de>, { D: Deserializer<'de>, {
let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize( let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
deserializer,
)?;
let styles = parsed_map let styles = parsed_map
.into_iter() .into_iter()
@ -405,16 +454,12 @@ fn parse_color(s: &str) -> Option<Color> {
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default(); let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c)) Some(Color::Indexed(c))
} else if s.contains("gray") { } else if s.contains("gray") {
let c = 232 let c = 232 + s.trim_start_matches("gray").parse::<u8>().unwrap_or_default();
+ s.trim_start_matches("gray").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c)) Some(Color::Indexed(c))
} else if s.contains("rgb") { } else if s.contains("rgb") {
let red = let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
(s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
let green = let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
(s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
let blue =
(s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
let c = 16 + red * 36 + green * 6 + blue; let c = 16 + red * 36 + green * 6 + blue;
Some(Color::Indexed(c)) Some(Color::Indexed(c))
} else if s == "bold black" { } else if s == "bold black" {
@ -487,8 +532,7 @@ mod tests {
#[test] #[test]
fn test_process_color_string() { fn test_process_color_string() {
let (color, modifiers) = let (color, modifiers) = process_color_string("underline bold inverse gray");
process_color_string("underline bold inverse gray");
assert_eq!(color, "gray"); assert_eq!(color, "gray");
assert!(modifiers.contains(Modifier::UNDERLINED)); assert!(modifiers.contains(Modifier::UNDERLINED));
assert!(modifiers.contains(Modifier::BOLD)); assert!(modifiers.contains(Modifier::BOLD));
@ -562,18 +606,12 @@ mod tests {
fn test_multiple_modifiers() { fn test_multiple_modifiers() {
assert_eq!( assert_eq!(
parse_key_event("ctrl-alt-a").unwrap(), parse_key_event("ctrl-alt-a").unwrap(),
KeyEvent::new( KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::ALT
)
); );
assert_eq!( assert_eq!(
parse_key_event("ctrl-shift-enter").unwrap(), parse_key_event("ctrl-shift-enter").unwrap(),
KeyEvent::new( KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
KeyCode::Enter,
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)
); );
} }

View file

@ -21,9 +21,8 @@ pub fn init() -> Result<()> {
let metadata = metadata!(); let metadata = metadata!();
let file_path = handle_dump(&metadata, panic_info); let file_path = handle_dump(&metadata, panic_info);
// prints human-panic message // prints human-panic message
print_msg(file_path, &metadata).expect( print_msg(file_path, &metadata)
"human-panic: printing error message to console failed", .expect("human-panic: printing error message to console failed");
);
eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
} }
let msg = format!("{}", panic_hook.panic_report(panic_info)); let msg = format!("{}", panic_hook.panic_report(panic_info));

View file

@ -99,15 +99,9 @@ impl KeyCodeExt for KeyCode {
fn into_key_event(self) -> KeyEvent { fn into_key_event(self) -> KeyEvent {
match self { match self {
Self::Char(CTRL_C) => { Self::Char(CTRL_C) => KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) Self::Char(CTRL_D) => KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
} Self::Char(CTRL_Z) => KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
Self::Char(CTRL_D) => {
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
}
Self::Char(CTRL_Z) => {
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)
}
other => KeyEvent::new(other, KeyModifiers::empty()), other => KeyEvent::new(other, KeyModifiers::empty()),
} }
} }

View file

@ -10,11 +10,7 @@ macro_rules! embedded_route {
($path:expr) => { ($path:expr) => {
match WebLandingServer::get($path) { match WebLandingServer::get($path) {
Some(content) => HttpResponse::Ok() Some(content) => HttpResponse::Ok()
.content_type( .content_type(mime_guess::from_path($path).first_or_octet_stream().as_ref())
mime_guess::from_path($path)
.first_or_octet_stream()
.as_ref(),
)
.body(content.data.into_owned()), .body(content.data.into_owned()),
None => HttpResponse::NotFound().body("404 Not Found"), None => HttpResponse::NotFound().body("404 Not Found"),
} }
@ -45,11 +41,7 @@ impl WebLandingServer {
tracing::info!("Web server listening on {}", addr); tracing::info!("Web server listening on {}", addr);
HttpServer::new(|| { HttpServer::new(|| {
// TODO: register a default service for a nicer 404 page // TODO: register a default service for a nicer 404 page
App::new() App::new().service(index).service(favicon).service(dist).wrap(Logger::default())
.service(index)
.service(favicon)
.service(dist)
.wrap(Logger::default())
}) })
.bind(addr)? .bind(addr)?
.run() .run()

View file

@ -30,20 +30,16 @@ pub fn init() -> Result<()> {
// //
// Stage 1: Construct base filter // Stage 1: Construct base filter
let env_filter = EnvFilter::builder().with_default_directive( let env_filter = EnvFilter::builder().with_default_directive(if cfg!(debug_assertions) {
if cfg!(debug_assertions) { tracing::Level::DEBUG.into()
tracing::Level::DEBUG.into() } else {
} else { tracing::Level::INFO.into()
tracing::Level::INFO.into() });
},
);
// Stage 2: Attempt to read from {RUST|CRATE_NAME}_LOG env var or ignore // Stage 2: Attempt to read from {RUST|CRATE_NAME}_LOG env var or ignore
let env_filter = env_filter let env_filter = env_filter
.try_from_env() .try_from_env()
.unwrap_or_else(|_| { .unwrap_or_else(|_| env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy())
env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy()
})
.add_directive("russh::cipher=info".parse().unwrap()) .add_directive("russh::cipher=info".parse().unwrap())
.add_directive("tui_markdown=info".parse().unwrap()); .add_directive("tui_markdown=info".parse().unwrap());
@ -86,9 +82,7 @@ pub fn init() -> Result<()> {
let layer = layer let layer = layer
.compact() .compact()
.without_time() .without_time()
.with_span_events( .with_span_events(tracing_subscriber::fmt::format::FmtSpan::NONE)
tracing_subscriber::fmt::format::FmtSpan::NONE,
)
.with_target(false) .with_target(false)
.with_thread_ids(false); .with_thread_ids(false);
layer layer

View file

@ -1,3 +1,5 @@
#![feature(optimize_attribute)]
use std::net::SocketAddr; use std::net::SocketAddr;
use clap::Parser as _; use clap::Parser as _;
@ -5,8 +7,7 @@ use cli::Cli;
use color_eyre::eyre::eyre; use color_eyre::eyre::eyre;
use color_eyre::Result; use color_eyre::Result;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use russh::keys::PrivateKey; use russh::server::Config as SshConfig;
use russh::server::Config;
use russh::MethodSet; use russh::MethodSet;
use ssh::SshServer; use ssh::SshServer;
@ -14,7 +15,9 @@ use ssh::SshServer;
pub(crate) use atproto::com; pub(crate) use atproto::com;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
pub(crate) use atrium_api::*; pub(crate) use atrium_api::*;
use tracing::instrument;
use crate::config::Config;
use crate::landing::WebLandingServer; use crate::landing::WebLandingServer;
mod action; mod action;
@ -31,14 +34,9 @@ mod logging;
mod ssh; mod ssh;
mod tui; mod tui;
const SSH_KEYS: &[&[u8]] = &[
include_bytes!(concat!(env!("OUT_DIR"), "/rsa.pem")),
include_bytes!(concat!(env!("OUT_DIR"), "/ecdsa.pem")),
include_bytes!(concat!(env!("OUT_DIR"), "/ed25519.pem")),
];
#[rustfmt::skip] #[rustfmt::skip]
lazy_static! { lazy_static! {
pub(crate) static ref CONFIG: Config = Config::new().expect("Config loading error, see above");
pub(crate) static ref OPTIONS: Cli = Cli::parse(); pub(crate) static ref OPTIONS: Cli = Cli::parse();
pub(crate) static ref SSH_SOCKET_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.ssh_port))); pub(crate) static ref SSH_SOCKET_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.ssh_port)));
pub(crate) static ref WEB_SERVER_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.web_port))); pub(crate) static ref WEB_SERVER_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.web_port)));
@ -50,14 +48,30 @@ async fn main() -> Result<()> {
crate::logging::init()?; crate::logging::init()?;
let _ = *OPTIONS; // force clap to run by evaluating it let _ = *OPTIONS; // force clap to run by evaluating it
let ssh_socket_addr = let ssh_socket_addr = SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?; let web_server_addr = WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
let web_server_addr =
WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
tokio::select! { let local_set = tokio::task::LocalSet::new();
ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res, loop {
web_res = WebLandingServer::start(web_server_addr) => web_res.map_err(|err| eyre!(err)), let task = local_set.run_until(async {
tokio::task::spawn_local(async move {
let ssh_config = ssh_config();
tokio::select! {
ssh_res = SshServer::start(ssh_socket_addr, ssh_config) => ssh_res,
web_res = WebLandingServer::start(web_server_addr) => web_res.map_err(|err| eyre!(err))
}
}).await
});
match task.await {
Ok(res) => break res,
Err(err) => {
if err.is_panic() {
tracing::error!("Application panicked, recovering...");
std::panic::resume_unwind(err.into_panic());
}
}
}
} }
} }
@ -68,26 +82,24 @@ pub fn host_ip() -> Result<[u8; 4]> {
.host .host
.splitn(4, ".") .splitn(4, ".")
.map(|octet_str| { .map(|octet_str| {
octet_str.parse::<u8>().map_err(|_| { octet_str
eyre!("Octet component out of range (expected u8)") .parse::<u8>()
}) .map_err(|_| eyre!("Octet component out of range (expected u8)"))
}) })
.collect::<Result<Vec<u8>>>()?, .collect::<Result<Vec<u8>>>()?,
) )
.map_err(|_| eyre!("Invalid host IP provided")) .map_err(|_| eyre!("Invalid host IP provided"))
} }
fn ssh_config() -> Config { #[instrument]
let conf = Config { fn ssh_config() -> SshConfig {
let conf = SshConfig {
methods: MethodSet::NONE, methods: MethodSet::NONE,
keys: SSH_KEYS keys: CONFIG.private_keys.clone(),
.to_vec()
.iter()
.filter_map(|pem| PrivateKey::from_openssh(pem).ok())
.collect(),
..Default::default() ..Default::default()
}; };
tracing::trace!("SSH config: {:#?}", conf); tracing::info!("SSH will use {} host keys", conf.keys.len());
tracing::trace!("SSH config: {:?}", conf);
conf conf
} }

View file

@ -13,6 +13,7 @@ use tracing::instrument;
use crate::app::App; use crate::app::App;
use crate::tui::backend::SshBackend; use crate::tui::backend::SshBackend;
use crate::tui::terminal::{TerminalInfo, TerminalKind};
use crate::tui::{Terminal, Tui}; use crate::tui::{Terminal, Tui};
use crate::OPTIONS; use crate::OPTIONS;
@ -31,6 +32,7 @@ impl TermWriter {
Self { session, channel, inner: CryptoVec::new() } Self { session, channel, inner: CryptoVec::new() }
} }
#[optimize(speed)]
fn flush_inner(&mut self) -> std::io::Result<()> { fn flush_inner(&mut self) -> std::io::Result<()> {
let handle = TokioHandle::current(); let handle = TokioHandle::current();
handle.block_on(async move { handle.block_on(async move {
@ -49,16 +51,15 @@ impl TermWriter {
impl Write for TermWriter { impl Write for TermWriter {
#[instrument(skip(self, buf), level = "debug")] #[instrument(skip(self, buf), level = "debug")]
#[optimize(speed)]
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
tracing::trace!( tracing::trace!("Writing {} bytes into SSH terminal writer buffer", buf.len());
"Writing {} bytes into SSH terminal writer buffer",
buf.len()
);
self.inner.extend(buf); self.inner.extend(buf);
Ok(buf.len()) Ok(buf.len())
} }
#[instrument(skip(self), level = "trace")] #[instrument(skip(self), level = "trace")]
#[optimize(speed)]
fn flush(&mut self) -> std::io::Result<()> { fn flush(&mut self) -> std::io::Result<()> {
tracing::trace!("Flushing SSH terminal writer buffer"); tracing::trace!("Flushing SSH terminal writer buffer");
tokio::task::block_in_place(|| self.flush_inner()) tokio::task::block_in_place(|| self.flush_inner())
@ -67,6 +68,7 @@ impl Write for TermWriter {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub struct SshSession { pub struct SshSession {
terminal_info: Arc<RwLock<TerminalInfo>>,
app: Option<Arc<Mutex<App>>>, app: Option<Arc<Mutex<App>>>,
keystroke_tx: mpsc::UnboundedSender<Vec<u8>>, keystroke_tx: mpsc::UnboundedSender<Vec<u8>>,
resize_tx: mpsc::UnboundedSender<(u16, u16)>, resize_tx: mpsc::UnboundedSender<(u16, u16)>,
@ -81,8 +83,12 @@ impl SshSession {
let (resize_tx, resize_rx) = mpsc::unbounded_channel(); let (resize_tx, resize_rx) = mpsc::unbounded_channel();
let (init_dims_tx, init_dims_rx) = oneshot::channel(); let (init_dims_tx, init_dims_rx) = oneshot::channel();
let term_info = Arc::new(RwLock::new(TerminalInfo::default()));
Self { Self {
terminal_info: Arc::clone(&term_info),
app: App::new( app: App::new(
term_info,
OPTIONS.tick_rate, OPTIONS.tick_rate,
OPTIONS.frame_rate, OPTIONS.frame_rate,
keystroke_rx, keystroke_rx,
@ -101,16 +107,13 @@ impl SshSession {
async fn run_app( async fn run_app(
app: Arc<Mutex<App>>, app: Arc<Mutex<App>>,
writer: Arc<Mutex<Terminal>>, term: Arc<Mutex<Terminal>>,
tui: Arc<RwLock<Option<Tui>>>, tui: Arc<RwLock<Option<Tui>>>,
session: &Handle, session: &Handle,
channel_id: ChannelId, channel_id: ChannelId,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
app.lock_owned().await.run(writer, tui).await?; app.lock_owned().await.run(term, tui).await?;
session session.close(channel_id).await.map_err(|_| eyre!("failed to close session"))?;
.close(channel_id)
.await
.map_err(|_| eyre!("failed to close session"))?;
session session
.exit_status_request(channel_id, 0) .exit_status_request(channel_id, 0)
.await .await
@ -143,26 +146,28 @@ impl Handler for SshSession {
tracing::info!("Serving app to open session"); tracing::info!("Serving app to open session");
tokio::task::spawn(async move { tokio::task::spawn(async move {
let result = async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> { let result =
let ((term_width, term_height), (pixel_width, pixel_height)) = rx.await?; async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new( let ((term_width, term_height), (pixel_width, pixel_height)) =
TermWriter::new(session_handle.clone(), channel), rx.await?;
term_width, let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new(
term_height, TermWriter::new(session_handle.clone(), channel),
pixel_width, term_width,
pixel_height, term_height,
))?)); pixel_width,
pixel_height,
))?));
Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await?; Self::run_app(inner_app, writer, tui, &session_handle, channel_id)
Ok(()) .await?;
}; Ok(())
};
match result().await { match result().await {
Ok(()) => tracing::info!("Session exited successfully"), Ok(()) => tracing::info!("Session exited successfully"),
Err(err) => { Err(err) => {
tracing::error!("Session errored: {err}"); tracing::error!("Session errored: {err}");
let _ = let _ = session_handle.channel_failure(channel_id).await;
session_handle.channel_failure(channel_id).await;
} }
} }
}); });
@ -173,6 +178,31 @@ impl Handler for SshSession {
Err(eyre!("Failed to initialize App for session")) Err(eyre!("Failed to initialize App for session"))
} }
#[instrument(level = "debug", skip(self, _session), fields(channel_id = %_channel_id))]
async fn env_request(
&mut self,
_channel_id: ChannelId,
variable_name: &str,
variable_value: &str,
_session: &mut Session,
) -> Result<(), Self::Error> {
// FIXME: currently, terminals which don't set `$TERM_PROGRAM` just get stuck in the
// polling loop forever where we wait for the type to be probed, a workaround is to force
// set the variable to an empty string or something invalid:
//
// `TERM_PROGRAM="" ssh -o SendEnv=TERM_PROGRAM devcomp.xyz`
if variable_name == "TERM_PROGRAM" {
self.terminal_info
.write()
.await
.set_kind(TerminalKind::from_term_program(variable_value));
tracing::info!("Terminal program found: {:?}", self.terminal_info);
}
Ok(())
}
#[instrument(skip_all, fields(channel_id = %channel_id))] #[instrument(skip_all, fields(channel_id = %channel_id))]
async fn pty_request( async fn pty_request(
&mut self, &mut self,
@ -186,10 +216,20 @@ impl Handler for SshSession {
session: &mut Session, session: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
tracing::info!("PTY requested by terminal: {term}"); tracing::info!("PTY requested by terminal: {term}");
tracing::debug!( tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
"dims: {col_width} * {row_height}, pixel: {pix_width} * \
{pix_height}" #[cfg(feature = "blog")]
); if pix_width != 0 && pix_height != 0 {
self.terminal_info.write().await.set_font_size((
(pix_width / col_width).try_into().or(Err(eyre!("Terminal too wide")))?,
(pix_height / row_height).try_into().or(Err(eyre!("Terminal too tall")))?,
));
} else {
self.terminal_info
.write()
.await
.set_kind(TerminalKind::Unsupported(crate::tui::terminal::UnsupportedReason::Unsized));
}
if !term.contains("xterm") { if !term.contains("xterm") {
session.channel_failure(channel_id)?; session.channel_failure(channel_id)?;
@ -218,26 +258,31 @@ impl Handler for SshSession {
data: &[u8], data: &[u8],
_session: &mut Session, _session: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
tracing::debug!( tracing::debug!("Received keystroke data from SSH: {:?}, sending", data);
"Received keystroke data from SSH: {:?}, sending",
data
);
self.keystroke_tx self.keystroke_tx
.send(data.to_vec()) .send(data.to_vec())
.map_err(|_| eyre!("Failed to send event keystroke data")) .map_err(|_| eyre!("Failed to send event keystroke data"))
} }
#[instrument(skip_all, fields(channel_id = %_channel_id))]
async fn window_change_request( async fn window_change_request(
&mut self, &mut self,
_: ChannelId, _channel_id: ChannelId,
col_width: u32, col_width: u32,
row_height: u32, row_height: u32,
_: u32, pix_width: u32,
_: u32, pix_height: u32,
_: &mut Session, _: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
// TODO: actually make it resize properly tracing::info!("Terminal window resized by client, notifying components");
// That would involve first updating the Backend's size and then updating the rect via the event tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
#[cfg(feature = "blog")]
self.terminal_info.write().await.set_font_size((
(pix_width / col_width).try_into().or(Err(eyre!("Terminal too wide")))?,
(pix_height / row_height).try_into().or(Err(eyre!("Terminal too tall")))?,
));
self.resize_tx self.resize_tx
.send((col_width as u16, row_height as u16)) .send((col_width as u16, row_height as u16))
.map_err(|_| eyre!("Failed to send pty size specifications"))?; .map_err(|_| eyre!("Failed to send pty size specifications"))?;
@ -254,9 +299,7 @@ impl SshServer {
pub async fn start(addr: SocketAddr, config: Config) -> eyre::Result<()> { pub async fn start(addr: SocketAddr, config: Config) -> eyre::Result<()> {
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
Self.run_on_socket(Arc::new(config), &listener) Self.run_on_socket(Arc::new(config), &listener).await.map_err(|err| eyre!(err))
.await
.map_err(|err| eyre!(err))
} }
} }

View file

@ -35,6 +35,7 @@ impl Backend for SshBackend {
Ok(Size { width: self.dims.0, height: self.dims.1 }) Ok(Size { width: self.dims.0, height: self.dims.1 })
} }
#[optimize(speed)]
fn draw<'a, I>(&mut self, content: I) -> io::Result<()> fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where where
I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>, { I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>, {
@ -49,9 +50,7 @@ impl Backend for SshBackend {
self.inner.show_cursor() self.inner.show_cursor()
} }
fn get_cursor_position( fn get_cursor_position(&mut self) -> io::Result<ratatui::prelude::Position> {
&mut self,
) -> io::Result<ratatui::prelude::Position> {
self.inner.get_cursor_position() self.inner.get_cursor_position()
} }
@ -73,6 +72,7 @@ impl Backend for SshBackend {
}) })
} }
#[optimize(speed)]
fn flush(&mut self) -> io::Result<()> { fn flush(&mut self) -> io::Result<()> {
Backend::flush(&mut self.inner) Backend::flush(&mut self.inner)
} }

View file

@ -7,8 +7,8 @@ use backend::SshBackend;
use color_eyre::Result; use color_eyre::Result;
use crossterm::cursor; use crossterm::cursor;
use crossterm::event::{ use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
EnableMouseCapture, KeyEvent, MouseEvent, KeyEvent, MouseEvent,
}; };
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -23,6 +23,7 @@ use tracing::error;
pub(crate) mod backend; pub(crate) mod backend;
pub(crate) mod status; pub(crate) mod status;
pub(crate) mod terminal;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event { pub enum Event {
@ -110,6 +111,7 @@ impl Tui {
}); });
} }
#[optimize(speed)]
async fn event_loop( async fn event_loop(
status: Arc<RwLock<TuiStatus>>, status: Arc<RwLock<TuiStatus>>,
event_tx: UnboundedSender<Event>, event_tx: UnboundedSender<Event>,
@ -117,10 +119,8 @@ impl Tui {
tick_rate: f64, tick_rate: f64,
frame_rate: f64, frame_rate: f64,
) { ) {
let mut tick_interval = let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
interval(Duration::from_secs_f64(1.0 / tick_rate)); let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
let mut render_interval =
interval(Duration::from_secs_f64(1.0 / frame_rate));
// if this fails, then it's likely a bug in the calling code // if this fails, then it's likely a bug in the calling code
event_tx.send(Event::Init).expect("failed to send init event"); event_tx.send(Event::Init).expect("failed to send init event");
@ -161,14 +161,9 @@ impl Tui {
}; };
if timeout(attempt_timeout, self.await_shutdown()).await.is_err() { if timeout(attempt_timeout, self.await_shutdown()).await.is_err() {
timeout(attempt_timeout, abort_shutdown).await.inspect_err( timeout(attempt_timeout, abort_shutdown).await.inspect_err(|_| {
|_| { error!("Failed to abort task in 100 milliseconds for unknown reason")
error!( })?;
"Failed to abort task in 100 milliseconds for unknown \
reason"
)
},
)?;
} }
Ok(()) Ok(())
@ -177,11 +172,7 @@ impl Tui {
pub fn enter(&mut self) -> Result<()> { pub fn enter(&mut self) -> Result<()> {
let mut term = self.terminal.try_lock()?; let mut term = self.terminal.try_lock()?;
// crossterm::terminal::enable_raw_mode()?; // TODO: Enable raw mode for pty // crossterm::terminal::enable_raw_mode()?; // TODO: Enable raw mode for pty
crossterm::execute!( crossterm::execute!(term.backend_mut(), EnterAlternateScreen, cursor::Hide)?;
term.backend_mut(),
EnterAlternateScreen,
cursor::Hide
)?;
if self.mouse { if self.mouse {
crossterm::execute!(term.backend_mut(), EnableMouseCapture)?; crossterm::execute!(term.backend_mut(), EnableMouseCapture)?;
@ -211,11 +202,7 @@ impl Tui {
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?; crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
} }
crossterm::execute!( crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
term.backend_mut(),
LeaveAlternateScreen,
cursor::Show
)?;
Ok(()) Ok(())
} }
@ -230,8 +217,7 @@ impl Tui {
// Update the status and initialize a cancellation token // Update the status and initialize a cancellation token
let token = Arc::new(CancellationToken::new()); let token = Arc::new(CancellationToken::new());
let suspension = Arc::new(Mutex::new(())); let suspension = Arc::new(Mutex::new(()));
*self.status.write().await = *self.status.write().await = TuiStatus::Suspended(Arc::clone(&suspension));
TuiStatus::Suspended(Arc::clone(&suspension));
// Spawn a task holding on the lock until a notification interrupts it // Spawn a task holding on the lock until a notification interrupts it
let status = Arc::clone(&self.status); let status = Arc::clone(&self.status);
@ -262,9 +248,7 @@ impl Drop for Tui {
block_in_place(|| { block_in_place(|| {
let handle = Handle::current(); let handle = Handle::current();
let _ = handle.block_on(async { let _ = handle.block_on(async {
self.exit() self.exit().await.inspect_err(|err| error!("Failed to exit Tui: {err}"))
.await
.inspect_err(|err| error!("Failed to exit Tui: {err}"))
}); });
}) })
} }

163
src/tui/terminal.rs Normal file
View file

@ -0,0 +1,163 @@
use std::default::Default;
use default_variant::default;
use serde::{Deserialize, Serialize};
use strum::Display;
#[cfg(feature = "blog")]
use ratatui_image::{
picker::{Capability, ProtocolType},
FontSize,
};
#[cfg(feature = "blog")]
pub const DEFAULT_FONT_SIZE: FontSize = (12, 12);
#[derive(Debug, Default, Clone)]
pub struct TerminalInfo {
kind: TerminalKind,
#[cfg(feature = "blog")]
font_size: Option<FontSize>,
}
impl TerminalInfo {
/// Get the terminal kind.
pub fn kind(&self) -> &TerminalKind {
&self.kind
}
/// Get the font size.
#[cfg(feature = "blog")]
pub fn font_size(&self) -> FontSize {
self.font_size.unwrap_or(DEFAULT_FONT_SIZE)
}
/// Sets the terminal kind, if currently unset (i.e., unprobed).
pub fn set_kind(&mut self, kind: TerminalKind) {
if matches!(self.kind, TerminalKind::Unsupported(UnsupportedReason::Unprobed)) {
self.kind = kind;
}
}
/// Sets the font size.
#[cfg(feature = "blog")]
pub fn set_font_size(&mut self, font_size: FontSize) {
self.font_size = Some(font_size);
}
}
#[derive(Debug, Deserialize, Serialize, Display, Clone /*, Copy */)]
#[default(Unsupported(UnsupportedReason::default()))]
#[strum(serialize_all = "lowercase")]
pub enum TerminalKind {
Ghostty,
Hyper,
ITerm2,
Kitty,
MinTty,
Rio,
Tabby,
Vscode,
Wezterm,
Unsupported(UnsupportedReason),
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy)]
pub enum UnsupportedReason {
/// Terminal emulator does not provide real pixel size, making it impossible to calculate
/// font size.
///
/// Currently known terminal emulators which exhibit this behavior:
///
/// - VSCode
Unsized,
/// Terminal emulator is not known.
Unknown,
/// Terminal emulator has not been detected yet. This is only set during SSH initialization.
#[default]
Unprobed,
}
impl TerminalKind {
pub const ALL_SUPPORTED: [Self; 9] = [
Self::Ghostty,
Self::Hyper,
Self::ITerm2,
Self::Kitty,
Self::MinTty,
Self::Rio,
Self::Tabby,
Self::Vscode,
Self::Wezterm,
];
pub fn from_term_program(program: &str) -> Self {
let terminals = [
("ghostty", Self::Ghostty),
("iTerm.app", Self::ITerm2),
("iTerm2", Self::ITerm2),
("WezTerm", Self::Wezterm),
("mintty", Self::MinTty),
("vscode", Self::Vscode),
("Tabby", Self::Tabby),
("Hyper", Self::Hyper),
("rio", Self::Rio),
];
for (term, variant) in terminals {
if program.contains(term) {
return variant;
}
}
Self::Unsupported(UnsupportedReason::Unknown)
}
pub fn supported() -> String {
Self::ALL_SUPPORTED.map(|term| term.to_string()).join(", ")
}
#[cfg(feature = "blog")]
pub fn capabilities(&self) -> Vec<Capability> {
match *self {
Self::Hyper | Self::Vscode => vec![Capability::RectangularOps],
Self::Ghostty => vec![Capability::Kitty, Capability::RectangularOps],
Self::Tabby | Self::MinTty => vec![Capability::Sixel, Capability::RectangularOps],
Self::Rio => vec![Capability::Sixel, Capability::RectangularOps],
Self::ITerm2 | Self::Wezterm => {
vec![Capability::Sixel, Capability::Kitty, Capability::RectangularOps]
}
Self::Kitty => vec![
Capability::Kitty,
Capability::RectangularOps,
Capability::TextSizingProtocol, // !! TODO: THIS COULD BE SO FUCKING COOL FOR MARKDOWN HEADINGS !!
],
Self::Unsupported(_) => vec![],
}
}
#[cfg(feature = "blog")]
pub fn as_protocol(&self) -> ProtocolType {
if matches!(
self,
Self::ITerm2
| Self::Wezterm
| Self::MinTty
| Self::Vscode
| Self::Tabby
| Self::Hyper
| Self::Rio
) {
return ProtocolType::Iterm2;
} else if self.capabilities().contains(&Capability::Kitty) {
return ProtocolType::Kitty;
} else if self.capabilities().contains(&Capability::Sixel) {
return ProtocolType::Sixel;
}
ProtocolType::Halfblocks
}
}

View file

@ -4,7 +4,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { crossfade, fade } from 'svelte/transition'; import { crossfade, fade } from 'svelte/transition';
const command = 'ssh devcomp.xyz'; const command = 'ssh -o SendEnv=TERM_PROGRAM devcomp.xyz';
const cursor = '█' const cursor = '█'
let showCheckmark = $state(false); let showCheckmark = $state(false);