httpman 0.1.5

A fast, modern HTTP client with tab management and request persistence
Documentation
use std::{fs::ReadDir, path::PathBuf};

use directories::ProjectDirs;
use ulid::Ulid;

use crate::{settings::AppSettings, tab::Tab, TabCtx};

#[derive(Debug)]
pub enum TabAction {
    LoadFile {
        file_path: PathBuf,
    },
    CreateFile {
        folder_path: PathBuf,
    },
    CreateFolder {
        parent_path: PathBuf,
    },
    RenameFolder {
        folder_path: PathBuf,
        current_name: String,
    },
}

#[derive(Debug)]
pub struct Storage {
    base: PathBuf,
    list: Vec<StorageItem>,
}

pub struct TabIterator {
    iter: ReadDir,
    ctx: TabCtx,
}

impl Iterator for TabIterator {
    type Item = anyhow::Result<Tab>;

    fn next(&mut self) -> Option<Self::Item> {
        loop {
            match self.iter.next() {
                Some(Ok(entry)) => {
                    let path = entry.path();
                    if path.is_file() {
                        return Some(Tab::new_from_file(&path, self.ctx.clone()));
                    }
                }
                Some(Err(e)) => return Some(Err(e.into())),
                None => return None,
            }
        }
    }
}

impl Storage {
    /// Create a new storage instance using OS-specific app data directory
    pub fn new() -> anyhow::Result<Self> {
        let base = if let Some(proj_dirs) = ProjectDirs::from("com", "httpman", "httpman") {
            proj_dirs.data_dir().to_path_buf()
        } else {
            // Fallback to current directory if OS directories not available
            std::env::current_dir()?.join(".httpman")
        };

        std::fs::create_dir_all(&base)?;

        let me = Self { base, list: vec![] };

        let tabs = me.tabs_folder();
        std::fs::create_dir_all(&tabs)?;

        let saved = me.saved_folder();
        std::fs::create_dir_all(&saved)?;

        Ok(me)
    }

    pub fn load_active_tabs(&self, ctx: TabCtx) -> anyhow::Result<TabIterator> {
        let tabs = self.tabs_folder();
        let iter = std::fs::read_dir(tabs)?;

        Ok(TabIterator { iter, ctx })
    }

    pub fn tabs_root(&self, ctx: TabCtx) -> StorageItem {
        let path = self.tabs_folder();
        StorageItem::new_from_folder(path, ctx)
    }

    pub fn refresh_list(&mut self, ctx: &TabCtx) -> anyhow::Result<&mut [StorageItem]> {
        self.list.clear();
        self.load_list(ctx)
    }

    pub fn load_list(&mut self, ctx: &TabCtx) -> anyhow::Result<&mut [StorageItem]> {
        if self.list.is_empty() {
            let saved_folder = self.saved_folder();
            for item in std::fs::read_dir(saved_folder)?.flatten() {
                self.list.push(StorageItem::new(ctx.clone(), item.path())?);
            }

            self.list.sort_by_key(|i| if i.is_folder() { 0 } else { 1 });
        }

        Ok(&mut self.list)
    }

    pub fn save_tab(&self, tab: &Tab) -> anyhow::Result<()> {
        let path = self.tabs_folder().join(tab.file_name());
        tab.save_to_file(&path)
    }

    pub fn delete_tab(&self, tab: &Tab) -> anyhow::Result<()> {
        let path = self.tabs_folder().join(tab.file_name());
        if path.exists() {
            std::fs::remove_file(path)?;
        }
        Ok(())
    }

    pub fn save_settings(&self, settings: &AppSettings) -> anyhow::Result<()> {
        let path = AppSettings::settings_path(&self.base);
        settings.save_to_file(path)
    }

    pub fn load_settings(&self) -> AppSettings {
        let path = AppSettings::settings_path(&self.base);
        AppSettings::load_from_file(path)
    }

    pub fn save_tab_to_saved(&self, tab: &Tab, name: &str) -> anyhow::Result<()> {
        let file_name = format!("{}.httpmand", name);
        let path = self.saved_folder().join(file_name);
        tab.save_to_file(&path)
    }

    pub fn create_saved_folder(&self, name: &str) -> anyhow::Result<()> {
        let path = self.saved_folder().join(name);
        std::fs::create_dir_all(path)?;
        Ok(())
    }

    pub fn load_saved_tab(&self, path: &std::path::Path, ctx: TabCtx) -> anyhow::Result<Tab> {
        Tab::new_from_file(path, ctx)
    }

    pub fn rename_folder(&self, old_path: &std::path::Path, new_name: &str) -> anyhow::Result<()> {
        if let Some(parent) = old_path.parent() {
            let new_path = parent.join(new_name);
            std::fs::rename(old_path, new_path)?;
        }
        Ok(())
    }

    pub fn is_empty(&self) -> bool {
        // Check if both tabs and saved folders are empty
        let tabs_empty = self
            .tabs_folder()
            .read_dir()
            .map(|entries| entries.count() == 0)
            .unwrap_or(true);

        let saved_empty = self
            .saved_folder()
            .read_dir()
            .map(|entries| entries.count() == 0)
            .unwrap_or(true);

        tabs_empty && saved_empty
    }

