forked from DevComp/ssh-portfolio
Compare commits
18 commits
Author | SHA1 | Date | |
---|---|---|---|
3c089bbdc3 | |||
ee524dc160 | |||
b878008c25 | |||
ad38a34c5a | |||
0383f796e2 | |||
ccf08c5511 | |||
403ff0f781 | |||
6a3597002c | |||
8c9932fe2b | |||
256aa5b8bd | |||
03432bf9bb | |||
655f9d624c | |||
dbd6ae9dad | |||
6242af5bff | |||
8dad453cc6 | |||
ebddeaa21e | |||
a389837e7b | |||
8f4ce95b4a |
32 changed files with 4993 additions and 1259 deletions
|
@ -10,6 +10,7 @@
|
||||||
"<left>": "PrevTab", // Go to the previous tab
|
"<left>": "PrevTab", // Go to the previous tab
|
||||||
"<down>": "SelectNext", // Go to the next selection in options
|
"<down>": "SelectNext", // Go to the next selection in options
|
||||||
"<up>": "SelectPrev", // Go to the previous selection in options
|
"<up>": "SelectPrev", // Go to the previous selection in options
|
||||||
|
"<enter>": "Continue", // Continue with the current selection
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
4599
Cargo.lock
generated
4599
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
@ -16,9 +16,20 @@ blog = [
|
||||||
"dep:atrium-common",
|
"dep:atrium-common",
|
||||||
"dep:reqwest",
|
"dep:reqwest",
|
||||||
"dep:ipld-core",
|
"dep:ipld-core",
|
||||||
|
"dep:tui-markdown",
|
||||||
|
"dep:chrono",
|
||||||
|
"dep:ratatui-image",
|
||||||
|
"dep:image"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[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"
|
||||||
anyhow = "1.0.90"
|
anyhow = "1.0.90"
|
||||||
async-trait = "0.1.85"
|
async-trait = "0.1.85"
|
||||||
atrium-api = { version = "0.25.4", optional = true }
|
atrium-api = { version = "0.25.4", optional = true }
|
||||||
|
@ -27,6 +38,7 @@ 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 }
|
||||||
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 }
|
||||||
clap = { version = "4.5.20", features = [
|
clap = { version = "4.5.20", features = [
|
||||||
"derive",
|
"derive",
|
||||||
"cargo",
|
"cargo",
|
||||||
|
@ -36,22 +48,27 @@ clap = { version = "4.5.20", features = [
|
||||||
"unstable-styles",
|
"unstable-styles",
|
||||||
] }
|
] }
|
||||||
color-eyre = "0.6.3"
|
color-eyre = "0.6.3"
|
||||||
config = "0.14.0"
|
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"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
libc = "0.2.161"
|
libc = "0.2.161"
|
||||||
|
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"] }
|
||||||
|
ratatui-image = { version = "8.0.1", optional = true }
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls"], optional = true }
|
reqwest = { version = "0.12", features = ["rustls-tls"], optional = true }
|
||||||
russh = "0.49.2"
|
russh = "0.49.2"
|
||||||
|
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"
|
||||||
signal-hook = "0.3.17"
|
signal-hook = "0.3.17"
|
||||||
|
@ -62,9 +79,11 @@ tokio-util = "0.7.12"
|
||||||
tracing = "0.1.40"
|
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 }
|
||||||
|
|
||||||
[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" }
|
||||||
|
patch-crate = "0.1.13"
|
||||||
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
|
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
|
||||||
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }
|
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }
|
||||||
|
|
21
build.rs
21
build.rs
|
@ -1,4 +1,5 @@
|
||||||
use std::{env, path::PathBuf};
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
|
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
|
||||||
|
@ -8,24 +9,23 @@ use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder};
|
||||||
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: &[(&'static str, Algorithm)] = &[
|
const SSH_KEY_ALGOS: &[(&str, Algorithm)] = &[
|
||||||
("rsa.pem", Algorithm::Rsa { hash: None }),
|
("rsa.pem", Algorithm::Rsa { hash: None }),
|
||||||
("ed25519.pem", Algorithm::Ed25519),
|
("ed25519.pem", Algorithm::Ed25519),
|
||||||
(
|
("ecdsa.pem", Algorithm::Ecdsa { curve: EcdsaCurve::NistP256 }),
|
||||||
"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");
|
||||||
|
println!("cargo:rerun-if-changed=patches/");
|
||||||
|
|
||||||
|
patch_crate::run().expect("Failed while patching");
|
||||||
|
|
||||||
// Generate openSSH host keys
|
// Generate openSSH host keys
|
||||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
let mut rng = rand_core::OsRng::default();
|
let mut rng = rand_core::OsRng;
|
||||||
for (file_name, algo) in SSH_KEY_ALGOS {
|
for (file_name, algo) in SSH_KEY_ALGOS {
|
||||||
let path = out_dir.join(file_name);
|
let path = out_dir.join(file_name);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
|
@ -36,7 +36,8 @@ fn main() -> Result<()> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
|
let key =
|
||||||
|
PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
|
||||||
key.write_openssh_file(&path, LineEnding::default())?;
|
key.write_openssh_file(&path, LineEnding::default())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
21
patches/ratatui-image+8.0.1.patch
Normal file
21
patches/ratatui-image+8.0.1.patch
Normal 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].
|
3
rust-toolchain
Normal file
3
rust-toolchain
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly-2025-03-28"
|
||||||
|
components = ["clippy", "rust-analyzer", "cargo", "rustc"]
|
13
rustfmt.toml
Normal file
13
rustfmt.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
edition = "2021"
|
||||||
|
use_small_heuristics = "Max"
|
||||||
|
max_width = 95
|
||||||
|
newline_style = "Unix"
|
||||||
|
|
||||||
|
indent_style = "Block"
|
||||||
|
hard_tabs = false
|
||||||
|
|
||||||
|
format_strings = true
|
||||||
|
brace_style = "PreferSameLine"
|
||||||
|
chain_width = 95
|
||||||
|
|
||||||
|
imports_granularity = "Module"
|
|
@ -1,7 +1,11 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::de::{self, Visitor};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use strum::Display;
|
use strum::Display;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
Tick,
|
Tick,
|
||||||
Render,
|
Render,
|
||||||
|
@ -20,4 +24,65 @@ pub enum Action {
|
||||||
// Selection management
|
// Selection management
|
||||||
SelectNext,
|
SelectNext,
|
||||||
SelectPrev,
|
SelectPrev,
|
||||||
|
Continue(Option<usize>),
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: should probably make this nicer
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Action {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>, {
|
||||||
|
struct ActionVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for ActionVisitor {
|
||||||
|
type Value = Action;
|
||||||
|
|
||||||
|
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(f, "an Action enum variant")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Action, E>
|
||||||
|
where
|
||||||
|
E: de::Error, {
|
||||||
|
if v == "Continue" {
|
||||||
|
Ok(Action::Continue(None))
|
||||||
|
} else {
|
||||||
|
// fallback: let serde handle all other strings
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
enum Helper {
|
||||||
|
Tick,
|
||||||
|
Render,
|
||||||
|
Suspend,
|
||||||
|
Resume,
|
||||||
|
Quit,
|
||||||
|
ClearScreen,
|
||||||
|
Help,
|
||||||
|
NextTab,
|
||||||
|
PrevTab,
|
||||||
|
SelectNext,
|
||||||
|
SelectPrev,
|
||||||
|
}
|
||||||
|
|
||||||
|
let helper: Helper = serde_json::from_str(&format!("\"{}\"", v))
|
||||||
|
.map_err(|_| de::Error::unknown_variant(v, &["Continue"]))?;
|
||||||
|
Ok(match helper {
|
||||||
|
Helper::Tick => Action::Tick,
|
||||||
|
Helper::Render => Action::Render,
|
||||||
|
Helper::Suspend => Action::Suspend,
|
||||||
|
Helper::Resume => Action::Resume,
|
||||||
|
Helper::Quit => Action::Quit,
|
||||||
|
Helper::ClearScreen => Action::ClearScreen,
|
||||||
|
Helper::Help => Action::Help,
|
||||||
|
Helper::NextTab => Action::NextTab,
|
||||||
|
Helper::PrevTab => Action::PrevTab,
|
||||||
|
Helper::SelectNext => Action::SelectNext,
|
||||||
|
Helper::SelectPrev => Action::SelectPrev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(ActionVisitor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
213
src/app.rs
213
src/app.rs
|
@ -1,36 +1,36 @@
|
||||||
use std::sync::{atomic::AtomicUsize, Arc};
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
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};
|
||||||
use ratatui::{
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
layout::{Constraint, Direction, Layout},
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
prelude::Rect,
|
use ratatui::text::{Line, Span};
|
||||||
style::{Color, Modifier, Style},
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||||
text::{Line, Span},
|
|
||||||
widgets::Paragraph,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::{
|
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||||
sync::{mpsc, Mutex, RwLock},
|
use tokio::task::block_in_place;
|
||||||
task::block_in_place,
|
|
||||||
};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, info};
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::action::Action;
|
||||||
action::Action,
|
use crate::components::*;
|
||||||
components::*,
|
use crate::config::Config;
|
||||||
config::Config,
|
use crate::keycode::KeyCodeExt;
|
||||||
keycode::KeyCodeExt,
|
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
|
||||||
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,
|
||||||
|
needs_resize: bool,
|
||||||
|
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
last_tick_key_events: Vec<KeyEvent>,
|
last_tick_key_events: Vec<KeyEvent>,
|
||||||
action_tx: mpsc::UnboundedSender<Action>,
|
action_tx: mpsc::UnboundedSender<Action>,
|
||||||
|
@ -44,7 +44,7 @@ pub struct App {
|
||||||
content: Arc<Mutex<Content>>,
|
content: Arc<Mutex<Content>>,
|
||||||
cat: Arc<Mutex<Cat>>,
|
cat: Arc<Mutex<Cat>>,
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
selection_list: Arc<Mutex<SelectionList>>,
|
blog_posts: Arc<Mutex<BlogPosts>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
@ -54,7 +54,10 @@ pub enum Mode {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
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>>,
|
||||||
|
@ -75,15 +78,18 @@ impl App {
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
let rt = tokio::runtime::Handle::current();
|
let rt = tokio::runtime::Handle::current();
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
let selection_list = Arc::new(Mutex::new(SelectionList::new(
|
let blog_posts = Arc::new(Mutex::new(BlogPosts::new(
|
||||||
rt.block_on(content.blocking_lock().blog_content())?,
|
rt.block_on(content.try_lock()?.blog_content())?,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
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,
|
||||||
|
|
||||||
config: Config::new()?,
|
config: Config::new()?,
|
||||||
mode: Mode::Home,
|
mode: Mode::Home,
|
||||||
last_tick_key_events: Vec::new(),
|
last_tick_key_events: Vec::new(),
|
||||||
|
@ -97,7 +103,7 @@ impl App {
|
||||||
content,
|
content,
|
||||||
cat,
|
cat,
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
selection_list,
|
blog_posts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,43 +113,46 @@ impl App {
|
||||||
tui: Arc<RwLock<Option<Tui>>>,
|
tui: Arc<RwLock<Option<Tui>>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut tui = tui.write().await;
|
let mut tui = tui.write().await;
|
||||||
let mut 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
|
||||||
|
let term_size = tui.terminal.try_lock()?.size()?;
|
||||||
|
tui.event_tx.send(Event::Resize(term_size.width, term_size.height))?;
|
||||||
|
|
||||||
// Blocking initialization logic for tui and components
|
// Blocking initialization logic for tui and components
|
||||||
block_in_place(|| {
|
block_in_place(|| {
|
||||||
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
|
#[cfg(feature = "blog")]
|
||||||
.try_lock()?
|
self.blog_posts.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())?;
|
|
||||||
|
|
||||||
// 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
|
#[cfg(feature = "blog")]
|
||||||
.try_lock()?
|
self.blog_posts.try_lock()?.register_config_handler(self.config.clone())?;
|
||||||
.register_config_handler(self.config.clone())?;
|
|
||||||
self.cat
|
while let TerminalKind::Unsupported(UnsupportedReason::Unprobed) =
|
||||||
.try_lock()?
|
self.terminal_info.blocking_read().kind()
|
||||||
.register_config_handler(self.config.clone())?;
|
{
|
||||||
|
tracing::debug!("Waiting for terminal kind 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(size)?;
|
self.cat.try_lock()?.init(self.terminal_info.clone(), size)?;
|
||||||
|
#[cfg(feature = "blog")]
|
||||||
|
self.blog_posts.try_lock()?.init(self.terminal_info.clone(), size)?;
|
||||||
|
|
||||||
Ok::<_, eyre::Error>(())
|
Ok::<_, eyre::Error>(())
|
||||||
})?;
|
})?;
|
||||||
|
@ -151,8 +160,8 @@ impl App {
|
||||||
let action_tx = self.action_tx.clone();
|
let action_tx = self.action_tx.clone();
|
||||||
let mut resume_tx: Option<Arc<CancellationToken>> = None;
|
let mut resume_tx: Option<Arc<CancellationToken>> = None;
|
||||||
loop {
|
loop {
|
||||||
self.handle_events(&mut tui).await?;
|
self.handle_events(tui).await?;
|
||||||
block_in_place(|| self.handle_actions(&mut tui))?;
|
block_in_place(|| self.handle_actions(tui))?;
|
||||||
if self.should_suspend {
|
if self.should_suspend {
|
||||||
if let Some(ref tx) = resume_tx {
|
if let Some(ref tx) = resume_tx {
|
||||||
tx.cancel();
|
tx.cancel();
|
||||||
|
@ -217,13 +226,13 @@ impl App {
|
||||||
};
|
};
|
||||||
match keymap.get(&vec![key]) {
|
match keymap.get(&vec![key]) {
|
||||||
Some(action) => {
|
Some(action) => {
|
||||||
info!("Got action: {action:?}");
|
debug!("Got action: {action:?}");
|
||||||
action_tx.send(action.clone())?;
|
action_tx.send(action.clone())?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.last_tick_key_events.push(key);
|
self.last_tick_key_events.push(key);
|
||||||
if let Some(action) = keymap.get(&self.last_tick_key_events) {
|
if let Some(action) = keymap.get(&self.last_tick_key_events) {
|
||||||
info!("Got action: {action:?}");
|
debug!("Got action: {action:?}");
|
||||||
action_tx.send(action.clone())?;
|
action_tx.send(action.clone())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -240,11 +249,18 @@ impl App {
|
||||||
Action::Tick => {
|
Action::Tick => {
|
||||||
self.last_tick_key_events.drain(..);
|
self.last_tick_key_events.drain(..);
|
||||||
}
|
}
|
||||||
Action::Quit => self.should_quit = true,
|
Action::Quit => {
|
||||||
|
if !self.blog_posts.try_lock()?.is_in_post() {
|
||||||
|
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) => self.resize(tui, w, h)?,
|
Action::Resize(w, h) => {
|
||||||
|
self.needs_resize = w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1;
|
||||||
|
self.resize(tui, w, h)?;
|
||||||
|
}
|
||||||
Action::Render => self.render(tui)?,
|
Action::Render => self.render(tui)?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
@ -261,7 +277,7 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
if let Some(action) = self.selection_list.try_lock()?.update(action.clone())? {
|
if let Some(action) = self.blog_posts.try_lock()?.update(action.clone())? {
|
||||||
self.action_tx.send(action)?;
|
self.action_tx.send(action)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -278,7 +294,55 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&mut self, tui: &mut Tui) -> Result<()> {
|
fn render(&mut self, tui: &mut Tui) -> Result<()> {
|
||||||
tui.terminal.try_lock()?.try_draw(|frame| {
|
let mut term = tui.terminal.try_lock()?;
|
||||||
|
if self.needs_resize {
|
||||||
|
term.draw(|frame| {
|
||||||
|
let size = frame.area();
|
||||||
|
let error_message = format!(
|
||||||
|
"window size must be at least {}x{}, currently {}x{}",
|
||||||
|
Self::MIN_TUI_DIMS.0,
|
||||||
|
Self::MIN_TUI_DIMS.1,
|
||||||
|
size.width,
|
||||||
|
size.height
|
||||||
|
);
|
||||||
|
|
||||||
|
let error_width = error_message.chars().count().try_into().unwrap_or(55);
|
||||||
|
let error_height = 5;
|
||||||
|
|
||||||
|
#[rustfmt::skip]
|
||||||
|
let area = Block::default()
|
||||||
|
.borders(Borders::all())
|
||||||
|
.style(Style::new().fg(Color::White))
|
||||||
|
.inner(Rect::new(
|
||||||
|
size.width
|
||||||
|
.checked_sub(error_width)
|
||||||
|
.and_then(|n| n.checked_div(2))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
size.height
|
||||||
|
.checked_sub(error_height)
|
||||||
|
.and_then(|n| n.checked_div(2))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
if error_width > size.width { u16::MIN } else { error_width },
|
||||||
|
if size.height > error_height { error_height } else { size.height },
|
||||||
|
));
|
||||||
|
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(
|
||||||
|
Line::from(error_message.clone()).style(
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
term.try_draw(|frame| {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
|
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
|
||||||
|
@ -287,26 +351,16 @@ impl App {
|
||||||
// Render the domain name text
|
// Render the domain name text
|
||||||
let title = Paragraph::new(Line::from(Span::styled(
|
let title = Paragraph::new(Line::from(Span::styled(
|
||||||
"devcomp.xyz ",
|
"devcomp.xyz ",
|
||||||
Style::default()
|
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||||
.fg(Color::White)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)));
|
)));
|
||||||
|
|
||||||
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 = self
|
let mut tabs = self.tabs.try_lock().map_err(std::io::Error::other)?;
|
||||||
.tabs
|
|
||||||
.try_lock()
|
|
||||||
.map_err(|err| std::io::Error::other(err))?;
|
|
||||||
|
|
||||||
tabs.draw(
|
tabs.draw(
|
||||||
frame,
|
frame,
|
||||||
|
@ -317,7 +371,7 @@ impl App {
|
||||||
height: chunks[0].height,
|
height: chunks[0].height,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|err| std::io::Error::other(err))?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
// Render the content
|
// Render the content
|
||||||
let content_rect = Rect {
|
let content_rect = Rect {
|
||||||
|
@ -329,16 +383,16 @@ impl App {
|
||||||
|
|
||||||
self.content
|
self.content
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map_err(|err| std::io::Error::other(err))?
|
.map_err(std::io::Error::other)?
|
||||||
.draw(frame, content_rect)
|
.draw(frame, content_rect)
|
||||||
.map_err(|err| std::io::Error::other(err))?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
// Render the eepy cat :3
|
// Render the eepy cat :3
|
||||||
self.cat
|
self.cat
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map_err(|err| std::io::Error::other(err))?
|
.map_err(std::io::Error::other)?
|
||||||
.draw(frame, frame.area())
|
.draw(frame, frame.area())
|
||||||
.map_err(|err| std::io::Error::other(err))?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
if tabs.current_tab() == 2 {
|
if tabs.current_tab() == 2 {
|
||||||
let mut content_rect = content_rect;
|
let mut content_rect = content_rect;
|
||||||
|
@ -350,11 +404,11 @@ impl App {
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
{
|
{
|
||||||
// Render the post selection list if the blog tab is selected
|
// Render the post selection list if the blog tab is selected
|
||||||
self.selection_list
|
self.blog_posts
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map_err(|err| std::io::Error::other(err))?
|
.map_err(std::io::Error::other)?
|
||||||
.draw(frame, content_rect)
|
.draw(frame, content_rect)
|
||||||
.map_err(|err| std::io::Error::other(err))?;
|
.map_err(std::io::Error::other)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "blog"))]
|
#[cfg(not(feature = "blog"))]
|
||||||
|
@ -362,7 +416,8 @@ 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 to view this tab.",
|
"Blog feature is disabled. Enable the `blog` feature to view this \
|
||||||
|
tab.",
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
|
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
|
||||||
|
|
||||||
|
|
|
@ -5,21 +5,18 @@ pub mod com;
|
||||||
pub mod blog {
|
pub mod blog {
|
||||||
use std::str::FromStr as _;
|
use std::str::FromStr as _;
|
||||||
|
|
||||||
|
use atrium_api::agent::atp_agent::store::MemorySessionStore;
|
||||||
|
use atrium_api::agent::atp_agent::CredentialSession;
|
||||||
|
use atrium_api::agent::Agent;
|
||||||
|
use atrium_api::com::atproto::repo::list_records;
|
||||||
use atrium_api::com::atproto::server::create_session::OutputData as SessionOutputData;
|
use atrium_api::com::atproto::server::create_session::OutputData as SessionOutputData;
|
||||||
use atrium_api::{
|
use atrium_api::types::string::{AtIdentifier, Handle};
|
||||||
agent::{
|
use atrium_api::types::{Collection as _, Object, Unknown};
|
||||||
atp_agent::{store::MemorySessionStore, CredentialSession},
|
use atrium_common::store::memory::MemoryStore;
|
||||||
Agent,
|
use atrium_common::store::Store;
|
||||||
},
|
|
||||||
com::atproto::repo::list_records,
|
|
||||||
types::{
|
|
||||||
string::{AtIdentifier, Handle},
|
|
||||||
Collection as _, Object, Unknown,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use atrium_common::store::{memory::MemoryStore, Store};
|
|
||||||
use atrium_xrpc_client::reqwest::ReqwestClient;
|
use atrium_xrpc_client::reqwest::ReqwestClient;
|
||||||
use color_eyre::{eyre::eyre, Result};
|
use color_eyre::eyre::eyre;
|
||||||
|
use color_eyre::Result;
|
||||||
use ipld_core::ipld::Ipld;
|
use ipld_core::ipld::Ipld;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use tokio::time::{Duration, Instant};
|
use tokio::time::{Duration, Instant};
|
||||||
|
@ -69,7 +66,7 @@ pub mod blog {
|
||||||
.com
|
.com
|
||||||
.atproto
|
.atproto
|
||||||
.repo
|
.repo
|
||||||
.list_records(Object::from(list_records::Parameters {
|
.list_records(list_records::Parameters {
|
||||||
extra_data: Ipld::Null,
|
extra_data: Ipld::Null,
|
||||||
data: list_records::ParametersData {
|
data: list_records::ParametersData {
|
||||||
collection: com::whtwnd::blog::Entry::nsid(),
|
collection: com::whtwnd::blog::Entry::nsid(),
|
||||||
|
@ -81,7 +78,7 @@ pub mod blog {
|
||||||
.map_err(|_| eyre!("Invalid repo handle"))?,
|
.map_err(|_| eyre!("Invalid repo handle"))?,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}))
|
})
|
||||||
.await?
|
.await?
|
||||||
.records;
|
.records;
|
||||||
|
|
||||||
|
@ -100,9 +97,7 @@ pub mod blog {
|
||||||
.collect::<Result<Vec<com::whtwnd::blog::entry::Record>>>()?;
|
.collect::<Result<Vec<com::whtwnd::blog::entry::Record>>>()?;
|
||||||
|
|
||||||
for (i, post) in posts.iter().enumerate() {
|
for (i, post) in posts.iter().enumerate() {
|
||||||
POSTS_CACHE_STORE
|
POSTS_CACHE_STORE.set(i, (Instant::now(), post.clone())).await?;
|
||||||
.set(i, (Instant::now(), post.clone()))
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(posts)
|
Ok(posts)
|
||||||
|
|
26
src/cli.rs
26
src/cli.rs
|
@ -18,28 +18,28 @@ 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, long, value_name = "PORT", default_value_t = 2222)]
|
#[arg(short = 'P', long, value_name = "PORT", default_value_t = 2222)]
|
||||||
pub port: u16,
|
pub ssh_port: u16,
|
||||||
|
/// The port to start the web server on
|
||||||
|
#[arg(short = 'p', long, value_name = "PORT", default_value_t = 80)]
|
||||||
|
pub web_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERSION_MESSAGE: &str = concat!(
|
|
||||||
env!("CARGO_PKG_VERSION"),
|
|
||||||
"-",
|
|
||||||
env!("VERGEN_GIT_DESCRIBE"),
|
|
||||||
" (",
|
|
||||||
env!("VERGEN_BUILD_DATE"),
|
|
||||||
")"
|
|
||||||
);
|
|
||||||
|
|
||||||
pub fn version() -> String {
|
pub fn version() -> String {
|
||||||
let author = clap::crate_authors!();
|
let author = clap::crate_authors!();
|
||||||
|
let version_message = format!(
|
||||||
|
"v{}-{} ({}, {})",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
&env!("VERGEN_GIT_SHA")[..7],
|
||||||
|
env!("VERGEN_GIT_BRANCH"),
|
||||||
|
env!("VERGEN_BUILD_DATE")
|
||||||
|
);
|
||||||
|
|
||||||
// let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string();
|
|
||||||
let config_dir_path = get_config_dir().display().to_string();
|
let config_dir_path = get_config_dir().display().to_string();
|
||||||
let data_dir_path = get_data_dir().display().to_string();
|
let data_dir_path = get_data_dir().display().to_string();
|
||||||
|
|
||||||
formatdoc! {"
|
formatdoc! {"
|
||||||
{VERSION_MESSAGE}
|
{version_message}
|
||||||
|
|
||||||
Authors: {author}
|
Authors: {author}
|
||||||
|
|
||||||
|
|
|
@ -1,28 +1,35 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use crossterm::event::{KeyEvent, MouseEvent};
|
use crossterm::event::{KeyEvent, MouseEvent};
|
||||||
use ratatui::{
|
use ratatui::layout::{Rect, Size};
|
||||||
layout::{Rect, Size},
|
use ratatui::Frame;
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::{action::Action, config::Config, tui::Event};
|
use crate::action::Action;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::tui::terminal::TerminalInfo;
|
||||||
|
use crate::tui::Event;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Component re-exports
|
// Component re-exports
|
||||||
//
|
//
|
||||||
|
#[cfg(feature = "blog")]
|
||||||
mod tabs;
|
mod blog;
|
||||||
mod content;
|
|
||||||
mod cat;
|
mod cat;
|
||||||
|
mod content;
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
mod selection_list;
|
mod selection_list;
|
||||||
|
mod tabs;
|
||||||
|
|
||||||
pub use tabs::*;
|
#[cfg(feature = "blog")]
|
||||||
pub use content::*;
|
pub use blog::*;
|
||||||
pub use cat::*;
|
pub use cat::*;
|
||||||
|
pub use content::*;
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
pub use selection_list::*;
|
pub use selection_list::*;
|
||||||
|
pub use tabs::*;
|
||||||
|
|
||||||
/// `Component` is a trait that represents a visual and interactive element of the user interface.
|
/// `Component` is a trait that represents a visual and interactive element of the user interface.
|
||||||
///
|
///
|
||||||
|
@ -55,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
|
||||||
///
|
///
|
||||||
|
@ -64,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.
|
||||||
|
|
195
src/components/blog.rs
Normal file
195
src/components/blog.rs
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
use std::io::{BufReader, Cursor};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use color_eyre::eyre::eyre;
|
||||||
|
use color_eyre::Result;
|
||||||
|
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::RwLock;
|
||||||
|
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::com;
|
||||||
|
use crate::com::whtwnd::blog::defs::Ogp;
|
||||||
|
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 struct BlogPosts {
|
||||||
|
list: SelectionList<Post>,
|
||||||
|
posts: Vec<Post>,
|
||||||
|
image_renderer: Option<Picker>,
|
||||||
|
in_post: (Option<StatefulProtocol>, Option<usize>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlogPosts {
|
||||||
|
pub fn new(posts: Vec<Post>) -> Self {
|
||||||
|
let posts_ref = posts.to_vec();
|
||||||
|
Self {
|
||||||
|
list: SelectionList::new(posts),
|
||||||
|
image_renderer: Some(Picker {
|
||||||
|
font_size: DEFAULT_FONT_SIZE,
|
||||||
|
protocol_type: ProtocolType::Halfblocks,
|
||||||
|
background_color: Rgba([0, 0, 0, 0]),
|
||||||
|
is_tmux: false, // FIXME: any way to figure this out?
|
||||||
|
capabilities: vec![],
|
||||||
|
}),
|
||||||
|
posts: posts_ref,
|
||||||
|
in_post: (None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_in_post(&self) -> bool {
|
||||||
|
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 {
|
||||||
|
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, _: Size) -> Result<()> {
|
||||||
|
let locked_info = term_info.blocking_read().clone();
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
|
||||||
|
self.list.register_action_handler(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, action: Action) -> Result<Option<Action>> {
|
||||||
|
match self.list.update(action.clone())?.unwrap() {
|
||||||
|
// safe to unwrap, guaranteed to not be `None`
|
||||||
|
Action::Tick => {}
|
||||||
|
Action::Render => {}
|
||||||
|
|
||||||
|
// FIXME: do we reload the image on every single render of a post?
|
||||||
|
Action::Quit | Action::PrevTab | Action::NextTab => self.in_post = (None, None),
|
||||||
|
Action::Continue(post_id) => self.in_post.1 = post_id,
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&mut self,
|
||||||
|
frame: &mut ratatui::Frame,
|
||||||
|
area: ratatui::prelude::Rect,
|
||||||
|
) -> Result<()> {
|
||||||
|
if let Some(post_id_inner) = self.in_post.1 {
|
||||||
|
let post = self
|
||||||
|
.posts
|
||||||
|
.get(post_id_inner)
|
||||||
|
.ok_or(eyre!("Current post apparently doesn't exist"))?;
|
||||||
|
|
||||||
|
let post_body = post.title.clone().map_or(post.content.clone(), |title| {
|
||||||
|
format!("# {}\n\n{}", title, post.content)
|
||||||
|
});
|
||||||
|
let post_content_text = tui_markdown::from_str(&post_body);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
Paragraph::new(post_content_text).wrap(Wrap { trim: true }),
|
||||||
|
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_content_text,
|
||||||
|
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_content_text,
|
||||||
|
Rect::new(area.x + 3, area.y + 3, area.width, area.height),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.list.draw(frame, area)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
use ratatui::{prelude::*, widgets::*};
|
use ratatui::prelude::*;
|
||||||
|
use ratatui::widgets::*;
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use super::Component;
|
use super::Component;
|
||||||
use crate::{action::Action, config::Config};
|
use crate::action::Action;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
const CAT_ASCII_ART: &str = indoc! {r#"
|
const CAT_ASCII_ART: &str = indoc! {r#"
|
||||||
|\__/,| (`\
|
|\__/,| (`\
|
||||||
|
@ -52,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(())
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
use std::sync::{
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
atomic::{AtomicUsize, Ordering},
|
use std::sync::Arc;
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use color_eyre::{eyre::eyre, Result};
|
use color_eyre::eyre::eyre;
|
||||||
|
use color_eyre::Result;
|
||||||
use figlet_rs::FIGfont;
|
use figlet_rs::FIGfont;
|
||||||
use ratatui::{prelude::*, widgets::*};
|
use ratatui::prelude::*;
|
||||||
|
use ratatui::widgets::*;
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use super::Component;
|
use super::Component;
|
||||||
use crate::{action::Action, config::Config};
|
use crate::action::Action;
|
||||||
|
#[cfg(feature = "blog")]
|
||||||
|
use crate::components::Post;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
@ -20,25 +29,20 @@ pub struct Content {
|
||||||
|
|
||||||
impl Content {
|
impl Content {
|
||||||
pub fn new(selected_tab: Arc<AtomicUsize>) -> Self {
|
pub fn new(selected_tab: Arc<AtomicUsize>) -> Self {
|
||||||
Self {
|
Self { selected_tab, ..Default::default() }
|
||||||
selected_tab,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the content for the "About" tab
|
/// Generate the content for the "About" tab
|
||||||
fn about_content(&self, area: Rect) -> Result<Vec<Line<'static>>> {
|
fn about_content(&self, area: Rect) -> Result<Vec<Line<'static>>> {
|
||||||
let greetings_header = FIGfont::from_content(include_str!("../../assets/drpepper.flf"))
|
let greetings_header =
|
||||||
.map_err(|err| eyre!(err))?
|
FIGfont::from_content(include_str!("../../assets/drpepper.flf"))
|
||||||
.convert("hiya!")
|
.map_err(|err| eyre!(err))?
|
||||||
.ok_or(eyre!("Failed to create figlet header for about page"))?
|
.convert("hiya!")
|
||||||
.to_string();
|
.ok_or(eyre!("Failed to create figlet header for about page"))?
|
||||||
|
.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()
|
||||||
|
@ -49,11 +53,15 @@ impl Content {
|
||||||
Span::from(" "),
|
Span::from(" "),
|
||||||
Span::from(line.clone()),
|
Span::from(line.clone()),
|
||||||
Span::from(" I'm Erica ("),
|
Span::from(" I'm Erica ("),
|
||||||
Span::styled("she/they", Style::default().add_modifier(Modifier::ITALIC)),
|
Span::styled(
|
||||||
|
"she/they",
|
||||||
|
Style::default().add_modifier(Modifier::ITALIC),
|
||||||
|
),
|
||||||
Span::from("), and I make scalable systems or something. IDFK."),
|
Span::from("), and I make scalable systems or something. IDFK."),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
Line::raw(format!(" {}", line)).style(Style::default().add_modifier(Modifier::BOLD))
|
Line::raw(format!(" {}", line))
|
||||||
|
.style(Style::default().add_modifier(Modifier::BOLD))
|
||||||
})
|
})
|
||||||
.collect::<Vec<Line<'static>>>();
|
.collect::<Vec<Line<'static>>>();
|
||||||
|
|
||||||
|
@ -78,28 +86,33 @@ impl Content {
|
||||||
Span::from("."),
|
Span::from("."),
|
||||||
]),
|
]),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(" I am an avid believer of open-source software, and contribute to a few projects such as:"),
|
Line::from(
|
||||||
|
" I am an avid believer of open-source software, and contribute to a few \
|
||||||
|
projects such as:",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let projects = vec![
|
let projects = vec![
|
||||||
(Style::default()
|
(
|
||||||
.fg(Color::LightMagenta)
|
Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
|
||||||
.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), "DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered by Lune"),
|
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
|
||||||
(Style::default()
|
"DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered \
|
||||||
.fg(Color::Yellow)
|
by Lune",
|
||||||
.add_modifier(Modifier::BOLD), "pesde-pkg/pesde: A package manager for the Luau programming language, supporting multiple runtimes including Roblox and Lune"),
|
),
|
||||||
|
(
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
"pesde-pkg/pesde: A package manager for the Luau programming language, \
|
||||||
|
supporting multiple runtimes including Roblox and 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);
|
||||||
|
|
||||||
|
@ -119,9 +132,7 @@ impl Content {
|
||||||
formatted_left,
|
formatted_left,
|
||||||
Span::from(":"),
|
Span::from(":"),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
first
|
first.trim_start_matches(format!("{left}:").as_str()).to_string(),
|
||||||
.trim_start_matches(format!("{left}:").as_str())
|
|
||||||
.to_string(),
|
|
||||||
Style::default().fg(Color::White),
|
Style::default().fg(Color::White),
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
|
@ -154,7 +165,10 @@ impl Content {
|
||||||
} else {
|
} else {
|
||||||
content.push(Line::from(vec![
|
content.push(Line::from(vec![
|
||||||
Span::from(indent),
|
Span::from(indent),
|
||||||
Span::styled(remaining_text.clone(), Style::default().fg(Color::White)),
|
Span::styled(
|
||||||
|
remaining_text.clone(),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
),
|
||||||
]));
|
]));
|
||||||
remaining_text.clear();
|
remaining_text.clear();
|
||||||
}
|
}
|
||||||
|
@ -164,12 +178,14 @@ 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 super adorable :3",
|
" I am also a fan of the 8 bit aesthetic and think seals are super adorable \
|
||||||
|
:3",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Ok(content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the content for the "Projects" tab
|
/// Generate the content for the "Projects" tab
|
||||||
fn projects_content(&self) -> Vec<Line<'static>> {
|
fn projects_content(&self) -> Vec<Line<'static>> {
|
||||||
vec![Line::from("WIP")]
|
vec![Line::from("WIP")]
|
||||||
|
@ -177,12 +193,12 @@ impl Content {
|
||||||
|
|
||||||
/// Generate the content for the "Blog" tab
|
/// Generate the content for the "Blog" tab
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
pub async fn blog_content(&self) -> Result<Vec<String>> {
|
pub async fn blog_content(&self) -> Result<Vec<Post>> {
|
||||||
Ok(crate::atproto::blog::get_all_posts()
|
Ok(crate::atproto::blog::get_all_posts()
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|post| post.title.clone().unwrap_or("<unknown>".to_string()))
|
.map(|post| Arc::new(post.clone()))
|
||||||
.collect::<Vec<String>>())
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,15 +228,12 @@ impl Component for Content {
|
||||||
0 => self.about_content(area)?,
|
0 => self.about_content(area)?,
|
||||||
1 => self.projects_content(),
|
1 => self.projects_content(),
|
||||||
/* Blog tab handled in `App::render` */
|
/* Blog tab handled in `App::render` */
|
||||||
|
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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(
|
||||||
|
@ -233,12 +246,7 @@ impl Component for Content {
|
||||||
|
|
||||||
for (i, &tab) in tabs.iter().enumerate() {
|
for (i, &tab) in tabs.iter().enumerate() {
|
||||||
let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) {
|
let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) {
|
||||||
(
|
("━", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
|
||||||
"━",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Magenta)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
("─", Style::default().fg(Color::DarkGray))
|
("─", Style::default().fg(Color::DarkGray))
|
||||||
};
|
};
|
||||||
|
@ -247,9 +255,7 @@ impl Component for Content {
|
||||||
|
|
||||||
border_top.spans.push(Span::styled("┴", default_style));
|
border_top.spans.push(Span::styled("┴", default_style));
|
||||||
border_top.spans.push(Span::styled("─", default_style));
|
border_top.spans.push(Span::styled("─", default_style));
|
||||||
border_top
|
border_top.spans.push(Span::styled(char.repeat(tab.len()), style));
|
||||||
.spans
|
|
||||||
.push(Span::styled(char.repeat(tab.len()), style));
|
|
||||||
border_top.spans.push(Span::styled("─", default_style));
|
border_top.spans.push(Span::styled("─", default_style));
|
||||||
border_top.spans.push(Span::styled("┴", default_style));
|
border_top.spans.push(Span::styled("┴", default_style));
|
||||||
|
|
||||||
|
@ -261,9 +267,7 @@ 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) + "╯",
|
||||||
|
@ -291,43 +295,23 @@ impl Component for Content {
|
||||||
// Render the borders
|
// Render the borders
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(border_top),
|
Paragraph::new(border_top),
|
||||||
Rect {
|
Rect { x: area.x, y: area.y, width: area.width, height: 1 },
|
||||||
x: area.x,
|
|
||||||
y: area.y,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(border_left.clone())),
|
Paragraph::new(Line::from(border_left.clone())),
|
||||||
Rect {
|
Rect { x: area.x, y: area.y + i, width: 1, height: 1 },
|
||||||
x: area.x,
|
|
||||||
y: area.y + i,
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,41 +1,38 @@
|
||||||
|
use chrono::DateTime;
|
||||||
use color_eyre::eyre::Result;
|
use color_eyre::eyre::Result;
|
||||||
use ratatui::{
|
use ratatui::prelude::*;
|
||||||
style::{Color, Style},
|
use ratatui::widgets::*;
|
||||||
widgets::{List, ListState},
|
|
||||||
};
|
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use crate::{action::Action, components::Component, config::Config};
|
use crate::action::Action;
|
||||||
|
use crate::components::{Component, Post};
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Debug)]
|
||||||
pub struct SelectionList {
|
pub struct SelectionList<T> {
|
||||||
command_tx: Option<UnboundedSender<Action>>,
|
|
||||||
config: Config,
|
config: Config,
|
||||||
options: List<'static>,
|
pub(super) options: Vec<T>,
|
||||||
list_state: ListState,
|
pub(super) list_state: ListState,
|
||||||
|
action_tx: Option<UnboundedSender<Action>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SelectionList {
|
impl<T> SelectionList<T> {
|
||||||
pub fn new(options: Vec<String>) -> Self {
|
pub fn new(options: Vec<T>) -> Self {
|
||||||
let mut list_state = ListState::default();
|
let mut list_state = ListState::default();
|
||||||
list_state.select_first();
|
list_state.select_first();
|
||||||
|
|
||||||
Self {
|
Self { config: Config::default(), options, list_state, action_tx: None }
|
||||||
options: List::new(options).highlight_style(Style::default().fg(Color::Yellow)),
|
|
||||||
list_state,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Component for SelectionList {
|
impl Component for SelectionList<Post> {
|
||||||
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
|
fn register_config_handler(&mut self, config: Config) -> Result<()> {
|
||||||
self.command_tx = Some(tx);
|
self.config = config;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_config_handler(&mut self, config: Config) -> Result<()> {
|
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
|
||||||
self.config = config;
|
self.action_tx = Some(tx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,16 +40,88 @@ impl Component for SelectionList {
|
||||||
match action {
|
match action {
|
||||||
Action::Tick => {}
|
Action::Tick => {}
|
||||||
Action::Render => {}
|
Action::Render => {}
|
||||||
|
Action::Continue(None) => {
|
||||||
|
if let Some(tx) = &self.action_tx {
|
||||||
|
tx.send(Action::Continue(self.list_state.selected()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::SelectNext => self.list_state.select_next(),
|
Action::SelectNext => self.list_state.select_next(),
|
||||||
Action::SelectPrev => self.list_state.select_previous(),
|
Action::SelectPrev => self.list_state.select_previous(),
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(None)
|
Ok(Some(action))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) -> Result<()> {
|
fn draw(
|
||||||
frame.render_stateful_widget(self.options.clone(), area, &mut self.list_state);
|
&mut self,
|
||||||
|
frame: &mut ratatui::Frame,
|
||||||
|
area: ratatui::prelude::Rect,
|
||||||
|
) -> Result<()> {
|
||||||
|
let items = self.options.iter().enumerate().map(|(i, post)| {
|
||||||
|
let bold_style = Style::default().add_modifier(Modifier::BOLD);
|
||||||
|
let accent_style = bold_style.fg(Color::LightMagenta);
|
||||||
|
|
||||||
|
let post_creation_date = post
|
||||||
|
.created_at
|
||||||
|
.as_ref()
|
||||||
|
.map(|dt| DateTime::parse_from_rfc3339(dt.as_str()))
|
||||||
|
.and_then(Result::ok)
|
||||||
|
.map_or(DateTime::UNIX_EPOCH.date_naive().to_string(), |dt| {
|
||||||
|
dt.date_naive().to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let arrow_or_pad =
|
||||||
|
if self.list_state.selected().is_some_and(|selection| selection == i) {
|
||||||
|
"▶ ".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{:>2}", " ")
|
||||||
|
};
|
||||||
|
|
||||||
|
let padded_date = format!("{:>10}", post_creation_date);
|
||||||
|
|
||||||
|
let title_spans = vec![
|
||||||
|
Span::styled(arrow_or_pad, accent_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(padded_date, bold_style),
|
||||||
|
Span::styled(" • ", accent_style),
|
||||||
|
Span::styled(
|
||||||
|
post.title.clone().unwrap_or("[object Object]".to_string()), // LMAOOO
|
||||||
|
accent_style,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut list_content = vec![Line::from(title_spans)];
|
||||||
|
|
||||||
|
let line_format = [
|
||||||
|
Span::raw(format!("{:>14}", " ")),
|
||||||
|
Span::styled("┊", Style::default().add_modifier(Modifier::DIM)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let subtitle_span = Span::raw(
|
||||||
|
[
|
||||||
|
" ",
|
||||||
|
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(), &[Span::raw("")]].concat()));
|
||||||
|
|
||||||
|
ListItem::new(list_content)
|
||||||
|
});
|
||||||
|
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.highlight_style(Style::default())
|
||||||
|
.highlight_symbol(""),
|
||||||
|
area,
|
||||||
|
&mut self.list_state,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
use std::sync::{atomic::{AtomicUsize, Ordering}, Arc};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use ratatui::{prelude::*, widgets::*};
|
use ratatui::prelude::*;
|
||||||
|
use ratatui::widgets::*;
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
use super::Component;
|
use super::Component;
|
||||||
use crate::{action::Action, config::Config};
|
use crate::action::Action;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Tabs {
|
pub struct Tabs {
|
||||||
|
@ -17,11 +20,7 @@ pub struct Tabs {
|
||||||
|
|
||||||
impl Tabs {
|
impl Tabs {
|
||||||
pub fn new(tabs: Vec<&'static str>, selected_tab: Arc<AtomicUsize>) -> Self {
|
pub fn new(tabs: Vec<&'static str>, selected_tab: Arc<AtomicUsize>) -> Self {
|
||||||
Self {
|
Self { tabs, selected_tab, ..Default::default() }
|
||||||
tabs,
|
|
||||||
selected_tab,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next(&mut self) {
|
pub fn next(&mut self) {
|
||||||
|
@ -69,9 +68,7 @@ impl Component for Tabs {
|
||||||
|
|
||||||
for (i, &tab) in self.tabs.iter().enumerate() {
|
for (i, &tab) in self.tabs.iter().enumerate() {
|
||||||
let style = if self.selected_tab.load(Ordering::Relaxed) == i {
|
let style = if self.selected_tab.load(Ordering::Relaxed) == i {
|
||||||
Style::default()
|
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||||
.fg(Color::Magenta)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::White)
|
Style::default().fg(Color::White)
|
||||||
};
|
};
|
||||||
|
@ -81,28 +78,19 @@ 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
|
tab_lines[1].spans.push(Span::styled(format!(" {} ", tab), style));
|
||||||
.push(Span::styled("│", Style::default().fg(Color::DarkGray)));
|
tab_lines[1].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("│", Style::default().fg(Color::DarkGray)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let tabs_widget = Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE));
|
let tabs_widget =
|
||||||
|
Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE));
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
tabs_widget,
|
tabs_widget,
|
||||||
Rect {
|
Rect { x: area.x, y: area.y, width: area.width, height: 2 },
|
||||||
x: area.x,
|
|
||||||
y: area.y,
|
|
||||||
width: area.width,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
#![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, env, path::PathBuf};
|
use std::collections::HashMap;
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
@ -8,10 +10,12 @@ use derive_deref::{Deref, DerefMut};
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
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, Deserialize};
|
use serde::de::Deserializer;
|
||||||
|
use serde::Deserialize;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::{action::Action, app::Mode};
|
use crate::action::Action;
|
||||||
|
use crate::app::Mode;
|
||||||
|
|
||||||
const CONFIG: &str = include_str!("../.config/config.json5");
|
const CONFIG: &str = include_str!("../.config/config.json5");
|
||||||
|
|
||||||
|
@ -36,13 +40,9 @@ pub struct Config {
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
|
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
|
||||||
pub static ref DATA_FOLDER: Option<PathBuf> =
|
pub static ref DATA_FOLDER: Option<PathBuf> =
|
||||||
env::var(format!("{}_DATA", PROJECT_NAME.clone()))
|
env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from);
|
||||||
.ok()
|
|
||||||
.map(PathBuf::from);
|
|
||||||
pub static ref CONFIG_FOLDER: Option<PathBuf> =
|
pub static ref CONFIG_FOLDER: Option<PathBuf> =
|
||||||
env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
|
env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from);
|
||||||
.ok()
|
|
||||||
.map(PathBuf::from);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
@ -63,9 +63,8 @@ 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
|
||||||
|
@ -80,9 +79,7 @@ impl Config {
|
||||||
for (mode, default_bindings) in default_config.keybindings.iter() {
|
for (mode, default_bindings) in default_config.keybindings.iter() {
|
||||||
let user_bindings = cfg.keybindings.entry(*mode).or_default();
|
let user_bindings = cfg.keybindings.entry(*mode).or_default();
|
||||||
for (key, cmd) in default_bindings.iter() {
|
for (key, cmd) in default_bindings.iter() {
|
||||||
user_bindings
|
user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone());
|
||||||
.entry(key.clone())
|
|
||||||
.or_insert_with(|| cmd.clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (mode, default_styles) in default_config.styles.iter() {
|
for (mode, default_styles) in default_config.styles.iter() {
|
||||||
|
@ -128,8 +125,7 @@ pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
|
||||||
impl<'de> Deserialize<'de> for KeyBindings {
|
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(deserializer)?;
|
let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
|
||||||
|
|
||||||
let keybindings = parsed_map
|
let keybindings = parsed_map
|
||||||
|
@ -324,8 +320,7 @@ pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
|
||||||
impl<'de> Deserialize<'de> for Styles {
|
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(deserializer)?;
|
let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
|
||||||
|
|
||||||
let styles = parsed_map
|
let styles = parsed_map
|
||||||
|
@ -387,22 +382,13 @@ fn parse_color(s: &str) -> Option<Color> {
|
||||||
let s = s.trim_end();
|
let s = s.trim_end();
|
||||||
if s.contains("bright color") {
|
if s.contains("bright color") {
|
||||||
let s = s.trim_start_matches("bright ");
|
let s = s.trim_start_matches("bright ");
|
||||||
let c = s
|
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
|
||||||
.trim_start_matches("color")
|
|
||||||
.parse::<u8>()
|
|
||||||
.unwrap_or_default();
|
|
||||||
Some(Color::Indexed(c.wrapping_shl(8)))
|
Some(Color::Indexed(c.wrapping_shl(8)))
|
||||||
} else if s.contains("color") {
|
} else if s.contains("color") {
|
||||||
let c = s
|
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
|
||||||
.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 = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
|
let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
|
||||||
|
@ -554,10 +540,7 @@ 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!(
|
||||||
|
|
|
@ -15,12 +15,6 @@ pub fn init() -> Result<()> {
|
||||||
.into_hooks();
|
.into_hooks();
|
||||||
eyre_hook.install()?;
|
eyre_hook.install()?;
|
||||||
std::panic::set_hook(Box::new(move |panic_info| {
|
std::panic::set_hook(Box::new(move |panic_info| {
|
||||||
// if let Ok(mut t) = crate::tui::Tui::new() {
|
|
||||||
// if let Err(r) = t.exit() {
|
|
||||||
// error!("Unable to exit Terminal: {:?}", r);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
{
|
{
|
||||||
use human_panic::{handle_dump, metadata, print_msg};
|
use human_panic::{handle_dump, metadata, print_msg};
|
||||||
|
@ -42,10 +36,11 @@ pub fn init() -> Result<()> {
|
||||||
.lineno_suffix(true)
|
.lineno_suffix(true)
|
||||||
.verbosity(better_panic::Verbosity::Full)
|
.verbosity(better_panic::Verbosity::Full)
|
||||||
.create_panic_handler()(panic_info);
|
.create_panic_handler()(panic_info);
|
||||||
}
|
|
||||||
|
|
||||||
std::process::exit(libc::EXIT_FAILURE);
|
std::process::exit(libc::EXIT_FAILURE);
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -59,7 +59,7 @@ impl KeyCodeExt for KeyCode {
|
||||||
19 => KeyCode::Pause,
|
19 => KeyCode::Pause,
|
||||||
|
|
||||||
// Anything else
|
// Anything else
|
||||||
0 | _ => KeyCode::Null,
|
_ => KeyCode::Null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,6 +107,7 @@ impl KeyCodeExt for KeyCode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rustfmt::skip]
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
54
src/landing.rs
Normal file
54
src/landing.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
use std::io;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use actix_web::middleware::Logger;
|
||||||
|
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
||||||
|
use rust_embed::Embed;
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
|
macro_rules! embedded_route {
|
||||||
|
($path:expr) => {
|
||||||
|
match WebLandingServer::get($path) {
|
||||||
|
Some(content) => HttpResponse::Ok()
|
||||||
|
.content_type(mime_guess::from_path($path).first_or_octet_stream().as_ref())
|
||||||
|
.body(content.data.into_owned()),
|
||||||
|
None => HttpResponse::NotFound().body("404 Not Found"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
($name:ident,$pat:expr,$path:literal) => {
|
||||||
|
#[actix_web::get($pat)]
|
||||||
|
async fn $name() -> impl Responder {
|
||||||
|
embedded_route!($path)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
($name:ident,$pat:expr) => {
|
||||||
|
#[actix_web::get($pat)]
|
||||||
|
async fn $name(path: web::Path<String>) -> impl Responder {
|
||||||
|
embedded_route!(path.as_str())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Embed)]
|
||||||
|
#[folder = "www/build"]
|
||||||
|
pub struct WebLandingServer;
|
||||||
|
|
||||||
|
impl WebLandingServer {
|
||||||
|
#[instrument(level = "trace")]
|
||||||
|
pub async fn start(addr: SocketAddr) -> io::Result<()> {
|
||||||
|
tracing::info!("Web server listening on {}", addr);
|
||||||
|
HttpServer::new(|| {
|
||||||
|
// TODO: register a default service for a nicer 404 page
|
||||||
|
App::new().service(index).service(favicon).service(dist).wrap(Logger::default())
|
||||||
|
})
|
||||||
|
.bind(addr)?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embedded_route!(index, "/", "index.html");
|
||||||
|
embedded_route!(favicon, "/favicon.png", "favicon.png");
|
||||||
|
embedded_route!(dist, "/{path:.*}");
|
|
@ -1,8 +1,11 @@
|
||||||
use std::io::stderr;
|
use std::io::stderr;
|
||||||
|
|
||||||
use color_eyre::{eyre::eyre, Result};
|
use color_eyre::eyre::eyre;
|
||||||
|
use color_eyre::Result;
|
||||||
use tracing_error::ErrorLayer;
|
use tracing_error::ErrorLayer;
|
||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter, util::TryInitError};
|
use tracing_subscriber::prelude::*;
|
||||||
|
use tracing_subscriber::util::TryInitError;
|
||||||
|
use tracing_subscriber::{fmt, EnvFilter};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
|
|
||||||
|
@ -27,14 +30,18 @@ pub fn init() -> Result<()> {
|
||||||
//
|
//
|
||||||
|
|
||||||
// Stage 1: Construct base filter
|
// Stage 1: Construct base filter
|
||||||
let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into());
|
let env_filter = EnvFilter::builder().with_default_directive(if cfg!(debug_assertions) {
|
||||||
|
tracing::Level::DEBUG.into()
|
||||||
|
} else {
|
||||||
|
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.try_from_env().unwrap_or_else(|_| {
|
let env_filter = env_filter
|
||||||
env_filter
|
.try_from_env()
|
||||||
.with_env_var(LOG_ENV.to_string())
|
.unwrap_or_else(|_| env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy())
|
||||||
.from_env_lossy()
|
.add_directive("russh::cipher=info".parse().unwrap())
|
||||||
});
|
.add_directive("tui_markdown=info".parse().unwrap());
|
||||||
|
|
||||||
// Stage 3: Enable directives to reduce verbosity for release mode builds
|
// Stage 3: Enable directives to reduce verbosity for release mode builds
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
|
@ -52,6 +59,7 @@ pub fn init() -> Result<()> {
|
||||||
// Build the subscriber and apply it
|
// Build the subscriber and apply it
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(env_filter)
|
.with(env_filter)
|
||||||
|
.with(ErrorLayer::default())
|
||||||
.with(
|
.with(
|
||||||
// Logging to file
|
// Logging to file
|
||||||
fmt::layer()
|
fmt::layer()
|
||||||
|
@ -66,15 +74,19 @@ pub fn init() -> Result<()> {
|
||||||
let layer = fmt::layer()
|
let layer = fmt::layer()
|
||||||
.with_writer(stderr)
|
.with_writer(stderr)
|
||||||
.with_timer(tracing_subscriber::fmt::time())
|
.with_timer(tracing_subscriber::fmt::time())
|
||||||
|
.with_thread_ids(true)
|
||||||
.with_ansi(true);
|
.with_ansi(true);
|
||||||
|
|
||||||
// Enable compact mode for release logs
|
// Enable compact mode for release logs
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
return layer.compact();
|
let layer = layer
|
||||||
#[cfg(debug_assertions)]
|
.compact()
|
||||||
|
.without_time()
|
||||||
|
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::NONE)
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(false);
|
||||||
layer
|
layer
|
||||||
})
|
})
|
||||||
.with(ErrorLayer::default())
|
|
||||||
.try_init()
|
.try_init()
|
||||||
.map_err(|err: TryInitError| eyre!(err))
|
.map_err(|err: TryInitError| eyre!(err))
|
||||||
}
|
}
|
||||||
|
|
82
src/main.rs
82
src/main.rs
|
@ -1,22 +1,22 @@
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
use clap::Parser as _;
|
use clap::Parser as _;
|
||||||
use cli::Cli;
|
use cli::Cli;
|
||||||
use color_eyre::{eyre::eyre, Result};
|
use color_eyre::eyre::eyre;
|
||||||
|
use color_eyre::Result;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use russh::{
|
use russh::keys::PrivateKey;
|
||||||
keys::PrivateKey,
|
use russh::server::Config;
|
||||||
server::{Config, Server},
|
use russh::MethodSet;
|
||||||
MethodSet,
|
|
||||||
};
|
|
||||||
use ssh::SshServer;
|
use ssh::SshServer;
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
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 crate::landing::WebLandingServer;
|
||||||
|
|
||||||
mod action;
|
mod action;
|
||||||
mod app;
|
mod app;
|
||||||
#[cfg(feature = "blog")]
|
#[cfg(feature = "blog")]
|
||||||
|
@ -26,6 +26,7 @@ mod components;
|
||||||
mod config;
|
mod config;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod keycode;
|
mod keycode;
|
||||||
|
mod landing;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod ssh;
|
mod ssh;
|
||||||
mod tui;
|
mod tui;
|
||||||
|
@ -35,24 +36,12 @@ const SSH_KEYS: &[&[u8]] = &[
|
||||||
include_bytes!(concat!(env!("OUT_DIR"), "/ecdsa.pem")),
|
include_bytes!(concat!(env!("OUT_DIR"), "/ecdsa.pem")),
|
||||||
include_bytes!(concat!(env!("OUT_DIR"), "/ed25519.pem")),
|
include_bytes!(concat!(env!("OUT_DIR"), "/ed25519.pem")),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[rustfmt::skip]
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub(crate) static ref OPTIONS: Cli = Cli::parse();
|
pub(crate) static ref OPTIONS: Cli = Cli::parse();
|
||||||
pub(crate) static ref SOCKET_ADDR: Option<SocketAddr> = Some(SocketAddr::from((
|
pub(crate) static ref SSH_SOCKET_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.ssh_port)));
|
||||||
// Convert the hostname IP to a fixed size array of [u8; 4]
|
pub(crate) static ref WEB_SERVER_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.web_port)));
|
||||||
TryInto::<[u8; 4]>::try_into(
|
|
||||||
OPTIONS
|
|
||||||
.host
|
|
||||||
.splitn(4, ".")
|
|
||||||
.map(|octet_str| u8::from_str_radix(octet_str, 10)
|
|
||||||
.map_err(|_| eyre!("Octet component out of range (expected u8)")))
|
|
||||||
.collect::<Result<Vec<u8>>>()
|
|
||||||
.ok()?
|
|
||||||
)
|
|
||||||
.ok()?,
|
|
||||||
|
|
||||||
// The port to listen on
|
|
||||||
OPTIONS.port
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
@ -61,24 +50,41 @@ 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 socket_addr = SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
|
let ssh_socket_addr = SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
|
||||||
let config = ssh_config();
|
let web_server_addr = WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
|
||||||
|
|
||||||
tracing::info!("Attempting to listen on {}", socket_addr);
|
tokio::select! {
|
||||||
SshServer::default()
|
ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res,
|
||||||
.run_on_socket(Arc::new(config), &TcpListener::bind(socket_addr).await?)
|
web_res = WebLandingServer::start(web_server_addr) => web_res.map_err(|err| eyre!(err)),
|
||||||
.await
|
}
|
||||||
.map_err(|err| eyre!(err))
|
}
|
||||||
|
|
||||||
|
/// Converts the supplied hostname IP via CLI to a fixed size array of `[u8; 4]`, defaults to `127.0.0.1`
|
||||||
|
pub fn host_ip() -> Result<[u8; 4]> {
|
||||||
|
TryInto::<[u8; 4]>::try_into(
|
||||||
|
OPTIONS
|
||||||
|
.host
|
||||||
|
.splitn(4, ".")
|
||||||
|
.map(|octet_str| {
|
||||||
|
octet_str
|
||||||
|
.parse::<u8>()
|
||||||
|
.map_err(|_| eyre!("Octet component out of range (expected u8)"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<u8>>>()?,
|
||||||
|
)
|
||||||
|
.map_err(|_| eyre!("Invalid host IP provided"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ssh_config() -> Config {
|
fn ssh_config() -> Config {
|
||||||
let mut conf = Config::default();
|
let conf = Config {
|
||||||
conf.methods = MethodSet::NONE;
|
methods: MethodSet::NONE,
|
||||||
conf.keys = SSH_KEYS
|
keys: SSH_KEYS
|
||||||
.to_vec()
|
.to_vec()
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|pem| PrivateKey::from_openssh(pem).ok())
|
.filter_map(|pem| PrivateKey::from_openssh(pem).ok())
|
||||||
.collect();
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
tracing::trace!("SSH config: {:#?}", conf);
|
tracing::trace!("SSH config: {:#?}", conf);
|
||||||
conf
|
conf
|
||||||
|
|
157
src/ssh.rs
157
src/ssh.rs
|
@ -1,22 +1,21 @@
|
||||||
use std::{io::Write, net::SocketAddr, sync::Arc};
|
use std::io::Write;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use color_eyre::eyre::{self, eyre};
|
use color_eyre::eyre::{self, eyre};
|
||||||
use russh::{
|
use russh::server::{Auth, Config, Handle, Handler, Msg, Server, Session};
|
||||||
server::{Auth, Handle, Handler, Msg, Server, Session},
|
use russh::{Channel, ChannelId, CryptoVec, Pty};
|
||||||
Channel, ChannelId, CryptoVec, Pty,
|
use tokio::net::TcpListener;
|
||||||
};
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
use tokio::{
|
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
|
||||||
runtime::Handle as TokioHandle,
|
|
||||||
sync::{mpsc, oneshot, Mutex, RwLock},
|
|
||||||
};
|
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::{
|
use crate::app::App;
|
||||||
app::App,
|
use crate::tui::backend::SshBackend;
|
||||||
tui::{backend::SshBackend, Terminal, Tui},
|
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
|
||||||
OPTIONS,
|
use crate::tui::{Terminal, Tui};
|
||||||
};
|
use crate::OPTIONS;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TermWriter {
|
pub struct TermWriter {
|
||||||
|
@ -30,11 +29,7 @@ impl TermWriter {
|
||||||
#[instrument(skip(session, channel), level = "trace", fields(channel_id = %channel.id()))]
|
#[instrument(skip(session, channel), level = "trace", fields(channel_id = %channel.id()))]
|
||||||
fn new(session: Handle, channel: Channel<Msg>) -> Self {
|
fn new(session: Handle, channel: Channel<Msg>) -> Self {
|
||||||
tracing::trace!("Acquiring new SSH writer");
|
tracing::trace!("Acquiring new SSH writer");
|
||||||
Self {
|
Self { session, channel, inner: CryptoVec::new() }
|
||||||
session,
|
|
||||||
channel,
|
|
||||||
inner: CryptoVec::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush_inner(&mut self) -> std::io::Result<()> {
|
fn flush_inner(&mut self) -> std::io::Result<()> {
|
||||||
|
@ -44,9 +39,11 @@ impl TermWriter {
|
||||||
.data(self.channel.id(), self.inner.clone())
|
.data(self.channel.id(), self.inner.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
std::io::Error::other(String::from_iter(err.iter().map(|item| *item as char)))
|
std::io::Error::other(String::from_iter(
|
||||||
|
err.iter().map(|item| *item as char),
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.and_then(|()| Ok(self.inner.clear()))
|
.map(|_| self.inner.clear())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,10 +51,7 @@ impl TermWriter {
|
||||||
impl Write for TermWriter {
|
impl Write for TermWriter {
|
||||||
#[instrument(skip(self, buf), level = "debug")]
|
#[instrument(skip(self, buf), level = "debug")]
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
@ -69,7 +63,9 @@ impl Write for TermWriter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)>,
|
||||||
|
@ -84,13 +80,18 @@ 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,
|
||||||
resize_rx,
|
resize_rx,
|
||||||
)
|
)
|
||||||
|
.inspect_err(|err| tracing::error!("Failed to create app: {err}"))
|
||||||
.ok()
|
.ok()
|
||||||
.map(|app| Arc::new(Mutex::new(app))),
|
.map(|app| Arc::new(Mutex::new(app))),
|
||||||
tui: Arc::new(RwLock::new(None)),
|
tui: Arc::new(RwLock::new(None)),
|
||||||
|
@ -103,22 +104,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<()> {
|
||||||
// let mut tui_inner = Tui::new(writer.clone())?;
|
app.lock_owned().await.run(term, tui).await?;
|
||||||
// let mut app_tmp = app.lock().await;
|
session.close(channel_id).await.map_err(|_| eyre!("failed to close session"))?;
|
||||||
// app_tmp.resize(&mut tui_inner, 169, 34)?;
|
|
||||||
// drop(app_tmp);
|
|
||||||
// tui.write().await.get_or_insert(tui_inner);
|
|
||||||
|
|
||||||
app.lock_owned().await.run(writer, tui).await?;
|
|
||||||
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
|
||||||
|
@ -151,23 +143,24 @@ 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: Result<(), Box<dyn std::error::Error + Send + Sync>> = (|| async {
|
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(())
|
||||||
)
|
};
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
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}");
|
||||||
|
@ -182,6 +175,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,
|
||||||
|
@ -197,6 +215,18 @@ impl Handler for SshSession {
|
||||||
tracing::info!("PTY requested by terminal: {term}");
|
tracing::info!("PTY requested by terminal: {term}");
|
||||||
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
|
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
|
||||||
|
|
||||||
|
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(UnsupportedReason::Unsized));
|
||||||
|
}
|
||||||
|
|
||||||
if !term.contains("xterm") {
|
if !term.contains("xterm") {
|
||||||
session.channel_failure(channel_id)?;
|
session.channel_failure(channel_id)?;
|
||||||
return Err(eyre!("Unsupported terminal type: {term}"));
|
return Err(eyre!("Unsupported terminal type: {term}"));
|
||||||
|
@ -235,12 +265,15 @@ impl Handler for SshSession {
|
||||||
_: ChannelId,
|
_: 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
|
self.terminal_info.write().await.set_font_size((
|
||||||
// That would involve first updating the Backend's size and then updating the rect via the event
|
(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"))?;
|
||||||
|
@ -252,13 +285,21 @@ impl Handler for SshSession {
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct SshServer;
|
pub struct SshServer;
|
||||||
|
|
||||||
|
impl SshServer {
|
||||||
|
#[instrument(level = "trace")]
|
||||||
|
pub async fn start(addr: SocketAddr, config: Config) -> eyre::Result<()> {
|
||||||
|
let listener = TcpListener::bind(addr).await?;
|
||||||
|
|
||||||
|
Self.run_on_socket(Arc::new(config), &listener).await.map_err(|err| eyre!(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Server for SshServer {
|
impl Server for SshServer {
|
||||||
type Handler = SshSession;
|
type Handler = SshSession;
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
|
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
|
||||||
let session = tokio::task::block_in_place(|| SshSession::new());
|
tokio::task::block_in_place(SshSession::new)
|
||||||
session
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
use std::{io, ops::{Deref, DerefMut}};
|
use std::io;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
use ratatui::{
|
use ratatui::backend::{Backend, CrosstermBackend, WindowSize};
|
||||||
backend::{Backend, CrosstermBackend, WindowSize},
|
use ratatui::layout::Size;
|
||||||
layout::Size,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::ssh::TermWriter;
|
use crate::ssh::TermWriter;
|
||||||
|
|
||||||
|
@ -15,28 +14,30 @@ pub struct SshBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SshBackend {
|
impl SshBackend {
|
||||||
pub fn new(writer: TermWriter, init_width: u16, init_height: u16, init_pixel_width: u16, init_pixdel_height: u16) -> Self {
|
pub fn new(
|
||||||
|
writer: TermWriter,
|
||||||
|
init_width: u16,
|
||||||
|
init_height: u16,
|
||||||
|
init_pixel_width: u16,
|
||||||
|
init_pixel_height: u16,
|
||||||
|
) -> Self {
|
||||||
let inner = CrosstermBackend::new(writer);
|
let inner = CrosstermBackend::new(writer);
|
||||||
SshBackend {
|
SshBackend {
|
||||||
inner,
|
inner,
|
||||||
dims: (init_width, init_height),
|
dims: (init_width, init_height),
|
||||||
pixel: (init_pixel_width, init_pixdel_height),
|
pixel: (init_pixel_width, init_pixel_height),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Backend for SshBackend {
|
impl Backend for SshBackend {
|
||||||
fn size(&self) -> io::Result<Size> {
|
fn size(&self) -> io::Result<Size> {
|
||||||
Ok(Size {
|
Ok(Size { width: self.dims.0, height: self.dims.1 })
|
||||||
width: self.dims.0,
|
|
||||||
height: self.dims.1,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)>, {
|
||||||
{
|
|
||||||
self.inner.draw(content)
|
self.inner.draw(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,33 +1,29 @@
|
||||||
#![allow(dead_code)] // TODO: Remove this once you start using the code
|
#![allow(dead_code)] // TODO: Remove this once you start using the code
|
||||||
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use backend::SshBackend;
|
use backend::SshBackend;
|
||||||
use color_eyre::Result;
|
use color_eyre::Result;
|
||||||
use crossterm::{
|
use crossterm::cursor;
|
||||||
cursor,
|
use crossterm::event::{
|
||||||
event::{
|
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
KeyEvent, MouseEvent,
|
||||||
KeyEvent, MouseEvent,
|
|
||||||
},
|
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
};
|
||||||
|
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use status::TuiStatus;
|
use status::TuiStatus;
|
||||||
use tokio::{
|
use tokio::runtime::Handle;
|
||||||
runtime::Handle,
|
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||||
sync::{
|
use tokio::sync::{Mutex, RwLock};
|
||||||
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
use tokio::task::{block_in_place, JoinHandle};
|
||||||
Mutex, RwLock,
|
use tokio::time::{interval, sleep, timeout};
|
||||||
},
|
|
||||||
task::{block_in_place, JoinHandle},
|
|
||||||
time::{interval, sleep, timeout},
|
|
||||||
};
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::error;
|
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 {
|
||||||
|
@ -126,9 +122,7 @@ impl Tui {
|
||||||
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
|
event_tx.send(Event::Init).expect("failed to send init event");
|
||||||
.send(Event::Init)
|
|
||||||
.expect("failed to send init event");
|
|
||||||
|
|
||||||
let suspension_status = Arc::clone(&status);
|
let suspension_status = Arc::clone(&status);
|
||||||
loop {
|
loop {
|
||||||
|
@ -165,12 +159,10 @@ impl Tui {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(_) = timeout(attempt_timeout, self.await_shutdown()).await {
|
if timeout(attempt_timeout, self.await_shutdown()).await.is_err() {
|
||||||
timeout(attempt_timeout, abort_shutdown)
|
timeout(attempt_timeout, abort_shutdown).await.inspect_err(|_| {
|
||||||
.await
|
error!("Failed to abort task in 100 milliseconds for unknown reason")
|
||||||
.inspect_err(|_| {
|
})?;
|
||||||
error!("Failed to abort task in 100 milliseconds for unknown reason")
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -198,21 +190,18 @@ impl Tui {
|
||||||
pub async fn exit(&mut self) -> Result<()> {
|
pub async fn exit(&mut self) -> Result<()> {
|
||||||
self.stop().await?;
|
self.stop().await?;
|
||||||
// TODO: enable raw mode for pty
|
// TODO: enable raw mode for pty
|
||||||
if true || crossterm::terminal::is_raw_mode_enabled()? {
|
let mut term = self.terminal.try_lock()?;
|
||||||
let mut term = self.terminal.try_lock()?;
|
term.flush()?;
|
||||||
term.flush()?;
|
|
||||||
|
|
||||||
if self.paste {
|
if self.paste {
|
||||||
crossterm::execute!(term.backend_mut(), DisableBracketedPaste)?;
|
crossterm::execute!(term.backend_mut(), DisableBracketedPaste)?;
|
||||||
}
|
|
||||||
|
|
||||||
if self.mouse {
|
|
||||||
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
|
|
||||||
// crossterm::terminal::disable_raw_mode()?; // TODO: disable raw mode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.mouse {
|
||||||
|
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,9 +247,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}"))
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
use std::{future::{Future, IntoFuture}, pin::Pin, sync::Arc};
|
use std::future::{Future, IntoFuture};
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use futures::future;
|
use futures::future;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
153
src/tui/terminal.rs
Normal file
153
src/tui/terminal.rs
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
use std::default::Default;
|
||||||
|
|
||||||
|
use default_variant::default;
|
||||||
|
use ratatui_image::picker::{Capability, ProtocolType};
|
||||||
|
use ratatui_image::FontSize;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use strum::Display;
|
||||||
|
|
||||||
|
pub const DEFAULT_FONT_SIZE: FontSize = (12, 12);
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct TerminalInfo {
|
||||||
|
kind: TerminalKind,
|
||||||
|
font_size: Option<FontSize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalInfo {
|
||||||
|
/// Get the terminal kind.
|
||||||
|
pub fn kind(&self) -> &TerminalKind {
|
||||||
|
&self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the font size.
|
||||||
|
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.
|
||||||
|
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(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
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![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sveltejs/adapter-auto": "^6.0.0",
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
"@sveltejs/kit": "^2.22.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
@ -173,7 +173,7 @@
|
||||||
|
|
||||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||||
|
|
||||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-shOuLI5D2s+0zTv2ab5M5PqfknXqWbKi+0UwB9yLTRIdzsK1R93JOO8jNhIYSHdW+IYXIYnLniu+JZqXs7h9Wg=="],
|
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.9", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw=="],
|
||||||
|
|
||||||
"@sveltejs/kit": ["@sveltejs/kit@2.28.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-qrhygwHV5r6JrvCw4gwNqqxYGDi5YbajocxfKgFXmSFpFo8wQobUvsM0OfakN4h+0LEmXtqHRrC6BcyAkOwyoQ=="],
|
"@sveltejs/kit": ["@sveltejs/kit@2.28.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-qrhygwHV5r6JrvCw4gwNqqxYGDi5YbajocxfKgFXmSFpFo8wQobUvsM0OfakN4h+0LEmXtqHRrC6BcyAkOwyoQ=="],
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@sveltejs/adapter-auto": "^6.0.0",
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
"@sveltejs/kit": "^2.22.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|
1
www/src/routes/+layout.ts
Normal file
1
www/src/routes/+layout.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const prerender = true;
|
|
@ -1,4 +1,4 @@
|
||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
|
Loading…
Add table
Reference in a new issue