Restore opened tabs and windows upon restart (#72)

This commit is contained in:
Ulyssa 2023-06-28 23:42:31 -07:00
parent 758397eb5a
commit 7c39e88ba2
No known key found for this signature in database
GPG key ID: 1B3965A3D18B9B64
8 changed files with 428 additions and 25 deletions

6
Cargo.lock generated
View file

@ -2136,8 +2136,9 @@ dependencies = [
[[package]]
name = "modalkit"
version = "0.0.15"
source = "git+https://github.com/ulyssa/modalkit?rev=f641162#f6411625caa1ddb764c91a3fc47e052cfdc405b5"
version = "0.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f38eef0b4f6377e73d1082d508cae1df4d99a94d25361538131d839f292aa49"
dependencies = [
"anymap2",
"arboard",
@ -2151,6 +2152,7 @@ dependencies = [
"ratatui",
"regex",
"ropey",
"serde",
"textwrap",
"thiserror",
"unicode-segmentation",

View file

@ -51,8 +51,7 @@ unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]}
[dependencies.modalkit]
git = "https://github.com/ulyssa/modalkit"
rev = "f641162"
version = "0.0.16"
[dependencies.matrix-sdk]
version = "0.6"

View file

@ -36,6 +36,10 @@ These options are configured as a map under the profiles name.
> Overwrite general settings for this account. The fields are identical to
> those in *TUNABLES*.
**layout** (type: startup layout object)
> Overwrite general settings for this account. The fields are identical to
> those in *STARTUP LAYOUT*.
**dirs** (type: XDG overrides object)
> Overwrite general settings for this account. The fields are identical to
> those in *DIRECTORIES*.
@ -92,6 +96,23 @@ maps containing the following key value pairs.
> _light-cyan_, _light-green_, _light-magenta_, _light-red_,
> _light-yellow_, _magenta_, _none_, _red_, _white_, _yellow_
# STARTUP LAYOUT
Specifies what initial set of tabs and windows to show when starting the
client. Configured as an object under the key *layout*.
**style** (type: string)
> Specifies what window layout to load when starting. Valid values are
> _restore_ to restore the layout from the last time the client was exited,
> _new_ to open a single window (uses the value of _default\_room_ if set), or
> _config_ to open the layout described under _tabs_.
**tabs** (type: array of window objects)
> If **style** is set to _config_, then this value will be used to open a set
> of tabs and windows at startup. Each object can contain either a **window**
> key specifying a username, room identifier or room alias to show, or a
> **split** key specifying an array of window objects.
# DIRECTORIES
Specifies the directories to save data in. Configured as a map under the key

View file

@ -1,12 +1,23 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::fmt::{self, Display};
use std::hash::Hash;
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use emojis::Emoji;
use serde::{
de::Error as SerdeError,
de::Visitor,
Deserialize,
Deserializer,
Serialize,
Serializer,
};
use tokio::sync::Mutex as AsyncMutex;
use url::Url;
use matrix_sdk::{
encryption::verification::SasVerification,
@ -715,17 +726,159 @@ impl ApplicationStore for ChatStore {}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambId {
/// A Matrix room.
Room(OwnedRoomId),
/// The `:rooms` window.
DirectList,
/// The `:members` window for a given Matrix room.
MemberList(OwnedRoomId),
/// The `:rooms` window.
RoomList,
/// The `:spaces` window.
SpaceList,
/// The `:verify` window.
VerifyList,
/// The `:welcome` window.
Welcome,
}
impl Display for IambId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IambId::Room(room_id) => {
write!(f, "iamb://room/{room_id}")
},
IambId::MemberList(room_id) => {
write!(f, "iamb://members/{room_id}")
},
IambId::DirectList => f.write_str("iamb://dms"),
IambId::RoomList => f.write_str("iamb://rooms"),
IambId::SpaceList => f.write_str("iamb://spaces"),
IambId::VerifyList => f.write_str("iamb://verify"),
IambId::Welcome => f.write_str("iamb://welcome"),
}
}
}
impl ApplicationWindowId for IambId {}
impl Serialize for IambId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for IambId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(IambIdVisitor)
}
}
struct IambIdVisitor;
impl<'de> Visitor<'de> for IambIdVisitor {
type Value = IambId;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a valid window URL")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: SerdeError,
{
let Ok(url) = Url::parse(value) else {
return Err(E::custom("Invalid iamb window URL"));
};
if url.scheme() != "iamb" {
return Err(E::custom("Invalid iamb window URL"));
}
match url.domain() {
Some("room") => {
let Some(path) = url.path_segments() else {
return Err(E::custom("Invalid members window URL"));
};
let &[room_id] = path.collect::<Vec<_>>().as_slice() else {
return Err(E::custom("Invalid members window URL"));
};
let Ok(room_id) = OwnedRoomId::try_from(room_id) else {
return Err(E::custom("Invalid room identifier"));
};
Ok(IambId::Room(room_id))
},
Some("members") => {
let Some(path) = url.path_segments() else {
return Err(E::custom( "Invalid members window URL"));
};
let &[room_id] = path.collect::<Vec<_>>().as_slice() else {
return Err(E::custom( "Invalid members window URL"));
};
let Ok(room_id) = OwnedRoomId::try_from(room_id) else {
return Err(E::custom("Invalid room identifier"));
};
Ok(IambId::MemberList(room_id))
},
Some("dms") => {
if url.path() != "" {
return Err(E::custom("iamb://dms takes no path"));
}
Ok(IambId::DirectList)
},
Some("rooms") => {
if url.path() != "" {
return Err(E::custom("iamb://rooms takes no path"));
}
Ok(IambId::RoomList)
},
Some("spaces") => {
if url.path() != "" {
return Err(E::custom("iamb://spaces takes no path"));
}
Ok(IambId::SpaceList)
},
Some("verify") => {
if url.path() != "" {
return Err(E::custom("iamb://verify takes no path"));
}
Ok(IambId::VerifyList)
},
Some("welcome") => {
if url.path() != "" {
return Err(E::custom("iamb://welcome takes no path"));
}
Ok(IambId::Welcome)
},
Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))),
None => Err(E::custom("Invalid iamb window URL")),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RoomFocus {
Scrollback,

View file

@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
use std::process;
use clap::Parser;
use matrix_sdk::ruma::{OwnedUserId, UserId};
use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId};
use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer};
use tracing::Level;
use url::Url;
@ -19,6 +19,8 @@ use modalkit::tui::{
text::Span,
};
use super::base::IambId;
macro_rules! usage {
( $($args: tt)* ) => {
println!($($args)*);
@ -339,12 +341,43 @@ impl Directories {
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(untagged)]
pub enum WindowPath {
AliasId(OwnedRoomAliasId),
RoomId(OwnedRoomId),
UserId(OwnedUserId),
Window(IambId),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(untagged, deny_unknown_fields)]
pub enum WindowLayout {
Window { window: WindowPath },
Split { split: Vec<WindowLayout> },
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase", tag = "style")]
pub enum Layout {
/// Restore the layout from the previous session.
#[default]
Restore,
/// Open a single window using the `default_room` value.
New,
/// Open the window layouts described under `tabs`.
Config { tabs: Vec<WindowLayout> },
}
#[derive(Clone, Deserialize)]
pub struct ProfileConfig {
pub user_id: OwnedUserId,
pub url: Url,
pub settings: Option<Tunables>,
pub dirs: Option<Directories>,
pub layout: Option<Layout>,
}
#[derive(Clone, Deserialize)]
@ -353,6 +386,7 @@ pub struct IambConfig {
pub default_profile: Option<String>,
pub settings: Option<Tunables>,
pub dirs: Option<Directories>,
pub layout: Option<Layout>,
}
impl IambConfig {
@ -376,11 +410,13 @@ impl IambConfig {
#[derive(Clone)]
pub struct ApplicationSettings {
pub matrix_dir: PathBuf,
pub layout_json: PathBuf,
pub session_json: PathBuf,
pub profile_name: String,
pub profile: ProfileConfig,
pub tunables: TunableValues,
pub dirs: DirectoryValues,
pub layout: Layout,
}
impl ApplicationSettings {
@ -401,6 +437,7 @@ impl ApplicationSettings {
default_profile,
dirs,
settings: global,
layout,
} = IambConfig::load(config_json.as_path())?;
validate_profile_names(&profiles);
@ -424,10 +461,17 @@ impl ApplicationSettings {
);
};
let layout = profile.layout.take().or(layout).unwrap_or_default();
let tunables = global.unwrap_or_default();
let tunables = profile.settings.take().unwrap_or_default().merge(tunables);
let tunables = tunables.values();
let dirs = dirs.unwrap_or_default();
let dirs = profile.dirs.take().unwrap_or_default().merge(dirs);
let dirs = dirs.values();
// Set up paths that live inside the profile's data directory.
let mut profile_dir = config_dir.clone();
profile_dir.push("profiles");
profile_dir.push(profile_name.as_str());
@ -438,17 +482,23 @@ impl ApplicationSettings {
let mut session_json = profile_dir;
session_json.push("session.json");
let dirs = dirs.unwrap_or_default();
let dirs = profile.dirs.take().unwrap_or_default().merge(dirs);
let dirs = dirs.values();
// Set up paths that live inside the profile's cache directory.
let mut cache_dir = dirs.cache.clone();
cache_dir.push("profiles");
cache_dir.push(profile_name.as_str());
let mut layout_json = cache_dir.clone();
layout_json.push("layout.json");
let settings = ApplicationSettings {
matrix_dir,
layout_json,
session_json,
profile_name,
profile,
tunables,
dirs,
layout,
};
Ok(settings)
@ -496,6 +546,7 @@ impl ApplicationSettings {
mod tests {
use super::*;
use matrix_sdk::ruma::user_id;
use std::convert::TryFrom;
#[test]
fn test_profile_name_invalid() {
@ -589,4 +640,62 @@ mod tests {
})];
assert_eq!(res.users, Some(users.into_iter().collect()));
}
#[test]
fn test_parse_layout() {
let user = WindowPath::UserId(user_id!("@user:example.com").to_owned());
let alias = WindowPath::AliasId(OwnedRoomAliasId::try_from("#room:example.com").unwrap());
let room = WindowPath::RoomId(OwnedRoomId::try_from("!room:example.com").unwrap());
let dms = WindowPath::Window(IambId::DirectList);
let welcome = WindowPath::Window(IambId::Welcome);
let res: Layout = serde_json::from_str("{\"style\": \"restore\"}").unwrap();
assert_eq!(res, Layout::Restore);
let res: Layout = serde_json::from_str("{\"style\": \"new\"}").unwrap();
assert_eq!(res, Layout::New);
let res: Layout = serde_json::from_str(
"{\"style\": \"config\", \"tabs\": [{\"window\":\"@user:example.com\"}]}",
)
.unwrap();
assert_eq!(res, Layout::Config {
tabs: vec![WindowLayout::Window { window: user.clone() }]
});
let res: Layout = serde_json::from_str(
"{\
\"style\": \"config\",\
\"tabs\": [\
{\"split\":[\
{\"window\":\"@user:example.com\"},\
{\"window\":\"#room:example.com\"}\
]},\
{\"split\":[\
{\"window\":\"!room:example.com\"},\
{\"split\":[\
{\"window\":\"iamb://dms\"},\
{\"window\":\"iamb://welcome\"}\
]}\
]}\
]}",
)
.unwrap();
let split1 = WindowLayout::Split {
split: vec![
WindowLayout::Window { window: user.clone() },
WindowLayout::Window { window: alias },
],
};
let split2 = WindowLayout::Split {
split: vec![WindowLayout::Window { window: dms }, WindowLayout::Window {
window: welcome,
}],
};
let split3 = WindowLayout::Split {
split: vec![WindowLayout::Window { window: room }, split2],
};
let tabs = vec![split1, split3];
assert_eq!(res, Layout::Config { tabs });
}
}

View file

@ -6,7 +6,7 @@ use std::collections::VecDeque;
use std::convert::TryFrom;
use std::fmt::Display;
use std::fs::{create_dir_all, File};
use std::io::{stdout, BufReader, Stdout};
use std::io::{stdout, BufReader, BufWriter, Stdout};
use std::ops::DerefMut;
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
@ -89,6 +89,7 @@ use modalkit::{
Jumpable,
Promptable,
Scrollable,
TabAction,
TabContainer,
TabCount,
UIError,
@ -103,21 +104,120 @@ use modalkit::{
input::{bindings::BindingMachine, dialog::Pager, key::TerminalKey},
widgets::{
cmdbar::CommandBarState,
screen::{Screen, ScreenState},
screen::{FocusList, Screen, ScreenState, TabLayoutDescription},
windows::WindowLayoutDescription,
TerminalCursor,
TerminalExtOps,
Window,
},
};
fn config_tab_to_desc(
layout: config::WindowLayout,
store: &mut ProgramStore,
) -> IambResult<WindowLayoutDescription<IambInfo>> {
let desc = match layout {
config::WindowLayout::Window { window } => {
let ChatStore { names, worker, .. } = &mut store.application;
let window = match window {
config::WindowPath::UserId(user_id) => {
let name = user_id.to_string();
let room_id = worker.join_room(name.clone())?;
names.insert(name, room_id.clone());
IambId::Room(room_id)
},
config::WindowPath::RoomId(room_id) => IambId::Room(room_id),
config::WindowPath::AliasId(alias) => {
let name = alias.to_string();
let room_id = worker.join_room(name.clone())?;
names.insert(name, room_id.clone());
IambId::Room(room_id)
},
config::WindowPath::Window(id) => id,
};
WindowLayoutDescription::Window { window, length: None }
},
config::WindowLayout::Split { split } => {
let children = split
.into_iter()
.map(|child| config_tab_to_desc(child, store))
.collect::<IambResult<Vec<_>>>()?;
WindowLayoutDescription::Split { children, length: None }
},
};
Ok(desc)
}
fn setup_screen(
settings: ApplicationSettings,
store: &mut ProgramStore,
) -> IambResult<ScreenState<IambWindow, IambInfo>> {
let cmd = CommandBarState::new(store);
let dims = crossterm::terminal::size()?;
let area = Rect::new(0, 0, dims.0, dims.1);
match settings.layout {
config::Layout::Restore => {
if let Ok(layout) = std::fs::read(&settings.layout_json) {
let tabs: TabLayoutDescription<IambInfo> =
serde_json::from_slice(&layout).map_err(IambError::from)?;
let tabs = tabs.to_layout(area.into(), store)?;
return Ok(ScreenState::from_list(tabs, cmd));
}
},
config::Layout::New => {},
config::Layout::Config { tabs } => {
let mut list = FocusList::default();
for tab in tabs.into_iter() {
let tab = config_tab_to_desc(tab, store)?;
let tab = tab.to_layout(area.into(), store)?;
list.push(tab);
}
return Ok(ScreenState::from_list(list, cmd));
},
}
let win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, store).ok())
.or_else(|| IambWindow::open(IambId::Welcome, store).ok())
.unwrap();
return Ok(ScreenState::new(win, cmd));
}
struct Application {
store: AsyncProgramStore,
worker: Requester,
/// Terminal backend.
terminal: Terminal<CrosstermBackend<Stdout>>,
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
actstack: VecDeque<(ProgramAction, ProgramContext)>,
/// State for the Matrix client, editing, etc.
store: AsyncProgramStore,
/// UI state (open tabs, command bar, etc.) to use when rendering.
screen: ScreenState<IambWindow, IambInfo>,
/// Handle to communicate synchronously with the Matrix worker task.
worker: Requester,
/// Mapped keybindings.
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
/// Pending actions to run.
actstack: VecDeque<(ProgramAction, ProgramContext)>,
/// Whether or not the terminal is currently focused.
focused: bool,
/// The tab layout before the last executed [TabAction].
last_layout: Option<TabLayoutDescription<IambInfo>>,
}
impl Application {
@ -141,16 +241,7 @@ impl Application {
let bindings = KeyManager::new(bindings);
let mut locked = store.lock().await;
let win = settings
.tunables
.default_room
.and_then(|room| IambWindow::find(room, locked.deref_mut()).ok())
.or_else(|| IambWindow::open(IambId::Welcome, locked.deref_mut()).ok())
.unwrap();
let cmd = CommandBarState::new(locked.deref_mut());
let screen = ScreenState::new(win, cmd);
let screen = setup_screen(settings, locked.deref_mut())?;
let worker = locked.application.worker.clone();
drop(locked);
@ -165,6 +256,7 @@ impl Application {
actstack,
screen,
focused: true,
last_layout: None,
})
}
@ -312,7 +404,6 @@ impl Application {
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
Action::ShowInfoMessage(info) => Some(info),
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
Action::Jump(l, dir, count) => {
@ -328,6 +419,13 @@ impl Application {
},
// UI actions.
Action::Tab(cmd) => {
if let TabAction::Close(_, _) = &cmd {
self.last_layout = self.screen.as_description().into();
}
self.screen.tab_command(&cmd, &ctx, store)?
},
Action::RedrawScreen => {
self.screen.clear_message();
self.redraw(true, store)?;
@ -494,6 +592,19 @@ impl Application {
}
}
if let Some(ref layout) = self.last_layout {
let locked = self.store.lock().await;
let path = locked.application.settings.layout_json.as_path();
path.parent().map(create_dir_all).transpose()?;
let file = File::create(path)?;
let writer = BufWriter::new(file);
if let Err(e) = serde_json::to_writer(writer, layout) {
tracing::error!("Failed to save window layout while exiting: {}", e);
}
}
crossterm::terminal::disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?;

View file

@ -156,6 +156,10 @@ impl<'a> TextPrinter<'a> {
pub fn push_str(&mut self, s: &'a str, style: Style) {
let style = self.base_style.patch(style);
if self.width == 0 {
return;
}
for mut word in UnicodeSegmentation::split_word_bounds(s) {
if let "\n" | "\r\n" = word {
// Render embedded newlines as spaces.

View file

@ -195,16 +195,20 @@ pub fn mock_tunables() -> TunableValues {
pub fn mock_settings() -> ApplicationSettings {
ApplicationSettings {
matrix_dir: PathBuf::new(),
layout_json: PathBuf::new(),
session_json: PathBuf::new(),
profile_name: "test".into(),
profile: ProfileConfig {
user_id: user_id!("@user:example.com").to_owned(),
url: Url::parse("https://example.com").unwrap(),
settings: None,
dirs: None,
layout: None,
},
tunables: mock_tunables(),
dirs: mock_dirs(),
layout: Default::default(),
}
}