    pub fn create_example_data(&self, ctx: &TabCtx) -> anyhow::Result<()> {
        // Create an Examples folder in saved requests
        let examples_folder = self.saved_folder().join("Examples");
        std::fs::create_dir_all(&examples_folder)?;

        // Create Bitcoin API example
        let bitcoin_tab = crate::tab::Tab::new_bitcoin_api_example(ctx.clone());
        let bitcoin_path = examples_folder.join("Bitcoin_Price_API.httpmand");
        bitcoin_tab.save_to_file(&bitcoin_path)?;

        // Create Image example
        let image_tab = crate::tab::Tab::new_image_example(ctx.clone());
        let image_path = examples_folder.join("Random_Image.httpmand");
        image_tab.save_to_file(&image_path)?;

        Ok(())
    }

    fn requests_folder(&self) -> PathBuf {
        self.base.join("requests")
    }

    fn tabs_folder(&self) -> PathBuf {
        self.requests_folder().join("Tabs")
    }

    pub fn saved_folder(&self) -> PathBuf {
        self.requests_folder().join("Saved")
    }
}

#[derive(Debug)]
pub enum StorageItem {
    Folder {
        name: String,
        path: PathBuf,
        items: Vec<StorageItem>,
    },
    File {
        title: String,
        path: PathBuf,
        tab_id: Ulid,
    },
}

impl StorageItem {
    pub fn new(ctx: TabCtx, item: PathBuf) -> anyhow::Result<Self> {
        if item.is_dir() {
            Ok(Self::new_from_folder(item, ctx))
        } else if item.is_file() {
            Self::new_from_file(item, ctx.clone())
        } else {
            Err(anyhow::anyhow!("Not a file or folder"))
        }
    }

    pub fn is_folder(&self) -> bool {
        match self {
            StorageItem::Folder { .. } => true,
            _ => false,
        }
    }

    fn new_from_folder(item: PathBuf, ctx: TabCtx) -> Self {
        let name = item
            .file_name()
            .map(|f| f.to_string_lossy().to_string())
            .unwrap_or_else(|| item.to_string_lossy().to_string());

        let mut items = vec![];
        if let Ok(entries) = std::fs::read_dir(&item) {
            for entry in entries.flatten() {
                if let Ok(storage_item) = Self::new(ctx.clone(), entry.path()) {
                    items.push(storage_item);
                }
            }
        }

        Self::Folder {
            name,
            path: item,
            items,
        }
    }

    fn new_from_file(item: PathBuf, ctx: TabCtx) -> anyhow::Result<Self> {
        let file_name = item
            .file_name()
            .map(|f| f.to_string_lossy())
            .unwrap_or_default();
        if file_name.ends_with(".httpmand") {
            let tab = Tab::new_from_file(&item, ctx.clone())?;
            Ok(StorageItem::File {
                title: tab.title(),
                path: item,
                tab_id: tab.id,
            })
        } else {
            Err(anyhow::anyhow!("Not a tab file"))
        }
    }

    pub fn name(&self) -> &str {
        match self {
            Self::Folder { name, .. } => name,
            Self::File { title, .. } => title,
        }
    }

    pub fn render_tree(&self, ui: &mut egui::Ui, ctx: &TabCtx) -> Option<TabAction> {
        match self {
            StorageItem::Folder { name, items, path } => {
                let mut action = None;

                let header_response = egui::CollapsingHeader::new(format!("📁 {}", name))
                    .default_open(false)
                    .show(ui, |ui| {
                        ui.indent("folder_indent", |ui| {
                            // Context menu for folder
                            ui.allocate_response(ui.available_size(), egui::Sense::hover())
                                .context_menu(|ui| {
                                    if ui.button("📄 New Request").clicked() {
                                        action = Some(TabAction::CreateFile {
                                            folder_path: path.clone(),
                                        });
                                        ui.close_menu();
                                    }
                                    if ui.button("📁 New Folder").clicked() {
                                        action = Some(TabAction::CreateFolder {
                                            parent_path: path.clone(),
                                        });
                                        ui.close_menu();
                                    }
                                    if ui.button("✏️ Rename").clicked() {
                                        action = Some(TabAction::RenameFolder {
                                            folder_path: path.clone(),
                                            current_name: name.clone(),
                                        });
                                        ui.close_menu();
                                    }
                                });

                            for item in items {
                                if let Some(item_action) = item.render_tree(ui, ctx) {
                                    action = Some(item_action);
                                }
                            }
                        });
                    });

                // Check for double-click on folder header for rename
                if header_response.header_response.double_clicked() {
                    action = Some(TabAction::RenameFolder {
                        folder_path: path.clone(),
                        current_name: name.clone(),
                    });
                }

                action
            }
            StorageItem::File { title, path, .. } => {
                let mut action = None;

                ui.horizontal(|ui| {
                    if ui.link(format!("📄 {}", title)).clicked() {
                        action = Some(TabAction::LoadFile {
                            file_path: path.clone(),
                        });
                    }
                });

                action
            }
        }
    }
}