diff --git a/src/app/history.rs b/src/app/history.rs new file mode 100644 index 0000000..28d78fc --- /dev/null +++ b/src/app/history.rs @@ -0,0 +1,252 @@ +use crate::model::RequestId; + +use super::reducer::{Action, reduce}; +use super::state::AppModel; + +const SNAPSHOT_INTERVAL: usize = 50; +const DEFAULT_HISTORY_MAX: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Focus { + None, + Request(RequestId), +} + +impl Focus { + pub fn from_selection(selection: Option<&RequestId>) -> Self { + selection.map_or(Self::None, |id| Self::Request(id.clone())) + } + + pub const fn request(&self) -> Option<&RequestId> { + match self { + Self::None => None, + Self::Request(id) => Some(id), + } + } + + pub fn into_request(self) -> Option { + match self { + Self::None => None, + Self::Request(id) => Some(id), + } + } +} + +#[derive(Debug, Clone)] +struct HistoryEntry { + action: Action, + focus: Focus, +} + +#[derive(Debug, Clone)] +struct Snapshot { + index: usize, + model: AppModel, + focus: Focus, +} + +#[derive(Debug)] +pub struct HistoryResult { + pub model: AppModel, + pub focus: Focus, +} + +#[derive(Debug)] +pub struct History { + entries: Vec, + cursor: usize, + snapshots: Vec, + max: usize, +} + +impl History { + pub fn new(initial: AppModel) -> Self { + Self { + entries: Vec::new(), + cursor: 0, + snapshots: vec![Snapshot { + index: 0, + model: initial, + focus: Focus::None, + }], + max: DEFAULT_HISTORY_MAX, + } + } + + pub fn record(&mut self, action: Action, focus: Focus, model: &AppModel) { + if self.cursor < self.entries.len() { + self.entries.truncate(self.cursor); + self.snapshots.retain(|snap| snap.index <= self.cursor); + } + + self.entries.push(HistoryEntry { action, focus: focus.clone() }); + self.cursor += 1; + + if self.cursor.is_multiple_of(SNAPSHOT_INTERVAL) { + self.snapshots.push(Snapshot { + index: self.cursor, + model: model.clone(), + focus, + }); + } + } + + pub fn undo(&mut self, apply_focus: F) -> Option + where + F: Fn(&Focus, &mut AppModel), + { + if self.cursor == 0 { + return None; + } + self.cursor -= 1; + Some(self.replay_to(self.cursor, apply_focus)) + } + + pub fn redo(&mut self, apply_focus: F) -> Option + where + F: Fn(&Focus, &mut AppModel), + { + if self.cursor >= self.entries.len() { + return None; + } + self.cursor += 1; + Some(self.replay_to(self.cursor, apply_focus)) + } + + pub fn trim_if_needed(&mut self, apply_focus: F) + where + F: Fn(&Focus, &mut AppModel), + { + if self.entries.len() <= self.max { + return; + } + + let overflow = self.entries.len() - self.max; + let base = self.replay_to(overflow, apply_focus); + self.entries.drain(0..overflow); + self.cursor = self.cursor.saturating_sub(overflow); + self.snapshots.clear(); + self.snapshots.push(Snapshot { + index: 0, + model: base.model, + focus: base.focus, + }); + } + + fn replay_to(&self, target: usize, apply_focus: F) -> HistoryResult + where + F: Fn(&Focus, &mut AppModel), + { + let snapshot = self + .snapshots + .iter() + .filter(|snap| snap.index <= target) + .max_by_key(|snap| snap.index) + .expect("history snapshot"); + let mut model = snapshot.model.clone(); + let mut current_focus = snapshot.focus.clone(); + let mut last_focus = current_focus.clone(); + + for entry in self.entries.iter().take(target).skip(snapshot.index) { + if entry.focus != current_focus { + current_focus = entry.focus.clone(); + apply_focus(¤t_focus, &mut model); + } + let (next_model, _effects) = reduce(model, entry.action.clone()); + model = next_model; + last_focus = current_focus.clone(); + } + + HistoryResult { + model, + focus: last_focus, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use crate::model::{RequestDraft, RequestId}; + + use super::super::reducer::{Action}; + use super::super::state::{AppModel, LoadedDraft}; + use super::{Focus, History}; + + fn apply_focus( + drafts: &HashMap, + focus: &Focus, + model: &mut AppModel, + ) { + let Some(id) = focus.request() else { + return; + }; + if let Some(draft) = drafts.get(id) { + let loaded = LoadedDraft { + draft: draft.clone(), + save_path: "test.http".to_string(), + }; + model.load_draft(loaded); + } + } + + #[test] + fn history_replay_respects_focus() { + let mut drafts = HashMap::new(); + let id_a = RequestId::HttpFile { + path: PathBuf::from("a.http"), + index: 0, + }; + let id_b = RequestId::HttpFile { + path: PathBuf::from("b.http"), + index: 0, + }; + drafts.insert( + id_a.clone(), + RequestDraft { + title: "A".to_string(), + ..Default::default() + }, + ); + drafts.insert( + id_b.clone(), + RequestDraft { + title: "B".to_string(), + ..Default::default() + }, + ); + + let mut model = AppModel::default(); + let mut history = History::new(model.clone()); + + apply_focus(&drafts, &Focus::Request(id_a.clone()), &mut model); + let (model_after_a, _) = super::super::reducer::reduce( + model, + Action::TitleChanged("A1".to_string()), + ); + model = model_after_a; + history.record(Action::TitleChanged("A1".to_string()), Focus::Request(id_a.clone()), &model); + + apply_focus(&drafts, &Focus::Request(id_b.clone()), &mut model); + let (model_after_b, _) = super::super::reducer::reduce( + model, + Action::TitleChanged("B1".to_string()), + ); + model = model_after_b; + history.record(Action::TitleChanged("B1".to_string()), Focus::Request(id_b.clone()), &model); + + let undo = history + .undo(|focus, model| apply_focus(&drafts, focus, model)) + .expect("undo"); + assert_eq!(undo.focus, Focus::Request(id_a)); + assert_eq!(undo.model.draft.title, "A1"); + + let redo = history + .redo(|focus, model| apply_focus(&drafts, focus, model)) + .expect("redo"); + assert_eq!(redo.focus, Focus::Request(id_b)); + assert_eq!(redo.model.draft.title, "B1"); + } +} diff --git a/src/app/hotkeys.rs b/src/app/hotkeys.rs index 5872834..8126fcf 100644 --- a/src/app/hotkeys.rs +++ b/src/app/hotkeys.rs @@ -4,12 +4,23 @@ use super::messages::Message; pub fn subscription() -> Subscription { keyboard::listen().filter_map(|event| match event { - keyboard::Event::KeyPressed { key, modifiers, .. } => match key { + keyboard::Event::KeyPressed { key, modifiers, .. } => match key { keyboard::Key::Character(c) if c == "?" || (c == "/" && modifiers.shift()) => { Some(Message::ToggleShortcutsHelp) } + keyboard::Key::Character(c) + if c.eq_ignore_ascii_case("z") && modifiers.command() && modifiers.shift() => + { + Some(Message::Redo) + } + keyboard::Key::Character(c) if c.eq_ignore_ascii_case("z") && modifiers.command() => { + Some(Message::Undo) + } + keyboard::Key::Character(c) if c.eq_ignore_ascii_case("y") && modifiers.command() => { + Some(Message::Redo) + } keyboard::Key::Character(c) if c.eq_ignore_ascii_case("s") && modifiers.command() => { Some(Message::Save) } diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 478293e..aea059e 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -1,69 +1,28 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::PathBuf; -use std::time::Instant; use iced::widget::pane_grid; use iced::{Subscription, Task, Theme, application}; use reqwest::Client; -use crate::model::{Environment, HttpFile, RequestDraft, RequestId, ResponsePreview}; use crate::parser::{scan_env_files, scan_http_files}; use crate::state::AppState; -use super::options::{AuthState, RequestMode}; -use super::status::{default_environment, status_with_missing}; -use super::{EditTarget, Message, hotkeys, view, watcher}; +use super::history::History; +use super::state::{AppModel, Runtime, ViewState}; +use super::{Message, hotkeys, view, watcher}; const FILE_SCAN_MAX_DEPTH: usize = 6; -#[derive(Debug, Clone)] -pub struct HeaderRow { - pub name: String, - pub value: String, -} - -#[derive(Debug, Default)] -pub enum EditState { - #[default] - Off, - On { selection: HashSet }, -} - pub struct Zagel { - pub(super) http_files: HashMap, - pub(super) http_file_order: Vec, - pub(super) selection: Option, - pub(super) edit_state: EditState, - pub(super) draft: RequestDraft, - pub(super) body_editor: iced::widget::text_editor::Content, - pub(super) status_line: String, - pub(super) last_response: Option, - pub(super) environments: Vec, - pub(super) active_environment: usize, - pub(super) http_root: PathBuf, - pub(super) state: AppState, - pub(super) client: Client, - pub(super) response_viewer: iced::widget::text_editor::Content, - pub(super) save_path: String, - pub(super) mode: RequestMode, - pub(super) auth: AuthState, - pub(super) graphql_query: iced::widget::text_editor::Content, - pub(super) graphql_variables: iced::widget::text_editor::Content, - pub(super) header_rows: Vec, - pub(super) response_display: crate::app::view::ResponseDisplay, - pub(super) response_tab: crate::app::view::ResponseTab, - pub(super) show_shortcuts: bool, - pub(super) pending_rescan: bool, - pub(super) last_scan: Option, - pub(super) panes: pane_grid::State, - pub(super) workspace_panes: pane_grid::State, - pub(super) builder_panes: pane_grid::State, - pub(super) collapsed_collections: BTreeSet, + pub(super) model: AppModel, + pub(super) view: ViewState, + pub(super) runtime: Runtime, + pub(super) history: History, } impl Zagel { pub(super) fn init() -> (Self, Task) { - let state = AppState::load(); + let mut state = AppState::load(); let http_root = state .http_root .clone() @@ -98,172 +57,65 @@ impl Zagel { builder_panes.resize(split, 0.45); } - let mut app = Self { - http_files: HashMap::new(), - http_file_order: state.http_file_order.clone(), - selection: None, - edit_state: EditState::default(), - draft: RequestDraft::default(), - body_editor: iced::widget::text_editor::Content::with_text(""), - status_line: "Ready".to_string(), - last_response: None, - environments: vec![default_environment()], - active_environment: 0, - http_root, - state, - client: Client::new(), - response_viewer: iced::widget::text_editor::Content::with_text("No response yet"), - save_path: String::new(), - mode: RequestMode::Rest, - auth: AuthState::default(), - graphql_query: iced::widget::text_editor::Content::with_text(""), - graphql_variables: iced::widget::text_editor::Content::with_text("{}"), - header_rows: Vec::new(), - response_display: crate::app::view::ResponseDisplay::Pretty, - response_tab: crate::app::view::ResponseTab::Body, - show_shortcuts: false, - pending_rescan: false, - last_scan: None, + let model = AppModel::default(); + let http_file_order = state.http_file_order.clone(); + let mut view_state = ViewState::new( + http_root.clone(), panes, workspace_panes, builder_panes, - collapsed_collections: BTreeSet::new(), + http_file_order, + ); + view_state.update_status_with_model("Ready", &model); + + state.http_root = Some(http_root); + state.save(); + + let runtime = Runtime { + client: Client::new(), + state, + }; + let history = History::new(model.clone()); + + let app = Self { + model, + view: view_state, + runtime, + history, }; let task = app.rescan_files(); - app.persist_state(); - app.update_status_with_missing("Ready"); (app, task) } pub(super) fn subscription(state: &Self) -> Subscription { Subscription::batch([ hotkeys::subscription(), - watcher::subscription(state.http_root.clone()), + watcher::subscription(state.view.http_root.clone()), ]) } pub(super) const fn theme(state: &Self) -> Theme { - state.state.theme.iced_theme() + state.runtime.state.theme.iced_theme() } pub(super) fn rescan_files(&self) -> Task { Task::batch([ Task::perform( - scan_http_files(self.http_root.clone(), FILE_SCAN_MAX_DEPTH), + scan_http_files(self.view.http_root.clone(), FILE_SCAN_MAX_DEPTH), Message::HttpFilesLoaded, ), Task::perform( - scan_env_files(self.http_root.clone(), FILE_SCAN_MAX_DEPTH), + scan_env_files(self.view.http_root.clone(), FILE_SCAN_MAX_DEPTH), Message::EnvironmentsLoaded, ), ]) } - - pub(super) fn persist_state(&mut self) { - let mut state = self.state.clone(); - state.http_root = Some(self.http_root.clone()); - state.http_file_order.clone_from(&self.http_file_order); - self.state = state.clone(); - state.save(); - } - - pub(super) fn apply_saved_environment(&mut self) { - if let Some(saved) = self.state.active_environment.clone() - && let Some((idx, _)) = self - .environments - .iter() - .enumerate() - .find(|(_, env)| env.name == saved) - { - self.active_environment = idx; - self.state.active_environment = - Some(self.environments[self.active_environment].name.clone()); - return; - } - self.active_environment = 0; - if let Some(env) = self.environments.get(self.active_environment) { - self.state.active_environment = Some(env.name.clone()); - } - } - - pub(super) fn apply_selection(&mut self, id: &RequestId) { - let RequestId::HttpFile { path, index } = id; - self.selection = Some(id.clone()); - let maybe_request = self - .http_files - .get(path) - .and_then(|file| file.requests.get(*index)); - - let draft = maybe_request.cloned().unwrap_or_else(|| RequestDraft { - title: "New request".to_string(), - ..Default::default() - }); - self.draft = draft.clone(); - self.body_editor = iced::widget::text_editor::Content::with_text(&draft.body); - self.set_header_rows_from_draft(); - self.save_path = path.display().to_string(); - self.update_status_with_missing("Ready"); - self.update_response_viewer(); - } - - pub(super) fn set_header_rows_from_draft(&mut self) { - self.header_rows.clear(); - if self.draft.headers.is_empty() { - self.header_rows.push(HeaderRow { - name: String::new(), - value: String::new(), - }); - return; - } - for line in self.draft.headers.lines() { - if let Some((name, value)) = line.split_once(':') { - self.header_rows.push(HeaderRow { - name: name.trim().to_string(), - value: value.trim().to_string(), - }); - } - } - } - - pub(super) fn rebuild_headers_from_rows(&mut self) { - let lines: Vec = self - .header_rows - .iter() - .filter(|row| !row.name.trim().is_empty()) - .map(|row| format!("{}: {}", row.name.trim(), row.value.trim())) - .collect(); - self.draft.headers = lines.join("\n"); - } - - pub(super) fn update_response_viewer(&mut self) { - let body_text = self - .last_response - .as_ref() - .and_then(|resp| resp.error.clone().or_else(|| resp.body.clone())) - .unwrap_or_else(|| "No response yet".to_string()); - let display_text = match (self.response_display, super::view::pretty_json(&body_text)) { - (super::view::ResponseDisplay::Pretty, Some(pretty)) => pretty, - _ => body_text, - }; - self.response_viewer = iced::widget::text_editor::Content::with_text(&display_text); - } - - pub(super) fn update_status_with_missing(&mut self, base: &str) { - let env = self.environments.get(self.active_environment); - let extras = if self.mode == RequestMode::GraphQl { - vec![self.graphql_query.text(), self.graphql_variables.text()] - } else { - Vec::new() - }; - let extra_refs: Vec<&str> = extras.iter().map(std::string::String::as_str).collect(); - self.status_line = status_with_missing(base, &self.draft, env, &extra_refs); - } } pub fn run() -> iced::Result { application(Zagel::init, Zagel::update, view::view) - .title("Zagel  REST workbench") + .title("Zagel REST workbench") .subscription(Zagel::subscription) .theme(Zagel::theme) .run() diff --git a/src/app/messages.rs b/src/app/messages.rs index e01f5a7..dd0a91b 100644 --- a/src/app/messages.rs +++ b/src/app/messages.rs @@ -54,4 +54,6 @@ pub enum Message { MoveRequestDown(RequestId), AddRequest, ToggleShortcutsHelp, + Undo, + Redo, } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1cdaecb..bb794e8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,16 @@ mod headers; mod hotkeys; +mod history; mod lifecycle; mod messages; mod options; +mod reducer; +mod state; mod status; mod watcher; mod update; mod view; -pub use lifecycle::{EditState, HeaderRow, Zagel, run}; +pub use lifecycle::{Zagel, run}; pub use messages::{EditTarget, Message}; +pub use state::{EditState, HeaderRow}; diff --git a/src/app/reducer.rs b/src/app/reducer.rs new file mode 100644 index 0000000..159f1b3 --- /dev/null +++ b/src/app/reducer.rs @@ -0,0 +1,135 @@ +use std::path::PathBuf; + +use iced::widget::text_editor; + +use crate::model::{Environment, Method, RequestDraft, RequestId}; + +use super::options::{AuthState, RequestMode}; +use super::state::{AppModel, HeaderRow, LoadedDraft}; + +#[derive(Debug, Clone, Copy)] +pub struct HeaderIndex(usize); + +impl HeaderIndex { + pub const fn new(index: usize, row_count: usize) -> Option { + if index < row_count { + Some(Self(index)) + } else { + None + } + } + + pub const fn get(self) -> usize { + self.0 + } +} + +#[derive(Debug, Clone)] +pub enum Action { + LoadDraft(LoadedDraft), + MethodSelected(Method), + UrlChanged(String), + TitleChanged(String), + ModeChanged(RequestMode), + BodyEdited(text_editor::Action), + GraphqlQueryEdited(text_editor::Action), + GraphqlVariablesEdited(text_editor::Action), + AuthChanged(AuthState), + HeaderNameChanged(HeaderIndex, String), + HeaderValueChanged(HeaderIndex, String), + HeaderAdded, + HeaderRemoved(HeaderIndex), + SavePathChanged(String), + Emit(Effect), +} + +#[derive(Debug, Clone)] +pub enum Effect { + SendRequest { + draft: RequestDraft, + env: Option, + }, + PersistRequest { + root: PathBuf, + selection: Option, + draft: RequestDraft, + explicit_path: Option, + }, + CopyToClipboard(String), +} + +pub fn reduce(mut model: AppModel, action: Action) -> (AppModel, Vec) { + match action { + Action::LoadDraft(loaded) => { + model.load_draft(loaded); + (model, Vec::new()) + } + Action::MethodSelected(method) => { + model.draft.method = method; + (model, Vec::new()) + } + Action::UrlChanged(url) => { + model.draft.url = url; + (model, Vec::new()) + } + Action::TitleChanged(title) => { + model.draft.title = title; + (model, Vec::new()) + } + Action::ModeChanged(mode) => { + model.mode = mode; + (model, Vec::new()) + } + Action::BodyEdited(action) => { + model.body_editor.perform(action); + model.draft.body = model.body_editor.text(); + (model, Vec::new()) + } + Action::GraphqlQueryEdited(action) => { + model.graphql_query.perform(action); + (model, Vec::new()) + } + Action::GraphqlVariablesEdited(action) => { + model.graphql_variables.perform(action); + (model, Vec::new()) + } + Action::AuthChanged(auth) => { + model.auth = auth; + (model, Vec::new()) + } + Action::HeaderNameChanged(idx, value) => { + if let Some(row) = model.header_rows.get_mut(idx.get()) { + row.name = value; + model.rebuild_headers_from_rows(); + } + (model, Vec::new()) + } + Action::HeaderValueChanged(idx, value) => { + if let Some(row) = model.header_rows.get_mut(idx.get()) { + row.value = value; + model.rebuild_headers_from_rows(); + } + (model, Vec::new()) + } + Action::HeaderAdded => { + model.header_rows.push(HeaderRow { + name: String::new(), + value: String::new(), + }); + model.rebuild_headers_from_rows(); + (model, Vec::new()) + } + Action::HeaderRemoved(idx) => { + if idx.get() < model.header_rows.len() { + model.header_rows.remove(idx.get()); + model.rebuild_headers_from_rows(); + } + (model, Vec::new()) + } + Action::SavePathChanged(path) => { + model.save_path = path; + (model, Vec::new()) + } + Action::Emit(effect) => (model, vec![effect]), + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..8953b6b --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,283 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Instant; + +use iced::widget::{pane_grid, text_editor}; +use reqwest::Client; + +use crate::model::{Environment, HttpFile, RequestDraft, RequestId, ResponsePreview}; +use crate::state::AppState; + +use super::messages::EditTarget; +use super::options::{AuthState, RequestMode}; +use super::status::{default_environment, status_with_missing, with_default_environment}; +use super::view::{BuilderPane, PaneContent, ResponseDisplay, ResponseTab, WorkspacePane}; + +const MIN_SPLIT_RATIO: f32 = 0.2; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SplitRatio(f32); + +impl SplitRatio { + pub fn new(raw: f32) -> Self { + let clamped = raw.clamp(MIN_SPLIT_RATIO, 1.0 - MIN_SPLIT_RATIO); + Self(clamped) + } + + pub const fn get(self) -> f32 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnvironmentIndex(usize); + +impl EnvironmentIndex { + pub const fn new(index: usize, envs: &[Environment]) -> Option { + if index < envs.len() { + Some(Self(index)) + } else { + None + } + } + + pub fn find(name: &str, envs: &[Environment]) -> Option { + envs.iter().position(|env| env.name == name).map(Self) + } + + pub const fn get(self) -> usize { + self.0 + } +} + +#[derive(Debug, Default)] +pub enum EditState { + #[default] + Off, + On { selection: HashSet }, +} + +#[derive(Debug, Clone)] +pub struct HeaderRow { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone)] +pub struct LoadedDraft { + pub draft: RequestDraft, + pub save_path: String, +} + +#[derive(Debug, Clone)] +pub struct AppModel { + pub draft: RequestDraft, + pub body_editor: text_editor::Content, + pub save_path: String, + pub mode: RequestMode, + pub auth: AuthState, + pub graphql_query: text_editor::Content, + pub graphql_variables: text_editor::Content, + pub header_rows: Vec, +} + +impl Default for AppModel { + fn default() -> Self { + let draft = RequestDraft::default(); + let body_editor = text_editor::Content::with_text(&draft.body); + Self { + draft, + body_editor, + save_path: String::new(), + mode: RequestMode::Rest, + auth: AuthState::default(), + graphql_query: text_editor::Content::with_text(""), + graphql_variables: text_editor::Content::with_text("{}"), + header_rows: vec![HeaderRow { + name: String::new(), + value: String::new(), + }], + } + } +} + +impl AppModel { + pub fn load_draft(&mut self, loaded: LoadedDraft) { + self.draft = loaded.draft; + self.save_path = loaded.save_path; + self.body_editor = text_editor::Content::with_text(&self.draft.body); + self.set_header_rows_from_draft(); + } + + pub fn set_header_rows_from_draft(&mut self) { + self.header_rows.clear(); + if self.draft.headers.is_empty() { + self.header_rows.push(HeaderRow { + name: String::new(), + value: String::new(), + }); + return; + } + for line in self.draft.headers.lines() { + if let Some((name, value)) = line.split_once(':') { + self.header_rows.push(HeaderRow { + name: name.trim().to_string(), + value: value.trim().to_string(), + }); + } + } + if self.header_rows.is_empty() { + self.header_rows.push(HeaderRow { + name: String::new(), + value: String::new(), + }); + } + } + + pub fn rebuild_headers_from_rows(&mut self) { + let lines: Vec = self + .header_rows + .iter() + .filter(|row| !row.name.trim().is_empty()) + .map(|row| format!("{}: {}", row.name.trim(), row.value.trim())) + .collect(); + self.draft.headers = lines.join("\n"); + } +} + +#[derive(Debug)] +pub struct ViewState { + pub http_files: HashMap, + pub http_file_order: Vec, + pub selection: Option, + pub edit_state: EditState, + pub status_line: String, + pub last_response: Option, + pub environments: Vec, + pub active_environment: EnvironmentIndex, + pub http_root: PathBuf, + pub response_viewer: text_editor::Content, + pub response_display: ResponseDisplay, + pub response_tab: ResponseTab, + pub show_shortcuts: bool, + pub pending_rescan: bool, + pub last_scan: Option, + pub panes: pane_grid::State, + pub workspace_panes: pane_grid::State, + pub builder_panes: pane_grid::State, + pub collapsed_collections: BTreeSet, +} + +impl ViewState { + pub fn new( + http_root: PathBuf, + panes: pane_grid::State, + workspace_panes: pane_grid::State, + builder_panes: pane_grid::State, + http_file_order: Vec, + ) -> Self { + let environments = vec![default_environment()]; + let active_environment = + EnvironmentIndex::new(0, &environments).expect("default environment index"); + Self { + http_files: HashMap::new(), + http_file_order, + selection: None, + edit_state: EditState::default(), + status_line: "Ready".to_string(), + last_response: None, + environments, + active_environment, + http_root, + response_viewer: text_editor::Content::with_text("No response yet"), + response_display: ResponseDisplay::Pretty, + response_tab: ResponseTab::Body, + show_shortcuts: false, + pending_rescan: false, + last_scan: None, + panes, + workspace_panes, + builder_panes, + collapsed_collections: BTreeSet::new(), + } + } + + pub fn update_status_with_model(&mut self, base: &str, model: &AppModel) { + let env = self.environments.get(self.active_environment.get()); + let extras = if model.mode == RequestMode::GraphQl { + vec![model.graphql_query.text(), model.graphql_variables.text()] + } else { + Vec::new() + }; + let extra_refs: Vec<&str> = extras.iter().map(String::as_str).collect(); + self.status_line = status_with_missing(base, &model.draft, env, &extra_refs); + } + + pub fn update_status_with_draft( + &mut self, + base: &str, + draft: &RequestDraft, + extra_inputs: &[String], + ) { + let env = self.environments.get(self.active_environment.get()); + let extra_refs: Vec<&str> = extra_inputs.iter().map(String::as_str).collect(); + self.status_line = status_with_missing(base, draft, env, &extra_refs); + } + + pub fn update_response_viewer(&mut self) { + let body_text = self + .last_response + .as_ref() + .and_then(|resp| resp.error.clone().or_else(|| resp.body.clone())) + .unwrap_or_else(|| "No response yet".to_string()); + let display_text = match ( + self.response_display, + super::view::pretty_json(&body_text), + ) { + (ResponseDisplay::Pretty, Some(pretty)) => pretty, + _ => body_text, + }; + self.response_viewer = text_editor::Content::with_text(&display_text); + } + + pub fn set_environments(&mut self, envs: Vec, state: &mut AppState) { + self.environments = with_default_environment(envs); + let next_active = state + .active_environment + .clone() + .and_then(|saved| EnvironmentIndex::find(&saved, &self.environments)) + .unwrap_or_else(|| { + EnvironmentIndex::new(0, &self.environments) + .expect("default environment index") + }); + self.active_environment = next_active; + state.active_environment = Some( + self.environments[self.active_environment.get()] + .name + .clone(), + ); + } + + pub fn active_environment_name(&self) -> String { + self.environments[self.active_environment.get()].name.clone() + } + + pub fn resolve_request(&self, id: &RequestId) -> Option { + let RequestId::HttpFile { path, index } = id; + let draft = self + .http_files + .get(path) + .and_then(|file| file.requests.get(*index))?; + let save_path = path.display().to_string(); + Some(LoadedDraft { + draft: draft.clone(), + save_path, + }) + } +} + +#[derive(Debug, Clone)] +pub struct Runtime { + pub client: Client, + pub state: AppState, +} diff --git a/src/app/update.rs b/src/app/update.rs index 3baa7e9..d7cda1f 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -10,17 +10,14 @@ use crate::model::{Method, RequestDraft, RequestId, ResponsePreview}; use crate::net::send_request; use crate::parser::{persist_request, write_http_file}; +use super::history::Focus; use super::options::{RequestMode, apply_auth_headers, build_graphql_body}; -use super::status::{status_with_missing, with_default_environment}; -use super::{EditState, EditTarget, HeaderRow, Message, Zagel}; +use super::reducer::{Action, Effect, HeaderIndex, reduce}; +use super::state::{AppModel, EditState, SplitRatio, ViewState}; +use super::{EditTarget, Message, Zagel}; -const MIN_SPLIT_RATIO: f32 = 0.2; const FILE_SCAN_DEBOUNCE: Duration = Duration::from_millis(300); -fn clamp_ratio(ratio: f32) -> f32 { - ratio.clamp(MIN_SPLIT_RATIO, 1.0 - MIN_SPLIT_RATIO) -} - const fn edit_selection_mut( edit_state: &mut EditState, ) -> Option<&mut HashSet> { @@ -85,64 +82,80 @@ fn swap_request_indices_in_edit_selection_http( }); } -#[allow(clippy::too_many_lines)] impl Zagel { + #[allow(clippy::too_many_lines)] pub(super) fn update(&mut self, message: Message) -> Task { match message { Message::FilesChanged => { - if matches!(self.edit_state, EditState::On { .. }) { - self.pending_rescan = true; + if matches!(self.view.edit_state, EditState::On { .. }) { + self.view.pending_rescan = true; return Task::none(); } let now = Instant::now(); - if let Some(last) = self.last_scan + if let Some(last) = self.view.last_scan && now.duration_since(last) < FILE_SCAN_DEBOUNCE { return Task::none(); } - self.last_scan = Some(now); - self.pending_rescan = false; + self.view.last_scan = Some(now); + self.view.pending_rescan = false; self.rescan_files() } Message::WatcherUnavailable(message) => { - self.update_status_with_missing(&message); + self.view.update_status_with_model(&message, &self.model); Task::none() } Message::HttpFilesLoaded(files) => { - self.http_files = files; - self.http_file_order - .retain(|path| self.http_files.contains_key(path)); + self.view.http_files = files; + self.view + .http_file_order + .retain(|path| self.view.http_files.contains_key(path)); let mut new_paths: Vec = self + .view .http_files .keys() - .filter(|path| !self.http_file_order.contains(path)) + .filter(|path| !self.view.http_file_order.contains(path)) .cloned() .collect(); new_paths.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy())); - self.http_file_order.extend(new_paths); + self.view.http_file_order.extend(new_paths); + + if let Some(RequestId::HttpFile { path, index }) = self.view.selection.clone() + && self + .view + .http_files + .get(&path) + .is_none_or(|file| index >= file.requests.len()) + { + self.view.selection = None; + } + Task::none() } Message::PaneResized(pane_grid::ResizeEvent { split, ratio }) => { - self.panes.resize(split, clamp_ratio(ratio)); + let ratio = SplitRatio::new(ratio); + self.view.panes.resize(split, ratio.get()); Task::none() } Message::WorkspacePaneResized(pane_grid::ResizeEvent { split, ratio }) => { - self.workspace_panes.resize(split, clamp_ratio(ratio)); + let ratio = SplitRatio::new(ratio); + self.view.workspace_panes.resize(split, ratio.get()); Task::none() } Message::BuilderPaneResized(pane_grid::ResizeEvent { split, ratio }) => { - self.builder_panes.resize(split, clamp_ratio(ratio)); + let ratio = SplitRatio::new(ratio); + self.view.builder_panes.resize(split, ratio.get()); Task::none() } Message::ToggleCollection(path) => { - if !self.collapsed_collections.remove(&path) { - self.collapsed_collections.insert(path); + if !self.view.collapsed_collections.remove(&path) { + self.view.collapsed_collections.insert(path); } Task::none() } Message::ToggleEditMode => { - let was_editing = matches!(self.edit_state, EditState::On { .. }); - self.edit_state = if was_editing { + let was_editing = matches!(self.view.edit_state, EditState::On { .. }); + self.view.edit_state = if was_editing { EditState::Off } else { EditState::On { @@ -151,16 +164,16 @@ impl Zagel { }; if was_editing { self.persist_state(); - if self.pending_rescan { - self.pending_rescan = false; - self.last_scan = Some(Instant::now()); + if self.view.pending_rescan { + self.view.pending_rescan = false; + self.view.last_scan = Some(Instant::now()); return self.rescan_files(); } } Task::none() } Message::ToggleEditSelection(target) => { - if let Some(selection) = edit_selection_mut(&mut self.edit_state) + if let Some(selection) = edit_selection_mut(&mut self.view.edit_state) && !selection.remove(&target) { selection.insert(target); @@ -168,7 +181,7 @@ impl Zagel { Task::none() } Message::DeleteSelected => { - let selection = match &self.edit_state { + let selection = match &self.view.edit_state { EditState::On { selection } if !selection.is_empty() => selection.clone(), _ => return Task::none(), }; @@ -207,7 +220,7 @@ impl Zagel { let mut errors = Vec::new(); for (path, mut indices) in file_request_removals { - if let Some(file) = self.http_files.get_mut(&path) { + if let Some(file) = self.view.http_files.get_mut(&path) { let mut updated_requests = file.requests.clone(); indices.sort_unstable(); indices.dedup(); @@ -238,46 +251,49 @@ impl Zagel { err )), } - self.http_files.remove(path); + self.view.http_files.remove(path); } if !remove_file_paths.is_empty() { - self.http_file_order + self.view + .http_file_order .retain(|path| !remove_files_set.contains(path)); } - if let EditState::On { selection } = &mut self.edit_state { + if let EditState::On { selection } = &mut self.view.edit_state { selection.clear(); } if errors.is_empty() { - self.update_status_with_missing("Deleted selection"); + self.view.update_status_with_model("Deleted selection", &self.model); } else { - self.update_status_with_missing(&errors.join("; ")); + self.view + .update_status_with_model(&errors.join("; "), &self.model); } - if let Some(RequestId::HttpFile { path, index }) = self.selection.clone() + if let Some(RequestId::HttpFile { path, index }) = self.view.selection.clone() && self + .view .http_files .get(&path) .is_none_or(|file| index >= file.requests.len()) { - self.selection = None; + self.view.selection = None; } Task::none() } Message::MoveCollectionUp(path) => { - if let Some(pos) = self.http_file_order.iter().position(|p| p == &path) + if let Some(pos) = self.view.http_file_order.iter().position(|p| p == &path) && pos > 0 { - self.http_file_order.swap(pos, pos - 1); + self.view.http_file_order.swap(pos, pos - 1); } Task::none() } Message::MoveCollectionDown(path) => { - if let Some(pos) = self.http_file_order.iter().position(|p| p == &path) - && pos + 1 < self.http_file_order.len() + if let Some(pos) = self.view.http_file_order.iter().position(|p| p == &path) + && pos + 1 < self.view.http_file_order.len() { - self.http_file_order.swap(pos, pos + 1); + self.view.http_file_order.swap(pos, pos + 1); } Task::none() } @@ -288,7 +304,7 @@ impl Zagel { } let mut new_index = None; let mut status_error = None; - if let Some(file) = self.http_files.get_mut(path) + if let Some(file) = self.view.http_files.get_mut(path) && *index < file.requests.len() { let updated_index = *index - 1; @@ -306,20 +322,20 @@ impl Zagel { } if let Some(updated_index) = new_index { swap_request_indices_in_selection_http( - &mut self.selection, + &mut self.view.selection, path, *index, updated_index, ); swap_request_indices_in_edit_selection_http( - &mut self.edit_state, + &mut self.view.edit_state, path, *index, updated_index, ); } if let Some(message) = status_error { - self.update_status_with_missing(&message); + self.view.update_status_with_model(&message, &self.model); } Task::none() } @@ -327,7 +343,7 @@ impl Zagel { let RequestId::HttpFile { path, index } = &id; let mut new_index = None; let mut status_error = None; - if let Some(file) = self.http_files.get_mut(path) + if let Some(file) = self.view.http_files.get_mut(path) && *index + 1 < file.requests.len() { let updated_index = *index + 1; @@ -345,251 +361,382 @@ impl Zagel { } if let Some(updated_index) = new_index { swap_request_indices_in_selection_http( - &mut self.selection, + &mut self.view.selection, path, *index, updated_index, ); swap_request_indices_in_edit_selection_http( - &mut self.edit_state, + &mut self.view.edit_state, path, *index, updated_index, ); } if let Some(message) = status_error { - self.update_status_with_missing(&message); + self.view.update_status_with_model(&message, &self.model); } Task::none() } Message::EnvironmentsLoaded(envs) => { - self.environments = with_default_environment(envs); - self.apply_saved_environment(); - self.persist_state(); - self.update_status_with_missing("Ready"); - Task::none() - } - Message::Select(id) => { - self.apply_selection(&id); + self.view.set_environments(envs, &mut self.runtime.state); + self.runtime.state.save(); + self.view.update_status_with_model("Ready", &self.model); Task::none() } + Message::Select(id) => self.handle_selection(&id), Message::MethodSelected(method) => { - self.draft.method = method; - Task::none() + self.apply_model_action(Action::MethodSelected(method), true, None) } Message::UrlChanged(url) => { - self.draft.url = url; - self.update_status_with_missing("Ready"); - Task::none() + self.apply_model_action(Action::UrlChanged(url), true, Some("Ready")) } Message::TitleChanged(title) => { - self.draft.title = title; - Task::none() + self.apply_model_action(Action::TitleChanged(title), true, None) } Message::ModeChanged(mode) => { - self.mode = mode; - self.update_status_with_missing("Ready"); - Task::none() + self.apply_model_action(Action::ModeChanged(mode), true, Some("Ready")) } Message::BodyEdited(action) => { - self.body_editor.perform(action); - self.draft.body = self.body_editor.text(); - self.update_status_with_missing("Ready"); - Task::none() + self.apply_model_action(Action::BodyEdited(action), true, Some("Ready")) } Message::GraphqlQueryEdited(action) => { - self.graphql_query.perform(action); - self.update_status_with_missing("Ready"); - Task::none() + self.apply_model_action(Action::GraphqlQueryEdited(action), true, Some("Ready")) } Message::GraphqlVariablesEdited(action) => { - self.graphql_variables.perform(action); - self.update_status_with_missing("Ready"); - Task::none() + self.apply_model_action(Action::GraphqlVariablesEdited(action), true, Some("Ready")) } Message::AuthChanged(new_auth) => { - self.auth = new_auth; - Task::none() + self.apply_model_action(Action::AuthChanged(new_auth), true, None) } Message::HeaderNameChanged(idx, value) => { - if let Some(row) = self.header_rows.get_mut(idx) { - row.name = value; - self.rebuild_headers_from_rows(); - } - self.update_status_with_missing("Ready"); - Task::none() + let len = self.model.header_rows.len(); + let Some(index) = HeaderIndex::new(idx, len) else { + debug_assert!( + false, + "Invalid header index {idx} for header_rows len {len}" + ); + return Task::none(); + }; + self.apply_model_action(Action::HeaderNameChanged(index, value), true, Some("Ready")) } Message::HeaderValueChanged(idx, value) => { - if let Some(row) = self.header_rows.get_mut(idx) { - row.value = value; - self.rebuild_headers_from_rows(); - } - self.update_status_with_missing("Ready"); - Task::none() + let len = self.model.header_rows.len(); + let Some(index) = HeaderIndex::new(idx, len) else { + debug_assert!( + false, + "Invalid header index {idx} for header_rows len {len}" + ); + return Task::none(); + }; + self.apply_model_action(Action::HeaderValueChanged(index, value), true, Some("Ready")) } Message::HeaderAdded => { - self.header_rows.push(HeaderRow { - name: String::new(), - value: String::new(), - }); - self.rebuild_headers_from_rows(); - self.update_status_with_missing("Ready"); - Task::none() + self.apply_model_action(Action::HeaderAdded, true, Some("Ready")) } Message::HeaderRemoved(idx) => { - if idx < self.header_rows.len() { - self.header_rows.remove(idx); - self.rebuild_headers_from_rows(); - } - self.update_status_with_missing("Ready"); - Task::none() + let len = self.model.header_rows.len(); + let Some(index) = HeaderIndex::new(idx, len) else { + debug_assert!( + false, + "Invalid header index {idx} for header_rows len {len}" + ); + return Task::none(); + }; + self.apply_model_action(Action::HeaderRemoved(index), true, Some("Ready")) } Message::ResponseViewChanged(display) => { - self.response_display = display; - self.update_response_viewer(); + self.view.response_display = display; + self.view.update_response_viewer(); Task::none() } Message::ResponseTabChanged(tab) => { - self.response_tab = tab; + self.view.response_tab = tab; Task::none() } Message::ToggleShortcutsHelp => { - self.show_shortcuts = !self.show_shortcuts; + self.view.show_shortcuts = !self.view.show_shortcuts; Task::none() } Message::CopyResponseBody => { - clipboard::write(self.response_viewer.text()).map(|()| Message::CopyComplete) + let effect = Effect::CopyToClipboard(self.view.response_viewer.text()); + self.apply_model_action(Action::Emit(effect), false, None) } Message::CopyComplete => Task::none(), - Message::AddRequest => { - let new_draft = RequestDraft { - title: "New request".to_string(), - ..Default::default() - }; - if let Some(RequestId::HttpFile { path, .. }) = self.selection.clone() - && let Some(file) = self.http_files.get_mut(&path) - { - file.requests.push(new_draft.clone()); - let new_idx = file.requests.len() - 1; - let new_id = RequestId::HttpFile { - path: path.clone(), - index: new_idx, - }; - self.selection = Some(new_id); - self.update_status_with_missing("Saving new request..."); - let http_root = self.http_root.clone(); - return Task::perform( - async move { - persist_request(http_root, None, new_draft, Some(path)) - .await - .map_err(|e| e.to_string()) - }, - Message::Saved, - ); - } - self.update_status_with_missing("Select a file to add a request"); - Task::none() - } - Message::Send => { - let env = self.environments.get(self.active_environment).cloned(); - let mut draft = self.draft.clone(); - let mut extra_inputs: Vec = Vec::new(); - if self.mode == RequestMode::GraphQl { - draft.method = Method::Post; - let query = self.graphql_query.text(); - let variables = self.graphql_variables.text(); - extra_inputs.push(query.clone()); - extra_inputs.push(variables.clone()); - draft.body = build_graphql_body(&query, &variables); - if !draft.headers.contains("Content-Type") { - draft.headers.push_str("\nContent-Type: application/json"); - } - } - draft.headers = apply_auth_headers(&draft.headers, &self.auth); - let extra_refs: Vec<&str> = extra_inputs - .iter() - .map(std::string::String::as_str) - .collect(); - self.status_line = - status_with_missing("Sending...", &draft, env.as_ref(), &extra_refs); - Task::perform( - send_request(self.client.clone(), draft, env), - Message::ResponseReady, - ) - } + Message::AddRequest => self.handle_add_request(), + Message::Send => self.handle_send(), Message::ResponseReady(result) => { match result { Ok(resp) => { - self.update_status_with_missing("Received response"); - self.last_response = Some(resp); + self.view.update_status_with_model("Received response", &self.model); + self.view.last_response = Some(resp); } Err(err) => { - self.update_status_with_missing("Request failed"); - self.last_response = Some(ResponsePreview::error(err)); + self.view.update_status_with_model("Request failed", &self.model); + self.view.last_response = Some(ResponsePreview::error(err)); } } - self.update_response_viewer(); + self.view.update_response_viewer(); Task::none() } Message::EnvironmentChanged(name) => { - if let Some((idx, _)) = self - .environments - .iter() - .enumerate() - .find(|(_, env)| env.name == name) - { - self.active_environment = idx; - self.state.active_environment = Some(name); - self.persist_state(); + if let Some(index) = super::state::EnvironmentIndex::find(&name, &self.view.environments) { + self.view.active_environment = index; + self.runtime.state.active_environment = Some(name); + self.runtime.state.save(); } - self.update_status_with_missing("Ready"); + self.view.update_status_with_model("Ready", &self.model); Task::none() } - Message::Save => { - let selection = self.selection.clone(); - let draft = self.draft.clone(); - let root = self.http_root.clone(); - let explicit_path = if let Some(RequestId::HttpFile { .. }) = selection { - None - } else { - let path = self.save_path.trim(); - if path.is_empty() { - self.update_status_with_missing( - "Choose a path to save the request (Ctrl/Cmd+S)", - ); - return Task::none(); - } - Some(PathBuf::from(path)) - }; - self.update_status_with_missing("Saving..."); - Task::perform( + Message::Save => self.handle_save(), + Message::Saved(result) => self.handle_saved(result), + Message::SavePathChanged(path) => { + self.apply_model_action(Action::SavePathChanged(path), true, None) + } + Message::Undo => self.handle_undo(), + Message::Redo => self.handle_redo(), + } + } + + fn apply_model_action( + &mut self, + action: Action, + record: bool, + status_base: Option<&str>, + ) -> Task { + let focus = if record { + Focus::from_selection(self.view.selection.as_ref()) + } else { + Focus::None + }; + let (model, effects) = reduce(std::mem::take(&mut self.model), action.clone()); + self.model = model; + + if record { + self.history.record(action, focus, &self.model); + let view = &self.view; + self.history + .trim_if_needed(|focus, model| apply_focus_with_view(view, focus, model)); + } + + if let Some(base) = status_base { + self.view.update_status_with_model(base, &self.model); + } + + self.run_effects(effects) + } + + fn run_effects(&self, effects: Vec) -> Task { + if effects.is_empty() { + return Task::none(); + } + + let tasks: Vec> = effects + .into_iter() + .map(|effect| match effect { + Effect::SendRequest { draft, env } => Task::perform( + send_request(self.runtime.client.clone(), draft, env), + Message::ResponseReady, + ), + Effect::PersistRequest { + root, + selection, + draft, + explicit_path, + } => Task::perform( async move { persist_request(root, selection, draft, explicit_path) .await .map_err(|e| e.to_string()) }, Message::Saved, - ) - } - Message::Saved(result) => match result { - Ok((path, index)) => { - let id = RequestId::HttpFile { - path: path.clone(), - index, - }; - self.selection = Some(id); - self.update_status_with_missing(&format!("Saved to {}", path.display())); - Task::batch([Task::none(), self.rescan_files()]) - } - Err(err) => { - self.update_status_with_missing(&format!("Save failed: {err}")); - Task::none() + ), + Effect::CopyToClipboard(text) => { + clipboard::write(text).map(|()| Message::CopyComplete) } - }, - Message::SavePathChanged(path) => { - self.save_path = path; + }) + .collect(); + + Task::batch(tasks) + } + + fn persist_state(&mut self) { + self.runtime.state.http_root = Some(self.view.http_root.clone()); + self.runtime.state.http_file_order = self.view.http_file_order.clone(); + self.runtime.state.save(); + } + + fn handle_selection(&mut self, id: &RequestId) -> Task { + self.view.selection = Some(id.clone()); + if let Some(loaded) = self.view.resolve_request(id) { + let task = self.apply_model_action(Action::LoadDraft(loaded), false, Some("Ready")); + self.view.update_response_viewer(); + return task; + } + Task::none() + } + + fn handle_add_request(&mut self) -> Task { + if let Some(RequestId::HttpFile { path, .. }) = self.view.selection.clone() { + let new_draft = RequestDraft { + title: "New request".to_string(), + ..Default::default() + }; + if let Some(file) = self.view.http_files.get_mut(&path) { + let persist_draft = new_draft.clone(); + file.requests.push(new_draft); + let new_idx = file.requests.len() - 1; + let new_id = RequestId::HttpFile { + path: path.clone(), + index: new_idx, + }; + self.view.selection = Some(new_id.clone()); + if let Some(loaded) = self.view.resolve_request(&new_id) { + let task = self.apply_model_action(Action::LoadDraft(loaded), false, Some("Ready")); + self.view.update_response_viewer(); + return task; + } + self.view.update_status_with_model("Saving new request...", &self.model); + let effect = Effect::PersistRequest { + root: self.view.http_root.clone(), + selection: None, + draft: persist_draft, + explicit_path: Some(path), + }; + return self.apply_model_action(Action::Emit(effect), false, None); + } + self.view.selection = None; + self.view.update_status_with_model( + "Selected file is no longer available. Select a file to add a request.", + &self.model, + ); + return Task::none(); + } + + self.view + .update_status_with_model("Select a file to add a request", &self.model); + Task::none() + } + + fn handle_send(&mut self) -> Task { + let env = self + .view + .environments + .get(self.view.active_environment.get()) + .cloned(); + let (draft, extra_inputs) = self.prepare_send(); + self.view + .update_status_with_draft("Sending...", &draft, &extra_inputs); + let effect = Effect::SendRequest { draft, env }; + self.apply_model_action(Action::Emit(effect), false, None) + } + + fn prepare_send(&self) -> (RequestDraft, Vec) { + let mut draft = self.model.draft.clone(); + let mut extra_inputs: Vec = Vec::new(); + if self.model.mode == RequestMode::GraphQl { + draft.method = Method::Post; + let query = self.model.graphql_query.text(); + let variables = self.model.graphql_variables.text(); + extra_inputs.push(query.clone()); + extra_inputs.push(variables.clone()); + draft.body = build_graphql_body(&query, &variables); + if !headers_contain_name(&draft.headers, "Content-Type") { + draft.headers.push_str("\nContent-Type: application/json"); + } + } + draft.headers = apply_auth_headers(&draft.headers, &self.model.auth); + (draft, extra_inputs) + } + + fn handle_save(&mut self) -> Task { + let selection = self.view.selection.clone(); + let draft = self.model.draft.clone(); + let root = self.view.http_root.clone(); + let explicit_path = if let Some(RequestId::HttpFile { .. }) = selection { + None + } else { + let path = self.model.save_path.trim(); + if path.is_empty() { + self.view.update_status_with_model( + "Choose a path to save the request (Ctrl/Cmd+S)", + &self.model, + ); + return Task::none(); + } + Some(PathBuf::from(path)) + }; + self.view.update_status_with_model("Saving...", &self.model); + let effect = Effect::PersistRequest { + root, + selection, + draft, + explicit_path, + }; + self.apply_model_action(Action::Emit(effect), false, None) + } + + fn handle_saved(&mut self, result: Result<(PathBuf, usize), String>) -> Task { + match result { + Ok((path, index)) => { + let id = RequestId::HttpFile { + path: path.clone(), + index, + }; + self.view.selection = Some(id); + self.view + .update_status_with_model(&format!("Saved to {}", path.display()), &self.model); + self.rescan_files() + } + Err(err) => { + self.view + .update_status_with_model(&format!("Save failed: {err}"), &self.model); Task::none() } } } + + fn handle_undo(&mut self) -> Task { + let view = &self.view; + if let Some(result) = self + .history + .undo(|focus, model| apply_focus_with_view(view, focus, model)) + { + self.model = result.model; + self.view.selection = result.focus.into_request(); + self.view.update_status_with_model("Ready", &self.model); + self.view.update_response_viewer(); + } + Task::none() + } + + fn handle_redo(&mut self) -> Task { + let view = &self.view; + if let Some(result) = self + .history + .redo(|focus, model| apply_focus_with_view(view, focus, model)) + { + self.model = result.model; + self.view.selection = result.focus.into_request(); + self.view.update_status_with_model("Ready", &self.model); + self.view.update_response_viewer(); + } + Task::none() + } +} + +fn apply_focus_with_view(view: &ViewState, focus: &Focus, model: &mut AppModel) { + let Some(id) = focus.request() else { + return; + }; + if let Some(loaded) = view.resolve_request(id) { + model.load_draft(loaded); + } +} + +fn headers_contain_name(headers: &str, name: &str) -> bool { + headers + .lines() + .filter_map(|line| line.split_once(':')) + .any(|(header_name, _)| header_name.trim().eq_ignore_ascii_case(name)) } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 5acfc62..8b62f0d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -36,14 +36,14 @@ pub fn section<'a, Message: 'a>( pub fn view(app: &Zagel) -> Element<'_, Message> { let app_ref = app; - let grid = PaneGrid::new(&app_ref.panes, move |_, pane, _| match pane { + let grid = PaneGrid::new(&app_ref.view.panes, move |_, pane, _| match pane { PaneContent::Sidebar => pane_grid::Content::new(sidebar(SidebarContext { - http_files: &app_ref.http_files, - http_file_order: &app_ref.http_file_order, - selection: app_ref.selection.as_ref(), - collapsed: &app_ref.collapsed_collections, - http_root: &app_ref.http_root, - edit_state: &app_ref.edit_state, + http_files: &app_ref.view.http_files, + http_file_order: &app_ref.view.http_file_order, + selection: app_ref.view.selection.as_ref(), + collapsed: &app_ref.view.collapsed_collections, + http_root: &app_ref.view.http_root, + edit_state: &app_ref.view.edit_state, })), PaneContent::Workspace => pane_grid::Content::new(workspace(app_ref)), }) @@ -61,7 +61,7 @@ pub fn view(app: &Zagel) -> Element<'_, Message> { } fn status_bar(app: &Zagel) -> Element<'_, Message> { - let hint = if app.show_shortcuts { + let hint = if app.view.show_shortcuts { "Press ? to hide shortcuts" } else { "Press ? for shortcuts" @@ -70,7 +70,7 @@ fn status_bar(app: &Zagel) -> Element<'_, Message> { let content = row![ text(hint).size(12), space().width(Length::Fill), - text(format!("Status: {}", app.status_line)).size(12), + text(format!("Status: {}", app.view.status_line)).size(12), ] .spacing(8); diff --git a/src/app/view/workspace.rs b/src/app/view/workspace.rs index 366fc77..a4ce6c5 100644 --- a/src/app/view/workspace.rs +++ b/src/app/view/workspace.rs @@ -6,9 +6,9 @@ use iced::widget::{ use iced::{alignment, Alignment, Element, Length, Theme}; use super::super::{Message, Zagel, headers}; -use super::section; use super::auth::auth_editor; use super::response::{response_panel, response_tab_toggle, response_view_toggle}; +use super::section; use crate::app::options::RequestMode; use crate::model::{Method, RequestId}; use crate::theme; @@ -32,7 +32,7 @@ const ACTION_WIDTH: f32 = 84.0; const LABEL_WIDTH: f32 = 80.0; pub fn workspace(app: &Zagel) -> Element<'_, Message> { - let workspace_grid = PaneGrid::new(&app.workspace_panes, move |_, pane, _| match pane { + let workspace_grid = PaneGrid::new(&app.view.workspace_panes, move |_, pane, _| match pane { WorkspacePane::Builder => pane_grid::Content::new(builder(app)), WorkspacePane::Response => pane_grid::Content::new(response(app)), }) @@ -55,7 +55,7 @@ pub fn workspace(app: &Zagel) -> Element<'_, Message> { } fn builder(app: &Zagel) -> Element<'_, Message> { - let builder_grid = PaneGrid::new(&app.builder_panes, move |_, pane, _| match pane { + let builder_grid = PaneGrid::new(&app.view.builder_panes, move |_, pane, _| match pane { BuilderPane::Form => pane_grid::Content::new(builder_form(app)), BuilderPane::Body => pane_grid::Content::new(builder_body(app)), }) @@ -67,15 +67,17 @@ fn builder(app: &Zagel) -> Element<'_, Message> { builder_grid.into() } +#[allow(clippy::too_many_lines)] fn builder_form(app: &Zagel) -> Element<'_, Message> { let env_pick = container( pick_list( - app.environments - .iter() - .map(|e| e.name.clone()) - .collect::>(), - Some(app.environments[app.active_environment].name.clone()), - Message::EnvironmentChanged, + app.view + .environments + .iter() + .map(|e| e.name.clone()) + .collect::>(), + Some(app.view.active_environment_name()), + Message::EnvironmentChanged, ) .width(Length::Fill), ) @@ -84,26 +86,26 @@ fn builder_form(app: &Zagel) -> Element<'_, Message> { let method_pick = container( pick_list( - Method::ALL.to_vec(), - Some(app.draft.method), - Message::MethodSelected, + Method::ALL.to_vec(), + Some(app.model.draft.method), + Message::MethodSelected, ) .width(Length::Fill), ) .width(Length::FillPortion(2)) .max_width(METHOD_PICK_MAX_WIDTH); - let url_input = text_input("https://api.example.com", &app.draft.url) + let url_input = text_input("https://api.example.com", &app.model.draft.url) .on_input(Message::UrlChanged) .padding(6) .width(Length::FillPortion(6)); - let title_input = text_input("Title", &app.draft.title) + let title_input = text_input("Title", &app.model.draft.title) .on_input(Message::TitleChanged) .padding(4) .width(Length::FillPortion(5)); - let save_path_row: Element<'_, Message> = match &app.selection { + let save_path_row: Element<'_, Message> = match &app.view.selection { Some(RequestId::HttpFile { path, .. }) => row![ container(text("Saving to").size(14)).width(Length::Fixed(LABEL_WIDTH)), container(text(path.display().to_string()).size(14)).width(Length::Fill) @@ -113,7 +115,7 @@ fn builder_form(app: &Zagel) -> Element<'_, Message> { .into(), _ => row![ container(text("Save as").size(14)).width(Length::Fixed(LABEL_WIDTH)), - text_input("path/to/request.http", &app.save_path) + text_input("path/to/request.http", &app.model.save_path) .on_input(Message::SavePathChanged) .padding(4) .width(Length::Fill), @@ -125,16 +127,16 @@ fn builder_form(app: &Zagel) -> Element<'_, Message> { let mode_pick = container( pick_list( - RequestMode::ALL.to_vec(), - Some(app.mode), - Message::ModeChanged, + RequestMode::ALL.to_vec(), + Some(app.model.mode), + Message::ModeChanged, ) .width(Length::Fill), ) .width(Length::FillPortion(2)) .max_width(MODE_PICK_MAX_WIDTH); - let auth_view = auth_editor(&app.auth); + let auth_view = auth_editor(&app.model.auth); let meta_section = column![ row![ @@ -165,7 +167,7 @@ fn builder_form(app: &Zagel) -> Element<'_, Message> { let form_content = column![ section("Meta", meta_section.into()), section("Request", request_section.into()), - section("Headers", headers::editor(&app.header_rows)), + section("Headers", headers::editor(&app.model.header_rows)), section("Auth", auth_view), ] .spacing(10); @@ -182,34 +184,29 @@ fn builder_form(app: &Zagel) -> Element<'_, Message> { } fn builder_body(app: &Zagel) -> Element<'_, Message> { - let body_title = match app.mode { + let body_title = match app.model.mode { RequestMode::GraphQl => "GraphQL", RequestMode::Rest => "Body", }; - let body_panel: Element<'_, Message> = match app.mode { + let body_panel: Element<'_, Message> = match app.model.mode { RequestMode::GraphQl => { let query_editor: iced::widget::TextEditor<'_, _, _, Theme> = - text_editor(&app.graphql_query) + text_editor(&app.model.graphql_query) .on_action(Message::GraphqlQueryEdited) .height(Length::FillPortion(3)); let vars_editor: iced::widget::TextEditor<'_, _, _, Theme> = - text_editor(&app.graphql_variables) + text_editor(&app.model.graphql_variables) .on_action(Message::GraphqlVariablesEdited) .height(Length::FillPortion(2)); - column![ - text("Query"), - query_editor, - text("Variables"), - vars_editor, - ] - .height(Length::Fill) - .spacing(6) - .into() + column![text("Query"), query_editor, text("Variables"), vars_editor] + .height(Length::Fill) + .spacing(6) + .into() } RequestMode::Rest => { let body_editor: iced::widget::TextEditor<'_, _, _, Theme> = - text_editor(&app.body_editor) + text_editor(&app.model.body_editor) .on_action(Message::BodyEdited) .height(Length::Fill); column![text("Body"), body_editor] @@ -229,21 +226,21 @@ fn builder_body(app: &Zagel) -> Element<'_, Message> { fn response(app: &Zagel) -> Element<'_, Message> { let mut status_row = row![ - response_view_toggle(app.response_display), - response_tab_toggle(app.response_tab), + response_view_toggle(app.view.response_display), + response_tab_toggle(app.view.response_tab), ] .spacing(8); - if matches!(app.response_tab, super::response::ResponseTab::Body) { + if matches!(app.view.response_tab, super::response::ResponseTab::Body) { status_row = status_row.push(button("Copy body").on_press(Message::CopyResponseBody)); } let response_view = response_panel( - app.last_response.as_ref(), - &app.response_viewer, - app.response_display, - app.response_tab, - app.state.theme.highlight_theme(), + app.view.last_response.as_ref(), + &app.view.response_viewer, + app.view.response_display, + app.view.response_tab, + app.runtime.state.theme.highlight_theme(), ); let response_section = section( @@ -264,7 +261,7 @@ fn response(app: &Zagel) -> Element<'_, Message> { .height(Length::Fill) .into(); - if app.show_shortcuts { + if app.view.show_shortcuts { let overlay = container(shortcuts_panel()) .width(Length::Fill) .height(Length::Fill) @@ -288,6 +285,9 @@ fn shortcuts_panel() -> Element<'static, Message> { let shortcuts = column![ text("? - Toggle shortcuts help").size(14), + text("Ctrl/Cmd+Z - Undo").size(14), + text("Ctrl/Cmd+Shift+Z - Redo").size(14), + text("Ctrl/Cmd+Y - Redo").size(14), text("Ctrl/Cmd+S - Save request").size(14), text("Ctrl/Cmd+Enter - Send request").size(14), ]