diff --git a/.claude/agents/senior-rust-engineer.md b/.claude/agents/senior-rust-engineer.md index 2f7363c..edda8d7 100644 --- a/.claude/agents/senior-rust-engineer.md +++ b/.claude/agents/senior-rust-engineer.md @@ -7,6 +7,26 @@ model: sonnet You are a senior Rust engineer with deep expertise in TUI development using ratatui and async programming with tokio. You are building **forge** — a terminal-native API client (Postman in the terminal). Read `SPEC.md` for the full spec and `CLAUDE.md` for project conventions before writing code. +--- + +## Pre-Implementation Reasoning (MANDATORY) + +**Before writing any code**, run the reasoning skill checklist at `.claude/skills/reasoning/SKILL.md`. + +Work through every section that applies to your task: + +1. **Data Model** — new/changed structs, enum variants, serde defaults, exhaustive matches +2. **Lifecycle/Sync** — when created, modified, flushed, destroyed, loaded on restart +3. **Idempotency/Dedup** — what if triggered twice? search before push +4. **Inverse Operations** — every open/create needs a matching close/delete/save +5. **UI Completeness** — focus cycle, keybind hints, empty state, overflow/scroll +6. **State Coherence** — clamp indices after mutations, invalidate caches +7. **"Who Else Touches This?" Audit** — grep every symbol you change; categorize by create/read/update/delete/persist/display + +Answer each relevant question (one-liner is enough) before touching a single source file. Unanswered questions = gaps that will become bugs. + +--- + ## Core Principles - **Correctness first**: safe Rust, no `unwrap()` in production paths — use `?` and proper error types diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ad976d6..86b21a3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "Bash(echo No tokio-util in lock yet:*)", "WebFetch(domain:docs.rs)", "Bash(cd \"C:\\\\Users\\\\alber\\\\Desktop\\\\forge\" && cargo check 2>&1)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(xargs grep:*)", + "Bash(cargo check:*)", + "Bash(cargo test:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/.claude/skills/reasoning/SKILL.md b/.claude/skills/reasoning/SKILL.md new file mode 100644 index 0000000..4a3f3c5 --- /dev/null +++ b/.claude/skills/reasoning/SKILL.md @@ -0,0 +1,134 @@ +--- +name: reasoning +description: > + Self-questioning checklist to run before implementing any feature. + Use to surface edge cases, lifecycle gaps, persistence requirements, + dedup needs, and UI completeness issues before writing code. + Invoke mentally (or literally write answers) before starting implementation. +--- + +# Feature Reasoning Checklist + +Before writing code, work through these question groups out loud. +Answer each one briefly — a one-liner is enough. Unanswered questions = gaps in the plan. + +--- + +## 1. Data Model Questions + +*Trigger: adding or changing any struct field, enum variant, or type.* + +- Does this new field need to be **persisted** (saved to disk / TOML)? + → If yes: add `#[serde(default)]` so old files still load. +- Are there **struct literal initializations** (`Struct { field: val, ... }`) elsewhere + that will now fail to compile? + → Search for every place the struct is constructed by name. +- Does the `Default` impl need updating? +- Do any `Clone`, `PartialEq`, or `Display` impls need to handle the new field? +- If I added an enum variant: are there `match` statements on this enum elsewhere? + → Every exhaustive match must get a new arm. + +--- + +## 2. Lifecycle / Sync Questions + +*Trigger: any feature that creates, modifies, or destroys stateful data.* + +- **When is this data created?** (startup, user action, event?) +- **When is it modified?** (every keystroke, on submit, on navigate-away?) +- **When should it be flushed/saved?** Never assume "it's saved automatically." + → List every code path that changes it; each path needs a save call or a sync point. +- **When is it destroyed?** (tab close, workspace switch, app exit?) + → Every destruction path must flush state first. +- What happens on **app restart**? Is the state loaded back correctly? + → Test: create → close app → reopen → is state intact? + +--- + +## 3. Idempotency / Dedup Questions + +*Trigger: any feature that opens, creates, or adds something.* + +- What happens if the user **triggers this action twice** in a row? + → Should it be a no-op, an error, or create a second item? +- Is there an **existing open instance** that should be re-focused instead? + → Search open_tabs / active list before pushing a new item. +- What if the **item already exists** with the same identity? + → Define identity: is it by ID, by name, by position? + +--- + +## 4. Inverse / Symmetric Operations + +*Trigger: any "open", "create", "start", or "enable" action.* + +For every action X, list its inverses and verify each handles the new state: + +| Action added | Inverses to check | +|---|---| +| open tab | close tab, switch tab, switch workspace, app exit | +| create item | delete item, rename item, duplicate item | +| add field | all constructors, all serialization round-trips | +| enable feature | disable feature, reset to default | + +**Every entry point needs a matching exit point that cleans up / persists.** + +--- + +## 5. UI Completeness Questions + +*Trigger: any new visible element (widget, panel, row, popup, indicator).* + +- Should this element be **keyboard-navigable**? + → If yes: add a `Focus` variant, update `next()`/`prev()`, add visual indicator. +- Does it need a **direct shortcut key** (number, letter)? +- Does it need **keybinding hints** in a status bar or hint footer? +- What happens when the element is **empty** (zero items, blank state)? + → Render a placeholder; don't panic on `list[0]`. +- What happens when the element is **out of screen bounds** (many items, small terminal)? + → Clamp scroll_offset; ensure cursor stays visible. + +--- + +## 6. Related State Coherence + +*Trigger: any action that adds or removes items from a list, or changes active indices.* + +- After this action, are all **indices still valid**? + → active_tab_idx, cursor, scroll_offset — clamp them after mutations. +- Are there **other state fields** that reference the mutated data by index or ID? + → Update or invalidate caches (e.g. highlighted_body, selected row). +- Does any **other component render** based on the data I changed? + → Read render functions that touch the same state; ensure they handle new shape. + +--- + +## 7. The "Who Else Touches This?" Audit + +*Run this for every struct, field, or function you modify.* + +1. Search the codebase for all uses of the symbol. +2. Categorize: create / read / update / delete / display / persist / test. +3. For each category: does my change break or require updating that site? + +``` +Symbol: CollectionRequest + create: CollectionRequest::new(), struct literal in sidebar_duplicate → needs url/body_raw + read: flatten_tree(), find_col_request_by_id() → fine, reads by field + update: update_col_request_state() → needs url/body_raw + persist: save_collection_meta() → handled by serde + open: handle_sidebar_enter() → needs to load url/body_raw back + close: close_active_tab() → needs to sync url/body_raw first +``` + +--- + +## Checklist (run before every implementation) + +- [ ] Listed all struct literals for modified structs → all compile? +- [ ] Named every lifecycle stage (create / modify / destroy) and wired save/load +- [ ] Checked for dedup: what if this is triggered twice? +- [ ] Verified all inverses (close, delete, switch) handle the new state +- [ ] If new UI element: added to Focus cycle, visual indicator, hint bar +- [ ] Indices/cursors clamped after any list mutation +- [ ] "Who else touches this?" audit complete — no missed call sites diff --git a/PROGRESS.md b/PROGRESS.md index 1bdf828..c2ff3e2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,7 +4,7 @@ - [x] Round 1 — Core Request Engine - [x] Round 2 — Environment Variables (partly - [more details here](SPEC.md#implementation-tasks-1)) -- [ ] Round 3 — Collections & Workspaces +- [x] Round 3 — Collections & Workspaces - [ ] Round 4 — Authentication - [ ] Round 5 — Request Headers & Query Params (Done Partly) - [ ] Round 6 — Request Body Editor (Done Partly) @@ -124,6 +124,51 @@ --- +## Round 3 — Collections & Workspaces ✓ + +### Files Implemented (13 new/major files) + +| Layer | Files | +|---|---| +| State | `state/collection.rs`, `state/workspace.rs`, `state/app_state.rs` (major migration) | +| Storage | `storage/workspace.rs`, `storage/collection.rs` | +| UI | `ui/sidebar.rs` (full rewrite), `ui/request_tabs.rs`, `ui/naming_popup.rs`, `ui/confirm_delete.rs`, `ui/workspace_switcher.rs` | +| App Logic | `app.rs` (sidebar CRUD, tab management, workspace switching) | + +### Architecture + +- **AppState migration**: `state.request`/`state.response` → `state.workspace.open_tabs[active_tab_idx]`; accessed via `state.active_tab()` / `state.active_tab_mut()` +- **Environments moved**: `state.environments` → `state.workspace.environments`; `state.active_env_idx` → `state.workspace.active_environment_idx` +- **Storage path**: `%APPDATA%/forge/workspaces//` (Windows) / XDG / macOS equivalents +- **Sidebar tree**: `SidebarNode` enum flattened to a list via `flatten_collections()`; collapsed node IDs tracked in `sidebar.collapsed_ids: HashSet` +- **Tabs**: `WorkspaceState.open_tabs: Vec`; `active_tab_idx` tracks focus; tabs persist to `workspace.toml` + +### Keybindings Added + +| Key | Action | +|---|---| +| `Ctrl+W` | Workspace switcher popup | +| `Ctrl+n` (Sidebar) | New collection | +| `n` (Sidebar) | New request | +| `f` (Sidebar) | New folder | +| `r` (Sidebar) | Rename selected item | +| `d` (Sidebar) | Delete selected item | +| `D` (Sidebar) | Duplicate selected item | +| `h` / `l` (Sidebar) | Collapse / expand node | +| `/` (Sidebar) | Toggle search mode | +| `Alt+1–9` | Switch to tab N | +| `Alt+w` | Close active tab | +| `[` / `]` | Cycle open tabs (non-UrlBar focus) | + +### Gotchas & Fixes + +- Sidebar search runs inline at the footer row (repurposed hint row); `NamingState` carries the HTTP method for new requests so method persists through the naming popup flow +- `active_tab()` returns `Option<&RequestTab>` — all render functions must handle `None` gracefully +- Workspace save triggered on every tab/request mutation via `dirty` flag; debounced via the Tick event +- `flatten_collections()` recurses into folders and respects `collapsed_ids` to hide children + +--- + ## Round 5 — Request Headers & Query Params (Done Partly) ### What's Implemented diff --git a/README.md b/README.md index ee9c653..c02087a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Every request, collection, environment, and workspace is a human-readable TOML f - [x] **Round 1** - Core Request Engine (URL bar, HTTP executor, response viewer, syntax highlighting) - [x] **Round 2** - Environment Variables (`{{variable}}` interpolation, env switcher, secret vars) (partly - [more details here](SPEC.md#implementation-tasks-1)) -- [ ] **Round 3** - Collections & Workspaces (sidebar tree, tabs, file persistence) +- [x] **Round 3** - Collections & Workspaces (sidebar tree, tabs, file persistence) - [ ] **Round 4** - Authentication (Basic, Bearer, API Key, OAuth 2.0, Digest) - [ ] **Round 5** - Request Headers & Query Params (key-value editors, autocomplete, bidirectional URL sync) - [ ] **Round 6** - Request Body Editor (JSON, Form, Multipart, GraphQL, Raw, Binary) diff --git a/SPEC.md b/SPEC.md index f701ace..c273c3a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -908,15 +908,15 @@ pub struct RequestTab { ### Implementation Tasks -- [ ] Implement sidebar tree widget with collapse/expand -- [ ] Implement collection CRUD (new, rename, delete, duplicate) -- [ ] Implement folder support inside collections -- [ ] Implement request tabs in main panel -- [ ] Implement workspace switcher popup -- [ ] Implement sidebar fuzzy search -- [ ] Implement file persistence for collections (TOML files) -- [ ] Implement auto-save on request modification -- [ ] Implement workspace-level CRUD +- [x] Implement sidebar tree widget with collapse/expand +- [x] Implement collection CRUD (new, rename, delete, duplicate) +- [x] Implement folder support inside collections +- [x] Implement request tabs in main panel +- [x] Implement workspace switcher popup +- [x] Implement sidebar fuzzy search +- [x] Implement file persistence for collections (TOML files) +- [x] Implement auto-save on request modification +- [x] Implement workspace-level CRUD - [ ] Display unsaved indicator (`*`) on dirty requests/tabs --- diff --git a/src/app.rs b/src/app.rs index 4afc804..86f4f81 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,15 +5,23 @@ use tokio_util::sync::CancellationToken; use crate::error::AppError; use crate::event::Event; use crate::http::{client::build_client, executor::execute}; -use crate::state::app_state::{ActivePopup, ActiveTab, AppState, RequestStatus}; +use crate::state::app_state::{ + ActivePopup, ActiveTab, AppState, ConfirmDeleteState, NamingState, NamingTarget, + RequestStatus, WorkspaceSwitcherState, +}; +use crate::state::collection::{Collection, CollectionItem, CollectionRequest, Folder}; use crate::state::environment::{EnvVariable, Environment, VarType}; use crate::state::focus::Focus; use crate::state::mode::Mode; use crate::state::request_state::KeyValuePair; use crate::state::response_state::{ResponseBody, ResponseState}; +use crate::state::workspace::RequestTab; use crate::env::resolver::resolver_from_state; use crate::storage::environment as env_storage; +use crate::storage::collection as col_storage; +use crate::storage::workspace as ws_storage; use crate::ui::highlight::{detect_lang, highlight_text}; +use crate::ui::sidebar::flatten_tree; pub struct App { pub state: AppState, @@ -24,14 +32,26 @@ pub struct App { impl App { pub fn new(tx: UnboundedSender) -> Self { - let environments = env_storage::load_all(); - let active_env_idx = if environments.is_empty() { None } else { Some(0) }; + let mut ws = ws_storage::load_workspace_full("default"); + let all_workspaces = ws_storage::list_workspaces(); + + if ws.open_tabs.is_empty() { + ws.open_tabs.push(RequestTab::default()); + } + + let active_env_idx = if ws.environments.is_empty() { + None + } else { + ws.active_environment_idx.or(Some(0)) + }; + ws.active_environment_idx = active_env_idx; + Self { state: AppState { sidebar_visible: true, dirty: true, - environments, - active_env_idx, + workspace: ws, + all_workspaces, ..Default::default() }, client: build_client(), @@ -44,6 +64,7 @@ impl App { match event { Event::Key(key) if key.kind != KeyEventKind::Release => { self.state.dirty = true; + // Ctrl+R fires globally regardless of mode or focus if key.code == KeyCode::Char('r') && key.modifiers.contains(KeyModifiers::CONTROL) @@ -51,6 +72,7 @@ impl App { self.send_request(); return; } + // Ctrl+E: toggle environment switcher popup if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) @@ -65,9 +87,32 @@ impl App { ActivePopup::EnvSwitcher | ActivePopup::EnvEditor => { self.state.active_popup = ActivePopup::None; } + _ => { + self.state.active_popup = ActivePopup::None; + } } return; } + + // Ctrl+W: workspace switcher + if key.code == KeyCode::Char('w') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + match self.state.active_popup { + ActivePopup::None => { + self.state.active_popup = ActivePopup::WorkspaceSwitcher; + self.state.ws_switcher = WorkspaceSwitcherState::default(); + } + ActivePopup::WorkspaceSwitcher => { + self.state.active_popup = ActivePopup::None; + } + _ => { + self.state.active_popup = ActivePopup::None; + } + } + return; + } + // If a popup is open, route all keys to it if self.state.active_popup != ActivePopup::None { self.handle_popup_key(key); @@ -100,13 +145,18 @@ impl App { // ------------------------------------------------------------------------- fn handle_popup_key(&mut self, key: KeyEvent) { - match self.state.active_popup { + match self.state.active_popup.clone() { ActivePopup::EnvSwitcher => self.handle_env_switcher_key(key), ActivePopup::EnvEditor => self.handle_env_editor_key(key), + ActivePopup::WorkspaceSwitcher => self.handle_workspace_switcher_key(key), + ActivePopup::CollectionNaming => self.handle_naming_key(key), + ActivePopup::ConfirmDelete => self.handle_confirm_delete_key(key), ActivePopup::None => {} } } + // ─── Env switcher ───────────────────────────────────────────────────────── + fn handle_env_switcher_key(&mut self, key: KeyEvent) { if self.state.env_switcher.naming { self.handle_env_switcher_naming_key(key); @@ -122,6 +172,7 @@ impl App { let selected = self.state.env_switcher.selected; let idx = self .state + .workspace .environments .iter() .enumerate() @@ -129,7 +180,7 @@ impl App { .nth(selected) .map(|(i, _)| i); if let Some(i) = idx { - self.state.active_env_idx = Some(i); + self.state.workspace.active_environment_idx = Some(i); } self.state.active_popup = ActivePopup::None; } @@ -139,6 +190,7 @@ impl App { let selected = self.state.env_switcher.selected; let idx = self .state + .workspace .environments .iter() .enumerate() @@ -153,11 +205,10 @@ impl App { self.state.env_editor.editing = false; self.state.env_editor.show_secret = false; self.state.active_popup = ActivePopup::EnvEditor; - } else if self.state.environments.is_empty() { - // No environments — create a new one and open editor + } else if self.state.workspace.environments.is_empty() { let new_env = Environment::default(); - self.state.environments.push(new_env); - let i = self.state.environments.len() - 1; + self.state.workspace.environments.push(new_env); + let i = self.state.workspace.environments.len() - 1; self.state.env_editor.env_idx = i; self.state.env_editor.row = 0; self.state.env_editor.col = 0; @@ -168,17 +219,16 @@ impl App { } } KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::ALT) => { - // Enter naming mode — the environment is created after Enter self.state.env_switcher.naming = true; self.state.env_switcher.new_name = String::new(); self.state.env_switcher.new_name_cursor = 0; } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => { - // Delete the selected environment let filter = self.state.env_switcher.search.to_lowercase(); let selected = self.state.env_switcher.selected; let idx = self .state + .workspace .environments .iter() .enumerate() @@ -186,16 +236,17 @@ impl App { .nth(selected) .map(|(i, _)| i); if let Some(i) = idx { - let env_id = self.state.environments[i].id.clone(); - let _ = env_storage::delete(&env_id); - self.state.environments.remove(i); - // Update active_env_idx - match self.state.active_env_idx { - Some(ai) if ai == i => self.state.active_env_idx = None, - Some(ai) if ai > i => self.state.active_env_idx = Some(ai - 1), + let env_id = self.state.workspace.environments[i].id.clone(); + let ws_name = self.state.workspace.name.clone(); + let _ = env_storage::delete_ws(&ws_name, &env_id); + self.state.workspace.environments.remove(i); + match self.state.workspace.active_environment_idx { + Some(ai) if ai == i => self.state.workspace.active_environment_idx = None, + Some(ai) if ai > i => { + self.state.workspace.active_environment_idx = Some(ai - 1) + } _ => {} } - // Clamp selected let count = self.filtered_env_count(); self.state.env_switcher.selected = self.state.env_switcher.selected.min(count.saturating_sub(1)); @@ -235,6 +286,7 @@ impl App { fn filtered_env_count(&self) -> usize { let filter = self.state.env_switcher.search.to_lowercase(); self.state + .workspace .environments .iter() .filter(|e| filter.is_empty() || e.name.to_lowercase().contains(&filter)) @@ -255,10 +307,12 @@ impl App { }; let mut new_env = Environment::default(); new_env.name = name; - self.state.environments.push(new_env); - let i = self.state.environments.len() - 1; + let ws_name = self.state.workspace.name.clone(); + let _ = env_storage::save_ws(&ws_name, &new_env); + self.state.workspace.environments.push(new_env); + let i = self.state.workspace.environments.len() - 1; self.state.env_switcher.selected = i; - self.state.active_env_idx = Some(i); + self.state.workspace.active_environment_idx = Some(i); self.state.env_switcher.naming = false; self.state.env_switcher.new_name = String::new(); self.state.env_switcher.new_name_cursor = 0; @@ -306,6 +360,8 @@ impl App { } } + // ─── Env editor ─────────────────────────────────────────────────────────── + fn handle_env_editor_key(&mut self, key: KeyEvent) { if self.state.env_editor.editing_name { self.handle_env_name_edit_key(key); @@ -317,24 +373,20 @@ impl App { } match key.code { KeyCode::Esc => { - // Save and close self.save_current_env(); self.state.active_popup = ActivePopup::None; } KeyCode::Char('i') | KeyCode::Enter => { - // Start editing the current cell (except checkbox and type toggle cols) let col = self.state.env_editor.col; if col < 3 { self.state.env_editor.editing = true; - // Set cursor to end of current field let cursor = self.current_editor_field_len(); self.state.env_editor.cursor = cursor; } } KeyCode::Char('a') => { - // Add a new variable row let idx = self.state.env_editor.env_idx; - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { env.variables.push(EnvVariable::default()); self.state.env_editor.row = env.variables.len() - 1; self.state.env_editor.col = 0; @@ -343,27 +395,31 @@ impl App { } } KeyCode::Char('d') => { - // Delete current row let idx = self.state.env_editor.env_idx; - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { let row = self.state.env_editor.row; if row < env.variables.len() { env.variables.remove(row); let new_len = env.variables.len(); - if new_len > 0 { - self.state.env_editor.row = row.min(new_len - 1); + self.state.env_editor.row = if new_len > 0 { + row.min(new_len - 1) } else { - self.state.env_editor.row = 0; - } + 0 + }; } } } KeyCode::Char('j') | KeyCode::Down => { let idx = self.state.env_editor.env_idx; - let len = self.state.environments.get(idx).map(|e| e.variables.len()).unwrap_or(0); + let len = self + .state + .workspace + .environments + .get(idx) + .map(|e| e.variables.len()) + .unwrap_or(0); if len > 0 { - self.state.env_editor.row = - (self.state.env_editor.row + 1).min(len - 1); + self.state.env_editor.row = (self.state.env_editor.row + 1).min(len - 1); } } KeyCode::Char('k') | KeyCode::Up => { @@ -376,9 +432,8 @@ impl App { self.state.env_editor.col = (self.state.env_editor.col + 1).min(3); } KeyCode::Char('r') => { - // Enter name-editing mode let idx = self.state.env_editor.env_idx; - if let Some(env) = self.state.environments.get(idx) { + if let Some(env) = self.state.workspace.environments.get(idx) { self.state.env_editor.name_cursor = env.name.len(); self.state.env_editor.editing_name = true; } @@ -387,12 +442,11 @@ impl App { let idx = self.state.env_editor.env_idx; let row = self.state.env_editor.row; let col = self.state.env_editor.col; - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { if let Some(var) = env.variables.get_mut(row) { match col { 0 => var.enabled = !var.enabled, 3 => { - // Toggle secret/text var.var_type = if var.var_type == VarType::Secret { VarType::Text } else { @@ -400,9 +454,9 @@ impl App { }; } _ => { - // Toggle show_secret when on value col if col == 1 { - self.state.env_editor.show_secret = !self.state.env_editor.show_secret; + self.state.env_editor.show_secret = + !self.state.env_editor.show_secret; } } } @@ -427,16 +481,27 @@ impl App { self.state.env_editor.editing = true; } else { self.state.env_editor.col = 0; - // Move to next row let idx = self.state.env_editor.env_idx; - let len = self.state.environments.get(idx).map(|e| e.variables.len()).unwrap_or(0); + let len = self + .state + .workspace + .environments + .get(idx) + .map(|e| e.variables.len()) + .unwrap_or(0); let next_row = self.state.env_editor.row + 1; if next_row >= len { - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { env.variables.push(EnvVariable::default()); } } - let new_len = self.state.environments.get(idx).map(|e| e.variables.len()).unwrap_or(0); + let new_len = self + .state + .workspace + .environments + .get(idx) + .map(|e| e.variables.len()) + .unwrap_or(0); self.state.env_editor.row = next_row.min(new_len.saturating_sub(1)); self.state.env_editor.cursor = 0; self.state.env_editor.editing = true; @@ -499,7 +564,7 @@ impl App { KeyCode::Char(c) => { let idx = self.state.env_editor.env_idx; let cursor = self.state.env_editor.name_cursor; - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { env.name.insert(cursor, c); self.state.env_editor.name_cursor = cursor + c.len_utf8(); } @@ -508,7 +573,7 @@ impl App { let idx = self.state.env_editor.env_idx; let cursor = self.state.env_editor.name_cursor; if cursor > 0 { - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { let prev = Self::prev_char_boundary_of(&env.name, cursor); env.name.drain(prev..cursor); self.state.env_editor.name_cursor = prev; @@ -518,7 +583,7 @@ impl App { KeyCode::Delete => { let idx = self.state.env_editor.env_idx; let cursor = self.state.env_editor.name_cursor; - if let Some(env) = self.state.environments.get_mut(idx) { + if let Some(env) = self.state.workspace.environments.get_mut(idx) { if cursor < env.name.len() { let next = Self::next_char_boundary_of(&env.name, cursor); env.name.drain(cursor..next); @@ -528,7 +593,7 @@ impl App { KeyCode::Left => { let idx = self.state.env_editor.env_idx; let cursor = self.state.env_editor.name_cursor; - if let Some(env) = self.state.environments.get(idx) { + if let Some(env) = self.state.workspace.environments.get(idx) { self.state.env_editor.name_cursor = Self::prev_char_boundary_of(&env.name, cursor); } @@ -536,7 +601,7 @@ impl App { KeyCode::Right => { let idx = self.state.env_editor.env_idx; let cursor = self.state.env_editor.name_cursor; - if let Some(env) = self.state.environments.get(idx) { + if let Some(env) = self.state.workspace.environments.get(idx) { self.state.env_editor.name_cursor = Self::next_char_boundary_of(&env.name, cursor); } @@ -548,6 +613,7 @@ impl App { let idx = self.state.env_editor.env_idx; self.state.env_editor.name_cursor = self .state + .workspace .environments .get(idx) .map(|e| e.name.len()) @@ -561,7 +627,10 @@ impl App { let idx = self.state.env_editor.env_idx; let row = self.state.env_editor.row; let col = self.state.env_editor.col; - self.state.environments.get(idx) + self.state + .workspace + .environments + .get(idx) .and_then(|e| e.variables.get(row)) .map(|v| match col { 0 => v.key.len(), @@ -576,7 +645,13 @@ impl App { let idx = self.state.env_editor.env_idx; let row = self.state.env_editor.row; let col = self.state.env_editor.col; - let var = self.state.environments.get_mut(idx)?.variables.get_mut(row)?; + let var = self + .state + .workspace + .environments + .get_mut(idx)? + .variables + .get_mut(row)?; match col { 0 => Some(&mut var.key), 1 => Some(&mut var.value), @@ -587,16 +662,351 @@ impl App { fn save_current_env(&self) { let idx = self.state.env_editor.env_idx; - if let Some(env) = self.state.environments.get(idx) { - let _ = env_storage::save(env); + let ws_name = &self.state.workspace.name; + if let Some(env) = self.state.workspace.environments.get(idx) { + let _ = env_storage::save_ws(ws_name, env); } } - // ------------------------------------------------------------------------- - // Normal key handling (unchanged from Round 1) - // ------------------------------------------------------------------------- + // ─── Workspace switcher ─────────────────────────────────────────────────── + + fn handle_workspace_switcher_key(&mut self, key: KeyEvent) { + if self.state.ws_switcher.naming { + self.handle_ws_naming_key(key); + return; + } + match key.code { + KeyCode::Esc => { + self.state.active_popup = ActivePopup::None; + } + KeyCode::Enter => { + let filter = self.state.ws_switcher.search.to_lowercase(); + let selected = self.state.ws_switcher.selected; + let chosen = self + .state + .all_workspaces + .iter() + .filter(|w| filter.is_empty() || w.to_lowercase().contains(&filter)) + .nth(selected) + .cloned(); + if let Some(name) = chosen { + if name != self.state.workspace.name { + let mut ws = ws_storage::load_workspace_full(&name); + if ws.open_tabs.is_empty() { + ws.open_tabs.push(RequestTab::default()); + } + self.state.workspace = ws; + } + } + self.state.active_popup = ActivePopup::None; + } + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::ALT) => { + self.state.ws_switcher.naming = true; + self.state.ws_switcher.new_name = String::new(); + self.state.ws_switcher.new_name_cursor = 0; + } + KeyCode::Char('j') | KeyCode::Down => { + let filter = self.state.ws_switcher.search.to_lowercase(); + let count = self + .state + .all_workspaces + .iter() + .filter(|w| filter.is_empty() || w.to_lowercase().contains(&filter)) + .count(); + if count > 0 { + self.state.ws_switcher.selected = + (self.state.ws_switcher.selected + 1).min(count - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.state.ws_switcher.selected = + self.state.ws_switcher.selected.saturating_sub(1); + } + KeyCode::Backspace => { + let cursor = self.state.ws_switcher.search_cursor; + if cursor > 0 { + let s = self.state.ws_switcher.search.clone(); + let prev = Self::prev_char_boundary_of(&s, cursor); + self.state.ws_switcher.search.drain(prev..cursor); + self.state.ws_switcher.search_cursor = prev; + self.state.ws_switcher.selected = 0; + } + } + KeyCode::Char(c) => { + let cursor = self.state.ws_switcher.search_cursor; + self.state.ws_switcher.search.insert(cursor, c); + self.state.ws_switcher.search_cursor += c.len_utf8(); + self.state.ws_switcher.selected = 0; + } + _ => {} + } + } + + fn handle_ws_naming_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.state.ws_switcher.naming = false; + self.state.ws_switcher.new_name = String::new(); + } + KeyCode::Enter => { + let name = if self.state.ws_switcher.new_name.trim().is_empty() { + return; + } else { + self.state.ws_switcher.new_name.trim().to_string() + }; + let ws_file = crate::state::workspace::WorkspaceFile { + name: name.clone(), + active_environment_idx: None, + }; + let _ = ws_storage::save_workspace(&ws_file); + self.state.all_workspaces = ws_storage::list_workspaces(); + // Switch to new workspace + let mut ws = ws_storage::load_workspace_full(&name); + if ws.open_tabs.is_empty() { + ws.open_tabs.push(RequestTab::default()); + } + self.state.workspace = ws; + self.state.ws_switcher.naming = false; + self.state.ws_switcher.new_name = String::new(); + self.state.ws_switcher.new_name_cursor = 0; + self.state.active_popup = ActivePopup::None; + } + KeyCode::Char(c) => { + let cursor = self.state.ws_switcher.new_name_cursor; + self.state.ws_switcher.new_name.insert(cursor, c); + self.state.ws_switcher.new_name_cursor = cursor + c.len_utf8(); + } + KeyCode::Backspace => { + let cursor = self.state.ws_switcher.new_name_cursor; + if cursor > 0 { + let s = self.state.ws_switcher.new_name.clone(); + let prev = Self::prev_char_boundary_of(&s, cursor); + self.state.ws_switcher.new_name.drain(prev..cursor); + self.state.ws_switcher.new_name_cursor = prev; + } + } + KeyCode::Left => { + let cursor = self.state.ws_switcher.new_name_cursor; + let s = self.state.ws_switcher.new_name.clone(); + self.state.ws_switcher.new_name_cursor = Self::prev_char_boundary_of(&s, cursor); + } + KeyCode::Right => { + let cursor = self.state.ws_switcher.new_name_cursor; + let s = self.state.ws_switcher.new_name.clone(); + self.state.ws_switcher.new_name_cursor = Self::next_char_boundary_of(&s, cursor); + } + KeyCode::Home => { + self.state.ws_switcher.new_name_cursor = 0; + } + KeyCode::End => { + self.state.ws_switcher.new_name_cursor = self.state.ws_switcher.new_name.len(); + } + _ => {} + } + } + + // ─── Collection naming popup ────────────────────────────────────────────── + + fn handle_naming_key(&mut self, key: KeyEvent) { + let is_new_request = matches!(self.state.naming.target, NamingTarget::NewRequest { .. }); + match key.code { + KeyCode::Esc => { + self.state.active_popup = ActivePopup::None; + self.state.naming = NamingState::default(); + } + KeyCode::Enter => { + self.confirm_naming(); + self.state.active_popup = ActivePopup::None; + } + KeyCode::Tab if is_new_request => { + self.state.naming.method = cycle_method_next(&self.state.naming.method); + } + KeyCode::Right if is_new_request => { + self.state.naming.method = cycle_method_next(&self.state.naming.method); + } + KeyCode::Left if is_new_request => { + self.state.naming.method = cycle_method_prev(&self.state.naming.method); + } + KeyCode::Char(c) => { + let cursor = self.state.naming.cursor; + self.state.naming.input.insert(cursor, c); + self.state.naming.cursor = cursor + c.len_utf8(); + } + KeyCode::Backspace => { + let cursor = self.state.naming.cursor; + if cursor > 0 { + let s = self.state.naming.input.clone(); + let prev = Self::prev_char_boundary_of(&s, cursor); + self.state.naming.input.drain(prev..cursor); + self.state.naming.cursor = prev; + } + } + KeyCode::Delete => { + let cursor = self.state.naming.cursor; + let len = self.state.naming.input.len(); + if cursor < len { + let s = self.state.naming.input.clone(); + let next = Self::next_char_boundary_of(&s, cursor); + self.state.naming.input.drain(cursor..next); + } + } + KeyCode::Left => { + let cursor = self.state.naming.cursor; + let s = self.state.naming.input.clone(); + self.state.naming.cursor = Self::prev_char_boundary_of(&s, cursor); + } + KeyCode::Right => { + let cursor = self.state.naming.cursor; + let s = self.state.naming.input.clone(); + self.state.naming.cursor = Self::next_char_boundary_of(&s, cursor); + } + KeyCode::Home => { + self.state.naming.cursor = 0; + } + KeyCode::End => { + self.state.naming.cursor = self.state.naming.input.len(); + } + _ => {} + } + } + + fn confirm_naming(&mut self) { + let input = self.state.naming.input.trim().to_string(); + if input.is_empty() { + self.state.naming = NamingState::default(); + return; + } + + let ws_name = self.state.workspace.name.clone(); + let target = self.state.naming.target.clone(); + + match target { + NamingTarget::NewCollection => { + let col = Collection::new(&input); + let _ = col_storage::save_collection_meta(&ws_name, &col); + self.state.workspace.collections.push(col); + } + NamingTarget::NewFolder { collection_id } => { + if let Some(col) = self + .state + .workspace + .collections + .iter_mut() + .find(|c| c.id == collection_id) + { + let folder = Folder::new(&input); + col.items.push(CollectionItem::Folder(folder)); + let _ = col_storage::save_collection_meta(&ws_name, col); + } + } + NamingTarget::NewRequest { collection_id, folder_id } => { + let mut req = CollectionRequest::new(&input); + req.method = self.state.naming.method.clone(); + if let Some(col) = self + .state + .workspace + .collections + .iter_mut() + .find(|c| c.id == collection_id) + { + if let Some(fid) = folder_id { + // Find folder anywhere in the collection items + add_request_to_folder(&mut col.items, &fid, CollectionItem::Request(req)); + } else { + col.items.push(CollectionItem::Request(req)); + } + let _ = col_storage::save_collection_meta(&ws_name, col); + } + } + NamingTarget::Rename { id, .. } => { + // Find and rename the item with matching id in collections + for col in &mut self.state.workspace.collections { + if col.id == id { + col.name = input.clone(); + let _ = col_storage::save_collection_meta(&ws_name, col); + break; + } + if rename_item_in_list(&mut col.items, &id, &input) { + let _ = col_storage::save_collection_meta(&ws_name, col); + break; + } + } + } + } + + self.state.naming = NamingState::default(); + } + + // ─── Confirm delete popup ───────────────────────────────────────────────── + + fn handle_confirm_delete_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + self.execute_delete(); + self.state.active_popup = ActivePopup::None; + self.state.confirm_delete = ConfirmDeleteState::default(); + } + KeyCode::Char('n') | KeyCode::Esc => { + self.state.active_popup = ActivePopup::None; + self.state.confirm_delete = ConfirmDeleteState::default(); + } + _ => {} + } + } + + fn execute_delete(&mut self) { + let target_id = self.state.confirm_delete.target_id.clone(); + let ws_name = self.state.workspace.name.clone(); + + // Try to delete collection first + let col_pos = self + .state + .workspace + .collections + .iter() + .position(|c| c.id == target_id); + if let Some(pos) = col_pos { + let col_name = self.state.workspace.collections[pos].name.clone(); + let _ = col_storage::delete_collection(&ws_name, &col_name); + self.state.workspace.collections.remove(pos); + // Clamp cursor + let len = self.state.workspace.collections.len(); + self.state.sidebar.cursor = self.state.sidebar.cursor.min(len.saturating_sub(1)); + return; + } + + // Try to delete from within collections + for col in &mut self.state.workspace.collections { + if remove_item_from_list(&mut col.items, &target_id) { + let _ = col_storage::save_collection_meta(&ws_name, col); + break; + } + } + } + + // ─── Normal key handling ────────────────────────────────────────────────── fn handle_normal_key(&mut self, key: KeyEvent) { + // Alt+1..Alt+9: jump to open tab by index + if key.modifiers.contains(KeyModifiers::ALT) { + match key.code { + KeyCode::Char(c @ '1'..='9') => { + let idx = (c as usize) - ('1' as usize); + if idx < self.state.workspace.open_tabs.len() { + self.sync_active_tab_to_collection(); + self.state.workspace.active_tab_idx = idx; + } + return; + } + KeyCode::Char('w') => { + self.sync_active_tab_to_collection(); + self.close_active_tab(); + return; + } + _ => {} + } + } + match key.code { KeyCode::Char('q') => self.state.should_quit = true, KeyCode::Tab => self.state.focus = self.state.focus.next(), @@ -605,111 +1015,261 @@ impl App { if matches!(self.state.focus, Focus::UrlBar | Focus::Editor) { self.state.mode = Mode::Insert; if self.state.focus == Focus::Editor { - if self.state.active_tab == ActiveTab::Headers { - // Set cursor to end of active cell - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - if let Some(pair) = self.state.request.headers.get(row) { - let len = if col == 0 { pair.key.len() } else { pair.value.len() }; - self.state.request.headers_cursor = len; + let active_tab = self + .state + .active_tab() + .map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + let (row, col, len) = if let Some(tab) = self.state.active_tab() { + let row = tab.request.headers_row; + let col = tab.request.headers_col; + let len = tab + .request + .headers + .get(row) + .map(|p| if col == 0 { p.key.len() } else { p.value.len() }) + .unwrap_or(0); + (row, col, len) + } else { + (0, 0, 0) + }; + let _ = (row, col); + if let Some(tab) = self.state.active_tab_mut() { + tab.request.headers_cursor = len; } } else { - // Body editor: initialize body to Json if None - if self.state.request.body == crate::state::request_state::RequestBody::None { - self.state.request.body = crate::state::request_state::RequestBody::Json(String::new()); + if let Some(tab) = self.state.active_tab_mut() { + if tab.request.body + == crate::state::request_state::RequestBody::None + { + tab.request.body = + crate::state::request_state::RequestBody::Json( + String::new(), + ); + } } } } + } else if matches!(self.state.focus, Focus::Sidebar) { + self.handle_sidebar_enter(); + } else if matches!(self.state.focus, Focus::RequestTabs) { + self.state.focus = Focus::UrlBar; } } KeyCode::Char('[') => { - self.state.request.method = self.state.request.method.prev(); + if self.state.focus == Focus::UrlBar { + if let Some(tab) = self.state.active_tab_mut() { + tab.request.method = tab.request.method.prev(); + } + } else { + self.sync_active_tab_to_collection(); + self.prev_open_tab(); + } } KeyCode::Char(']') => { - self.state.request.method = self.state.request.method.next(); + if self.state.focus == Focus::UrlBar { + if let Some(tab) = self.state.active_tab_mut() { + tab.request.method = tab.request.method.next(); + } + } else { + self.sync_active_tab_to_collection(); + self.next_open_tab(); + } } KeyCode::Esc => self.cancel_request(), KeyCode::Char('j') | KeyCode::Down => { - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers - { - let len = self.state.request.headers.len(); - if len > 0 { - self.state.request.headers_row = - (self.state.request.headers_row + 1).min(len - 1); + if self.state.focus == Focus::Sidebar { + self.sidebar_move_cursor(1); + } else if self.state.focus == Focus::Editor { + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + let len = tab.request.headers.len(); + if len > 0 { + tab.request.headers_row = + (tab.request.headers_row + 1).min(len - 1); + } + } + } else if let Some(tab) = self.state.active_tab_mut() { + if let Some(resp) = &mut tab.response { + resp.scroll_offset = resp.scroll_offset.saturating_add(1); + } + } + } else if let Some(tab) = self.state.active_tab_mut() { + if let Some(resp) = &mut tab.response { + resp.scroll_offset = resp.scroll_offset.saturating_add(1); } - } else if let Some(resp) = &mut self.state.response { - resp.scroll_offset = resp.scroll_offset.saturating_add(1); } } KeyCode::Char('k') | KeyCode::Up => { - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers - { - self.state.request.headers_row = - self.state.request.headers_row.saturating_sub(1); - } else if let Some(resp) = &mut self.state.response { - resp.scroll_offset = resp.scroll_offset.saturating_sub(1); + if self.state.focus == Focus::Sidebar { + self.sidebar_move_cursor_up(); + } else if self.state.focus == Focus::Editor { + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + tab.request.headers_row = + tab.request.headers_row.saturating_sub(1); + } + } else if let Some(tab) = self.state.active_tab_mut() { + if let Some(resp) = &mut tab.response { + resp.scroll_offset = resp.scroll_offset.saturating_sub(1); + } + } + } else if let Some(tab) = self.state.active_tab_mut() { + if let Some(resp) = &mut tab.response { + resp.scroll_offset = resp.scroll_offset.saturating_sub(1); + } } } - KeyCode::Left | KeyCode::Char('h') if self.state.focus == Focus::TabBar => { - self.state.active_tab = self.state.active_tab.prev(); + KeyCode::Left | KeyCode::Char('h') + if self.state.focus == Focus::TabBar => + { + if let Some(tab) = self.state.active_tab_mut() { + tab.active_tab = tab.active_tab.prev(); + } } - KeyCode::Right | KeyCode::Char('l') if self.state.focus == Focus::TabBar => { - self.state.active_tab = self.state.active_tab.next(); + KeyCode::Right | KeyCode::Char('l') + if self.state.focus == Focus::TabBar => + { + if let Some(tab) = self.state.active_tab_mut() { + tab.active_tab = tab.active_tab.next(); + } + } + KeyCode::Char('h') if self.state.focus == Focus::Sidebar => { + self.sidebar_collapse(); + } + KeyCode::Char('l') if self.state.focus == Focus::Sidebar => { + self.sidebar_expand(); } KeyCode::Left - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers => + if self.state.focus == Focus::Editor => { - self.state.request.headers_col = 0; - let row = self.state.request.headers_row; - let len = self.state.request.headers.get(row).map(|p| p.key.len()).unwrap_or(0); - self.state.request.headers_cursor = len; + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + tab.request.headers_col = 0; + let row = tab.request.headers_row; + let len = + tab.request.headers.get(row).map(|p| p.key.len()).unwrap_or(0); + tab.request.headers_cursor = len; + } + } } KeyCode::Right - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers => + if self.state.focus == Focus::Editor => { - self.state.request.headers_col = 1; - let row = self.state.request.headers_row; - let len = self.state.request.headers.get(row).map(|p| p.value.len()).unwrap_or(0); - self.state.request.headers_cursor = len; + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + tab.request.headers_col = 1; + let row = tab.request.headers_row; + let len = + tab.request.headers.get(row).map(|p| p.value.len()).unwrap_or(0); + tab.request.headers_cursor = len; + } + } } KeyCode::Char('a') - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers => + if self.state.focus == Focus::Editor => { - self.state.request.headers.push(KeyValuePair::default()); - let new_row = self.state.request.headers.len() - 1; - self.state.request.headers_row = new_row; - self.state.request.headers_col = 0; - self.state.request.headers_cursor = 0; - self.state.mode = Mode::Insert; + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + tab.request.headers.push(KeyValuePair::default()); + let new_row = tab.request.headers.len() - 1; + tab.request.headers_row = new_row; + tab.request.headers_col = 0; + tab.request.headers_cursor = 0; + self.state.mode = Mode::Insert; + } + } } KeyCode::Char('x') | KeyCode::Char('d') - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers => + if self.state.focus == Focus::Editor => { - let len = self.state.request.headers.len(); - if len > 0 { - self.state.request.headers.remove(self.state.request.headers_row); - let new_len = self.state.request.headers.len(); - self.state.request.headers_row = if new_len > 0 { - self.state.request.headers_row.min(new_len - 1) - } else { - 0 - }; + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + let len = tab.request.headers.len(); + if len > 0 { + tab.request.headers.remove(tab.request.headers_row); + let new_len = tab.request.headers.len(); + tab.request.headers_row = if new_len > 0 { + tab.request.headers_row.min(new_len - 1) + } else { + 0 + }; + } + } } } KeyCode::Char(' ') - if self.state.focus == Focus::Editor - && self.state.active_tab == ActiveTab::Headers => + if self.state.focus == Focus::Editor => { - if let Some(pair) = self.state.request.headers.get_mut(self.state.request.headers_row) { - pair.enabled = !pair.enabled; + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if active_tab == Some(ActiveTab::Headers) { + if let Some(tab) = self.state.active_tab_mut() { + let row = tab.request.headers_row; + if let Some(pair) = tab.request.headers.get_mut(row) { + pair.enabled = !pair.enabled; + } + } } } + // Sidebar-specific keys + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) && self.state.focus == Focus::Sidebar => { + self.state.naming = NamingState { + target: NamingTarget::NewCollection, + ..NamingState::default() + }; + self.state.active_popup = ActivePopup::CollectionNaming; + } + KeyCode::Char('n') if self.state.focus == Focus::Sidebar => { + // New request at current cursor context + let target = self.sidebar_new_request_target(); + self.state.naming = NamingState { + target, + method: "GET".to_string(), + ..NamingState::default() + }; + self.state.active_popup = ActivePopup::CollectionNaming; + } + KeyCode::Char('f') if self.state.focus == Focus::Sidebar => { + // New folder at current cursor context + let target = self.sidebar_new_folder_target(); + self.state.naming = NamingState { + target, + ..NamingState::default() + }; + self.state.active_popup = ActivePopup::CollectionNaming; + } + KeyCode::Char('r') if self.state.focus == Focus::Sidebar => { + self.sidebar_rename(); + } + KeyCode::Char('d') if self.state.focus == Focus::Sidebar => { + self.sidebar_delete(); + } + KeyCode::Char('D') if self.state.focus == Focus::Sidebar => { + self.sidebar_duplicate(); + } + KeyCode::Char('/') if self.state.focus == Focus::Sidebar => { + self.state.sidebar.search_mode = true; + self.state.sidebar.search_query.clear(); + } + // RequestTabs-specific keys + KeyCode::Left if self.state.focus == Focus::RequestTabs => { + self.sync_active_tab_to_collection(); + self.prev_open_tab(); + } + KeyCode::Right if self.state.focus == Focus::RequestTabs => { + self.sync_active_tab_to_collection(); + self.next_open_tab(); + } + KeyCode::Char('x') if self.state.focus == Focus::RequestTabs => { + self.sync_active_tab_to_collection(); + self.close_active_tab(); + } KeyCode::Char('1') => self.state.focus = Focus::Sidebar, KeyCode::Char('2') => self.state.focus = Focus::UrlBar, KeyCode::Char('3') => self.state.focus = Focus::Editor, @@ -718,8 +1278,289 @@ impl App { } } + // ─── Sidebar helpers ────────────────────────────────────────────────────── + + fn sidebar_move_cursor(&mut self, delta: usize) { + let nodes = flatten_tree(&self.state); + let max = nodes.len().saturating_sub(1); + let new_cursor = (self.state.sidebar.cursor + delta).min(max); + self.state.sidebar.cursor = new_cursor; + // Scroll down if needed + // (We'll implement simple scroll clamping — caller must know visible height) + // For now: no-op; layout scrolls based on cursor vs scroll_offset + self.clamp_sidebar_scroll(); + } + + fn sidebar_move_cursor_up(&mut self) { + self.state.sidebar.cursor = self.state.sidebar.cursor.saturating_sub(1); + self.clamp_sidebar_scroll(); + } + + fn clamp_sidebar_scroll(&mut self) { + // Keep cursor visible — conservative 20-line window + let visible = 20usize; + let cursor = self.state.sidebar.cursor; + let scroll = self.state.sidebar.scroll_offset; + if cursor < scroll { + self.state.sidebar.scroll_offset = cursor; + } else if cursor >= scroll + visible { + self.state.sidebar.scroll_offset = cursor.saturating_sub(visible - 1); + } + } + + fn sidebar_collapse(&mut self) { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor) { + match &node.kind { + crate::ui::sidebar::NodeKind::Collection { .. } + | crate::ui::sidebar::NodeKind::Folder { .. } => { + self.state.sidebar.collapsed_ids.insert(node.id.clone()); + } + _ => {} + } + } + } + + fn sidebar_expand(&mut self) { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor) { + self.state.sidebar.collapsed_ids.remove(&node.id); + } + } + + fn handle_sidebar_enter(&mut self) { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor).cloned() { + match node.kind { + crate::ui::sidebar::NodeKind::Collection { collapsed } + | crate::ui::sidebar::NodeKind::Folder { collapsed } => { + if collapsed { + self.state.sidebar.collapsed_ids.remove(&node.id); + } else { + self.state.sidebar.collapsed_ids.insert(node.id.clone()); + } + } + crate::ui::sidebar::NodeKind::Request { method } => { + // Dedup: if already open, just focus it + if let Some(idx) = self.state.workspace.open_tabs.iter() + .position(|t| t.collection_id.as_deref() == Some(&node.id)) + { + self.state.workspace.active_tab_idx = idx; + return; + } + // Load persisted state from collection + let saved = find_col_request_by_id(&self.state.workspace.collections, &node.id).cloned(); + let mut tab = RequestTab::default(); + tab.request.name = node.label.clone(); + tab.request.method = crate::state::request_state::HttpMethod::from_str_or_get(&method); + tab.collection_id = Some(node.id.clone()); + if let Some(saved) = saved { + tab.request.url = saved.url.clone(); + if !saved.body_raw.is_empty() { + tab.request.body = crate::state::request_state::RequestBody::Json(saved.body_raw.clone()); + } + } + self.state.workspace.open_tabs.push(tab); + self.state.workspace.active_tab_idx = self.state.workspace.open_tabs.len() - 1; + } + } + } + } + + fn sidebar_new_request_target(&self) -> NamingTarget { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor) { + let col_id = self.find_collection_id_for_node(&node.id); + let folder_id = match &node.kind { + crate::ui::sidebar::NodeKind::Folder { .. } => Some(node.id.clone()), + _ => None, + }; + if let Some(cid) = col_id { + return NamingTarget::NewRequest { + collection_id: cid, + folder_id, + }; + } + } + NamingTarget::NewCollection + } + + fn sidebar_new_folder_target(&self) -> NamingTarget { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor) { + let col_id = self.find_collection_id_for_node(&node.id); + if let Some(cid) = col_id { + return NamingTarget::NewFolder { collection_id: cid }; + } + } + NamingTarget::NewCollection + } + + fn find_collection_id_for_node(&self, node_id: &str) -> Option { + for col in &self.state.workspace.collections { + if col.id == node_id { + return Some(col.id.clone()); + } + if item_exists_in_list(&col.items, node_id) { + return Some(col.id.clone()); + } + } + None + } + + fn sidebar_rename(&mut self) { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor).cloned() { + self.state.naming = NamingState { + target: NamingTarget::Rename { + id: node.id.clone(), + old_name: node.label.clone(), + }, + input: node.label.clone(), + cursor: node.label.len(), + ..NamingState::default() + }; + self.state.active_popup = ActivePopup::CollectionNaming; + } + } + + fn sidebar_delete(&mut self) { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor).cloned() { + let msg = format!("Delete \"{}\"?", node.label); + self.state.confirm_delete = ConfirmDeleteState { + message: msg, + target_id: node.id.clone(), + }; + self.state.active_popup = ActivePopup::ConfirmDelete; + } + } + + fn sidebar_duplicate(&mut self) { + let nodes = flatten_tree(&self.state); + if let Some(node) = nodes.get(self.state.sidebar.cursor).cloned() { + if let crate::ui::sidebar::NodeKind::Request { method } = &node.kind { + let new_req = CollectionRequest { + id: uuid::Uuid::new_v4().to_string(), + name: format!("{} (copy)", node.label), + method: method.clone(), + url: String::new(), + body_raw: String::new(), + }; + let ws_name = self.state.workspace.name.clone(); + // Insert after cursor in the containing collection/folder + for col in &mut self.state.workspace.collections { + if insert_after_in_list( + &mut col.items, + &node.id, + CollectionItem::Request(new_req.clone()), + ) { + let _ = col_storage::save_collection_meta(&ws_name, col); + break; + } + // Also check if the original is directly in the collection + if col.items.iter().any(|item| match item { + CollectionItem::Request(r) => r.id == node.id, + _ => false, + }) { + col.items.push(CollectionItem::Request(new_req.clone())); + let _ = col_storage::save_collection_meta(&ws_name, col); + break; + } + } + } + } + } + + // ─── Open tab management ────────────────────────────────────────────────── + + fn next_open_tab(&mut self) { + let len = self.state.workspace.open_tabs.len(); + if len == 0 { + return; + } + self.state.workspace.active_tab_idx = + (self.state.workspace.active_tab_idx + 1) % len; + } + + fn prev_open_tab(&mut self) { + let len = self.state.workspace.open_tabs.len(); + if len == 0 { + return; + } + self.state.workspace.active_tab_idx = + (self.state.workspace.active_tab_idx + len - 1) % len; + } + + fn close_active_tab(&mut self) { + let idx = self.state.workspace.active_tab_idx; + let len = self.state.workspace.open_tabs.len(); + if len == 0 { + return; + } + self.state.workspace.open_tabs.remove(idx); + if self.state.workspace.open_tabs.is_empty() { + self.state.workspace.open_tabs.push(RequestTab::default()); + self.state.workspace.active_tab_idx = 0; + } else { + self.state.workspace.active_tab_idx = + self.state.workspace.active_tab_idx.min( + self.state.workspace.open_tabs.len() - 1, + ); + } + } + + // ─── Collection sync ────────────────────────────────────────────────────── + + fn sync_active_tab_to_collection(&mut self) { + let idx = self.state.workspace.active_tab_idx; + if let Some(tab) = self.state.workspace.open_tabs.get(idx) { + let Some(req_id) = tab.collection_id.clone() else { return }; + let url = tab.request.url.clone(); + let method = tab.request.method.as_str().to_string(); + let body_raw = match &tab.request.body { + crate::state::request_state::RequestBody::Json(s) + | crate::state::request_state::RequestBody::Text(s) => s.clone(), + _ => String::new(), + }; + let ws_name = self.state.workspace.name.clone(); + for col in &mut self.state.workspace.collections { + if update_col_request_state(&mut col.items, &req_id, &url, &method, &body_raw) { + let _ = col_storage::save_collection_meta(&ws_name, col); + break; + } + } + } + } + + // ─── Insert key handling ────────────────────────────────────────────────── + fn handle_insert_key(&mut self, key: KeyEvent) { - if self.state.focus == Focus::Editor && self.state.active_tab == ActiveTab::Headers { + // Check if we're in sidebar search mode + if self.state.focus == Focus::Sidebar && self.state.sidebar.search_mode { + match key.code { + KeyCode::Esc => { + self.state.sidebar.search_mode = false; + self.state.sidebar.search_query.clear(); + self.state.mode = Mode::Normal; + } + KeyCode::Char(c) => { + self.state.sidebar.search_query.push(c); + } + KeyCode::Backspace => { + self.state.sidebar.search_query.pop(); + if self.state.sidebar.search_query.is_empty() { + self.state.sidebar.search_mode = false; + self.state.mode = Mode::Normal; + } + } + _ => {} + } + return; + } + + let active_tab = self.state.active_tab().map(|t| t.active_tab.clone()); + if self.state.focus == Focus::Editor && active_tab == Some(ActiveTab::Headers) { self.handle_headers_insert_key(key); return; } @@ -730,155 +1571,192 @@ impl App { self.state.mode = Mode::Normal; self.send_request(); } else if matches!(self.state.focus, Focus::Editor) { - // Insert newline in body editor - if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - let cursor = self.state.request.body_cursor; - text.insert(cursor, '\n'); - self.state.request.body_cursor = cursor + 1; + if let Some(tab) = self.state.active_tab_mut() { + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + let cursor = tab.request.body_cursor; + text.insert(cursor, '\n'); + tab.request.body_cursor = cursor + 1; + } } } } KeyCode::Char(c) => { if matches!(self.state.focus, Focus::UrlBar) { - let cursor = self.state.request.url_cursor; - self.state.request.url.insert(cursor, c); - self.state.request.url_cursor += c.len_utf8(); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.url_cursor; + tab.request.url.insert(cursor, c); + tab.request.url_cursor += c.len_utf8(); + } } else if matches!(self.state.focus, Focus::Editor) { - if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - let cursor = self.state.request.body_cursor; - text.insert(cursor, c); - self.state.request.body_cursor = cursor + c.len_utf8(); + if let Some(tab) = self.state.active_tab_mut() { + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + let cursor = tab.request.body_cursor; + text.insert(cursor, c); + tab.request.body_cursor = cursor + c.len_utf8(); + } } } } KeyCode::Backspace => { if matches!(self.state.focus, Focus::UrlBar) { - let cursor = self.state.request.url_cursor; - if cursor > 0 { - let url = self.state.request.url.clone(); - let prev = Self::prev_char_boundary_of(&url, cursor); - self.state.request.url.drain(prev..cursor); - self.state.request.url_cursor = prev; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.url_cursor; + if cursor > 0 { + let url = tab.request.url.clone(); + let prev = Self::prev_char_boundary_of(&url, cursor); + tab.request.url.drain(prev..cursor); + tab.request.url_cursor = prev; + } } } else if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - if cursor > 0 { - if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - let prev = Self::prev_char_boundary_of(text, cursor); - text.drain(prev..cursor); - self.state.request.body_cursor = prev; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + if cursor > 0 { + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + let prev = Self::prev_char_boundary_of(text, cursor); + text.drain(prev..cursor); + tab.request.body_cursor = prev; + } } } } } KeyCode::Delete => { if matches!(self.state.focus, Focus::UrlBar) { - let cursor = self.state.request.url_cursor; - let url = self.state.request.url.clone(); - if cursor < url.len() { - let next = Self::next_char_boundary_of(&url, cursor); - self.state.request.url.drain(cursor..next); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.url_cursor; + let url = tab.request.url.clone(); + if cursor < url.len() { + let next = Self::next_char_boundary_of(&url, cursor); + tab.request.url.drain(cursor..next); + } } } else if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let body_len = match &self.state.request.body { - crate::state::request_state::RequestBody::Json(s) | - crate::state::request_state::RequestBody::Text(s) => s.len(), - _ => 0, - }; - if cursor < body_len { - if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - let next = Self::next_char_boundary_of(text, cursor); - text.drain(cursor..next); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let body_len = match &tab.request.body { + crate::state::request_state::RequestBody::Json(s) + | crate::state::request_state::RequestBody::Text(s) => s.len(), + _ => 0, + }; + if cursor < body_len { + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + let next = Self::next_char_boundary_of(text, cursor); + text.drain(cursor..next); + } } } } } KeyCode::Left => { if matches!(self.state.focus, Focus::UrlBar) { - let cursor = self.state.request.url_cursor; - let url = self.state.request.url.clone(); - self.state.request.url_cursor = Self::prev_char_boundary_of(&url, cursor); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.url_cursor; + let url = tab.request.url.clone(); + tab.request.url_cursor = Self::prev_char_boundary_of(&url, cursor); + } } else if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let new_cursor = if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - Self::prev_char_boundary_of(text, cursor) - } else { - cursor - }; - self.state.request.body_cursor = new_cursor; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let new_cursor = + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + Self::prev_char_boundary_of(text, cursor) + } else { + cursor + }; + tab.request.body_cursor = new_cursor; + } } } KeyCode::Right => { if matches!(self.state.focus, Focus::UrlBar) { - let cursor = self.state.request.url_cursor; - let url = self.state.request.url.clone(); - self.state.request.url_cursor = Self::next_char_boundary_of(&url, cursor); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.url_cursor; + let url = tab.request.url.clone(); + tab.request.url_cursor = Self::next_char_boundary_of(&url, cursor); + } } else if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let new_cursor = if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - Self::next_char_boundary_of(text, cursor) - } else { - cursor - }; - self.state.request.body_cursor = new_cursor; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let new_cursor = + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + Self::next_char_boundary_of(text, cursor) + } else { + cursor + }; + tab.request.body_cursor = new_cursor; + } } } KeyCode::Up => { if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let body_snapshot = match &self.state.request.body { - crate::state::request_state::RequestBody::Json(s) | - crate::state::request_state::RequestBody::Text(s) => s.clone(), - _ => String::new(), - }; - self.state.request.body_cursor = Self::body_move_up(&body_snapshot, cursor); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let body_snapshot = match &tab.request.body { + crate::state::request_state::RequestBody::Json(s) + | crate::state::request_state::RequestBody::Text(s) => s.clone(), + _ => String::new(), + }; + tab.request.body_cursor = Self::body_move_up(&body_snapshot, cursor); + } } } KeyCode::Down => { if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let body_snapshot = match &self.state.request.body { - crate::state::request_state::RequestBody::Json(s) | - crate::state::request_state::RequestBody::Text(s) => s.clone(), - _ => String::new(), - }; - self.state.request.body_cursor = Self::body_move_down(&body_snapshot, cursor); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let body_snapshot = match &tab.request.body { + crate::state::request_state::RequestBody::Json(s) + | crate::state::request_state::RequestBody::Text(s) => s.clone(), + _ => String::new(), + }; + tab.request.body_cursor = Self::body_move_down(&body_snapshot, cursor); + } } } KeyCode::Home => { if matches!(self.state.focus, Focus::UrlBar) { - self.state.request.url_cursor = 0; + if let Some(tab) = self.state.active_tab_mut() { + tab.request.url_cursor = 0; + } } else if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let new_cursor = if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - let before = &text[..cursor.min(text.len())]; - match before.rfind('\n') { - Some(i) => i + 1, - None => 0, - } - } else { - cursor - }; - self.state.request.body_cursor = new_cursor; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let new_cursor = + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + let before = &text[..cursor.min(text.len())]; + match before.rfind('\n') { + Some(i) => i + 1, + None => 0, + } + } else { + cursor + }; + tab.request.body_cursor = new_cursor; + } } } KeyCode::End => { if matches!(self.state.focus, Focus::UrlBar) { - self.state.request.url_cursor = self.state.request.url.len(); + if let Some(tab) = self.state.active_tab_mut() { + tab.request.url_cursor = tab.request.url.len(); + } } else if matches!(self.state.focus, Focus::Editor) { - let cursor = self.state.request.body_cursor; - let new_cursor = if let Some(text) = Self::body_text_mut(&mut self.state.request.body) { - let after_start = cursor.min(text.len()); - let after = &text[after_start..]; - match after.find('\n') { - Some(i) => after_start + i, - None => text.len(), - } - } else { - cursor - }; - self.state.request.body_cursor = new_cursor; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.body_cursor; + let new_cursor = + if let Some(text) = Self::body_text_mut(&mut tab.request.body) { + let after_start = cursor.min(text.len()); + let after = &text[after_start..]; + match after.find('\n') { + Some(i) => after_start + i, + None => text.len(), + } + } else { + cursor + }; + tab.request.body_cursor = new_cursor; + } } } _ => {} @@ -886,7 +1764,6 @@ impl App { } /// Get a mutable reference to the body text string. - /// If body is None, initialize it to Json(""). fn body_text_mut(body: &mut crate::state::request_state::RequestBody) -> Option<&mut String> { use crate::state::request_state::RequestBody; match body { @@ -917,118 +1794,139 @@ impl App { self.state.mode = Mode::Normal; } KeyCode::Char(c) => { - let cursor = self.state.request.headers_cursor; - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - if let Some(text) = - Self::headers_active_text_mut(&mut self.state.request.headers, row, col) - { - text.insert(cursor, c); - self.state.request.headers_cursor = cursor + c.len_utf8(); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.headers_cursor; + let row = tab.request.headers_row; + let col = tab.request.headers_col; + if let Some(text) = + Self::headers_active_text_mut(&mut tab.request.headers, row, col) + { + text.insert(cursor, c); + tab.request.headers_cursor = cursor + c.len_utf8(); + } } } KeyCode::Backspace => { - let cursor = self.state.request.headers_cursor; - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - if cursor > 0 { - if let Some(text) = - Self::headers_active_text_mut(&mut self.state.request.headers, row, col) - { - let prev = Self::prev_char_boundary_of(text, cursor); - text.drain(prev..cursor); - self.state.request.headers_cursor = prev; + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.headers_cursor; + let row = tab.request.headers_row; + let col = tab.request.headers_col; + if cursor > 0 { + if let Some(text) = + Self::headers_active_text_mut(&mut tab.request.headers, row, col) + { + let prev = Self::prev_char_boundary_of(text, cursor); + text.drain(prev..cursor); + tab.request.headers_cursor = prev; + } } } } KeyCode::Delete => { - let cursor = self.state.request.headers_cursor; - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - if let Some(text) = - Self::headers_active_text_mut(&mut self.state.request.headers, row, col) - { - if cursor < text.len() { - let next = Self::next_char_boundary_of(text, cursor); - text.drain(cursor..next); + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.headers_cursor; + let row = tab.request.headers_row; + let col = tab.request.headers_col; + if let Some(text) = + Self::headers_active_text_mut(&mut tab.request.headers, row, col) + { + if cursor < text.len() { + let next = Self::next_char_boundary_of(text, cursor); + text.drain(cursor..next); + } } } } KeyCode::Left => { - let cursor = self.state.request.headers_cursor; - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - let new_cursor = - if let Some(text) = - Self::headers_active_text_mut(&mut self.state.request.headers, row, col) + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.headers_cursor; + let row = tab.request.headers_row; + let col = tab.request.headers_col; + let new_cursor = if let Some(text) = + Self::headers_active_text_mut(&mut tab.request.headers, row, col) { Self::prev_char_boundary_of(text, cursor) } else { cursor }; - self.state.request.headers_cursor = new_cursor; + tab.request.headers_cursor = new_cursor; + } } KeyCode::Right => { - let cursor = self.state.request.headers_cursor; - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - let new_cursor = - if let Some(text) = - Self::headers_active_text_mut(&mut self.state.request.headers, row, col) + if let Some(tab) = self.state.active_tab_mut() { + let cursor = tab.request.headers_cursor; + let row = tab.request.headers_row; + let col = tab.request.headers_col; + let new_cursor = if let Some(text) = + Self::headers_active_text_mut(&mut tab.request.headers, row, col) { Self::next_char_boundary_of(text, cursor) } else { cursor }; - self.state.request.headers_cursor = new_cursor; + tab.request.headers_cursor = new_cursor; + } } KeyCode::Home => { - self.state.request.headers_cursor = 0; + if let Some(tab) = self.state.active_tab_mut() { + tab.request.headers_cursor = 0; + } } KeyCode::End => { - let row = self.state.request.headers_row; - let col = self.state.request.headers_col; - let len = self.state.request.headers - .get(row) - .map(|p| if col == 0 { p.key.len() } else { p.value.len() }) - .unwrap_or(0); - self.state.request.headers_cursor = len; - } - KeyCode::Tab => { - let col = self.state.request.headers_col; - if col == 0 { - self.state.request.headers_col = 1; - let row = self.state.request.headers_row; - let val_len = self.state.request.headers + if let Some(tab) = self.state.active_tab_mut() { + let row = tab.request.headers_row; + let col = tab.request.headers_col; + let len = tab + .request + .headers .get(row) - .map(|p| p.value.len()) + .map(|p| if col == 0 { p.key.len() } else { p.value.len() }) .unwrap_or(0); - self.state.request.headers_cursor = val_len; - } else { - let next_row = self.state.request.headers_row + 1; - if next_row >= self.state.request.headers.len() { - self.state.request.headers.push(KeyValuePair::default()); + tab.request.headers_cursor = len; + } + } + KeyCode::Tab => { + if let Some(tab) = self.state.active_tab_mut() { + let col = tab.request.headers_col; + if col == 0 { + tab.request.headers_col = 1; + let row = tab.request.headers_row; + let val_len = tab + .request + .headers + .get(row) + .map(|p| p.value.len()) + .unwrap_or(0); + tab.request.headers_cursor = val_len; + } else { + let next_row = tab.request.headers_row + 1; + if next_row >= tab.request.headers.len() { + tab.request.headers.push(KeyValuePair::default()); + } + tab.request.headers_row = + next_row.min(tab.request.headers.len() - 1); + tab.request.headers_col = 0; + tab.request.headers_cursor = 0; } - self.state.request.headers_row = - next_row.min(self.state.request.headers.len() - 1); - self.state.request.headers_col = 0; - self.state.request.headers_cursor = 0; } } KeyCode::Enter => { - let next_row = self.state.request.headers_row + 1; - if next_row >= self.state.request.headers.len() { - self.state.request.headers.push(KeyValuePair::default()); + if let Some(tab) = self.state.active_tab_mut() { + let next_row = tab.request.headers_row + 1; + if next_row >= tab.request.headers.len() { + tab.request.headers.push(KeyValuePair::default()); + } + tab.request.headers_row = next_row.min(tab.request.headers.len() - 1); + tab.request.headers_col = 0; + tab.request.headers_cursor = 0; } - self.state.request.headers_row = - next_row.min(self.state.request.headers.len() - 1); - self.state.request.headers_col = 0; - self.state.request.headers_cursor = 0; } _ => {} } } + // ─── Char boundary helpers ──────────────────────────────────────────────── + fn prev_char_boundary_of(text: &str, pos: usize) -> usize { if pos == 0 { return 0; @@ -1095,22 +1993,30 @@ impl App { row_start + col_bytes } + // ─── Mouse handling ─────────────────────────────────────────────────────── + fn handle_mouse(&mut self, mouse: MouseEvent) { match mouse.kind { MouseEventKind::ScrollDown => { - if let Some(resp) = &mut self.state.response { - resp.scroll_offset = resp.scroll_offset.saturating_add(3); + if let Some(tab) = self.state.active_tab_mut() { + if let Some(resp) = &mut tab.response { + resp.scroll_offset = resp.scroll_offset.saturating_add(3); + } } } MouseEventKind::ScrollUp => { - if let Some(resp) = &mut self.state.response { - resp.scroll_offset = resp.scroll_offset.saturating_sub(3); + if let Some(tab) = self.state.active_tab_mut() { + if let Some(resp) = &mut tab.response { + resp.scroll_offset = resp.scroll_offset.saturating_sub(3); + } } } _ => {} } } + // ─── Response handling ──────────────────────────────────────────────────── + fn handle_response(&mut self, result: Result) { self.cancel = None; match result { @@ -1119,47 +2025,74 @@ impl App { let lang = detect_lang(text); response.highlighted_body = Some(highlight_text(text, lang)); } - self.state.response = Some(response); - self.state.request_status = RequestStatus::Idle; + if let Some(tab) = self.state.active_tab_mut() { + tab.response = Some(response); + tab.request_status = RequestStatus::Idle; + } + self.sync_active_tab_to_collection(); } Err(AppError::Cancelled) => { - self.state.request_status = RequestStatus::Idle; + if let Some(tab) = self.state.active_tab_mut() { + tab.request_status = RequestStatus::Idle; + } } Err(e) => { - self.state.request_status = RequestStatus::Error(e.to_string()); + if let Some(tab) = self.state.active_tab_mut() { + tab.request_status = RequestStatus::Error(e.to_string()); + } } } } + // ─── Tick handling ──────────────────────────────────────────────────────── + fn handle_tick(&mut self) { - if let RequestStatus::Loading { spinner_tick } = &mut self.state.request_status { - *spinner_tick = spinner_tick.wrapping_add(1); - self.state.dirty = true; + if let Some(tab) = self.state.active_tab_mut() { + if let RequestStatus::Loading { spinner_tick } = &mut tab.request_status { + *spinner_tick = spinner_tick.wrapping_add(1); + self.state.dirty = true; + } } } + // ─── HTTP request ───────────────────────────────────────────────────────── + fn send_request(&mut self) { - if self.state.request.url.is_empty() { + let url_empty = self + .state + .active_tab() + .map(|t| t.request.url.is_empty()) + .unwrap_or(true); + if url_empty { return; } + if let Some(token) = self.cancel.take() { token.cancel(); } let token = CancellationToken::new(); self.cancel = Some(token.clone()); - self.state.request_status = RequestStatus::Loading { spinner_tick: 0 }; - self.state.response = None; + + if let Some(tab) = self.state.active_tab_mut() { + tab.request_status = RequestStatus::Loading { spinner_tick: 0 }; + tab.response = None; + } // Build resolver and resolve URL + headers before cloning for the task let resolver = resolver_from_state(&self.state); - let mut request = self.state.request.clone(); - request.url = resolver.resolve_for_send(&request.url); - for header in &mut request.headers { - if header.enabled { - header.key = resolver.resolve_for_send(&header.key); - header.value = resolver.resolve_for_send(&header.value); + let request = if let Some(tab) = self.state.active_tab() { + let mut req = tab.request.clone(); + req.url = resolver.resolve_for_send(&req.url); + for header in &mut req.headers { + if header.enabled { + header.key = resolver.resolve_for_send(&header.key); + header.value = resolver.resolve_for_send(&header.value); + } } - } + req + } else { + return; + }; let client = self.client.clone(); let tx = self.tx.clone(); @@ -1173,6 +2106,205 @@ impl App { if let Some(token) = self.cancel.take() { token.cancel(); } - self.state.request_status = RequestStatus::Idle; + if let Some(tab) = self.state.active_tab_mut() { + tab.request_status = RequestStatus::Idle; + } + } +} + +// ─── Trait extension for HttpMethod ────────────────────────────────────────── + +trait HttpMethodExt { + fn from_str_or_get(s: &str) -> crate::state::request_state::HttpMethod; +} + +impl HttpMethodExt for crate::state::request_state::HttpMethod { + fn from_str_or_get(s: &str) -> Self { + use crate::state::request_state::HttpMethod; + match s { + "GET" => HttpMethod::Get, + "POST" => HttpMethod::Post, + "PUT" => HttpMethod::Put, + "PATCH" => HttpMethod::Patch, + "DELETE" => HttpMethod::Delete, + "HEAD" => HttpMethod::Head, + "OPTIONS" => HttpMethod::Options, + _ => HttpMethod::Get, + } + } +} + +// ─── HTTP method cycling ────────────────────────────────────────────────────── + +const METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +fn cycle_method_next(m: &str) -> String { + let pos = METHODS.iter().position(|&x| x == m).unwrap_or(0); + METHODS[(pos + 1) % METHODS.len()].to_string() +} + +fn cycle_method_prev(m: &str) -> String { + let pos = METHODS.iter().position(|&x| x == m).unwrap_or(0); + METHODS[(pos + METHODS.len() - 1) % METHODS.len()].to_string() +} + +// ─── Collection tree helpers ────────────────────────────────────────────────── + +fn add_request_to_folder( + items: &mut Vec, + folder_id: &str, + req: CollectionItem, +) -> bool { + for item in items.iter_mut() { + if let CollectionItem::Folder(f) = item { + if f.id == folder_id { + f.items.push(req); + return true; + } + if add_request_to_folder(&mut f.items, folder_id, req.clone()) { + return true; + } + } + } + false +} + +fn rename_item_in_list(items: &mut Vec, id: &str, name: &str) -> bool { + for item in items.iter_mut() { + match item { + CollectionItem::Folder(f) => { + if f.id == id { + f.name = name.to_string(); + return true; + } + if rename_item_in_list(&mut f.items, id, name) { + return true; + } + } + CollectionItem::Request(r) => { + if r.id == id { + r.name = name.to_string(); + return true; + } + } + } + } + false +} + +fn remove_item_from_list(items: &mut Vec, id: &str) -> bool { + let before = items.len(); + items.retain(|item| match item { + CollectionItem::Folder(f) => f.id != id, + CollectionItem::Request(r) => r.id != id, + }); + if items.len() < before { + return true; + } + // Recurse into folders + for item in items.iter_mut() { + if let CollectionItem::Folder(f) = item { + if remove_item_from_list(&mut f.items, id) { + return true; + } + } + } + false +} + +fn item_exists_in_list(items: &[CollectionItem], id: &str) -> bool { + for item in items { + match item { + CollectionItem::Folder(f) => { + if f.id == id || item_exists_in_list(&f.items, id) { + return true; + } + } + CollectionItem::Request(r) => { + if r.id == id { + return true; + } + } + } + } + false +} + +fn insert_after_in_list( + items: &mut Vec, + after_id: &str, + new_item: CollectionItem, +) -> bool { + for i in 0..items.len() { + let matches = match &items[i] { + CollectionItem::Folder(f) => f.id == after_id, + CollectionItem::Request(r) => r.id == after_id, + }; + if matches { + items.insert(i + 1, new_item); + return true; + } + if let CollectionItem::Folder(f) = &mut items[i] { + if insert_after_in_list(&mut f.items, after_id, new_item.clone()) { + return true; + } + } + } + false +} + +fn find_col_request_by_id<'a>( + collections: &'a [Collection], + id: &str, +) -> Option<&'a CollectionRequest> { + for col in collections { + if let Some(r) = find_request_in_items(&col.items, id) { + return Some(r); + } + } + None +} + +fn find_request_in_items<'a>( + items: &'a [CollectionItem], + id: &str, +) -> Option<&'a CollectionRequest> { + for item in items { + match item { + CollectionItem::Request(r) if r.id == id => return Some(r), + CollectionItem::Folder(f) => { + if let Some(r) = find_request_in_items(&f.items, id) { + return Some(r); + } + } + _ => {} + } + } + None +} + +fn update_col_request_state( + items: &mut Vec, + id: &str, + url: &str, + method: &str, + body_raw: &str, +) -> bool { + for item in items.iter_mut() { + match item { + CollectionItem::Request(r) if r.id == id => { + r.url = url.to_string(); + r.method = method.to_string(); + r.body_raw = body_raw.to_string(); + return true; + } + CollectionItem::Folder(f) => { + if update_col_request_state(&mut f.items, id, url, method, body_raw) { + return true; + } + } + _ => {} + } } + false } diff --git a/src/env/resolver.rs b/src/env/resolver.rs index 1b64047..75ae51e 100644 --- a/src/env/resolver.rs +++ b/src/env/resolver.rs @@ -131,8 +131,8 @@ pub fn resolver_from_state(state: &AppState) -> EnvResolver { let mut secret_keys: HashSet = HashSet::new(); // Layer 0: active environment - if let Some(idx) = state.active_env_idx { - if let Some(env) = state.environments.get(idx) { + if let Some(idx) = state.workspace.active_environment_idx { + if let Some(env) = state.workspace.environments.get(idx) { let mut map = HashMap::new(); for var in &env.variables { if var.enabled { diff --git a/src/state/app_state.rs b/src/state/app_state.rs index b663c0b..26f60c9 100644 --- a/src/state/app_state.rs +++ b/src/state/app_state.rs @@ -1,11 +1,13 @@ +use std::collections::HashSet; + use super::{ - environment::Environment, focus::Focus, mode::Mode, - request_state::RequestState, - response_state::ResponseState, + workspace::{RequestTab, WorkspaceState}, }; +// ─── Request/Response tab enums ────────────────────────────────────────────── + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ActiveTab { #[default] @@ -55,23 +57,26 @@ pub enum RequestStatus { Error(String), } -/// Which overlay popup (if any) is currently visible. +// ─── Popup discriminant ─────────────────────────────────────────────────────── + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ActivePopup { #[default] None, EnvSwitcher, EnvEditor, + WorkspaceSwitcher, + CollectionNaming, + ConfirmDelete, } -/// State for the environment switcher popup. +// ─── Env popup state (unchanged from Round 2) ───────────────────────────────── + #[derive(Debug, Clone)] pub struct EnvSwitcherState { - /// Currently highlighted row in the filtered list. pub selected: usize, pub search: String, pub search_cursor: usize, - /// Whether the user is currently typing a name for a new environment. pub naming: bool, pub new_name: String, pub new_name_cursor: usize, @@ -90,23 +95,15 @@ impl Default for EnvSwitcherState { } } -/// State for the environment editor popup. #[derive(Debug, Clone)] pub struct EnvEditorState { - /// Index into `AppState.environments` being edited. pub env_idx: usize, - /// Selected row (variable index). pub row: usize, - /// Selected column: 0=key, 1=value, 2=description, 3=type. pub col: u8, - /// Byte cursor within the currently edited cell. pub cursor: usize, pub show_secret: bool, - /// Whether we are currently editing the cell (Insert mode for the editor). pub editing: bool, - /// Whether the user is currently editing the environment's name. pub editing_name: bool, - /// Byte cursor within `environments[env_idx].name` during name editing. pub name_cursor: usize, } @@ -125,25 +122,105 @@ impl Default for EnvEditorState { } } +// ─── Round 3: Sidebar state ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct SidebarState { + pub cursor: usize, + pub collapsed_ids: HashSet, + pub search_mode: bool, + pub search_query: String, + pub scroll_offset: usize, +} + +// ─── Round 3: Workspace switcher popup ─────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct WorkspaceSwitcherState { + pub selected: usize, + pub search: String, + pub search_cursor: usize, + pub naming: bool, + pub new_name: String, + pub new_name_cursor: usize, +} + +// ─── Round 3: Collection/folder/request naming popup ───────────────────────── + +#[derive(Debug, Clone)] +pub enum NamingTarget { + NewCollection, + NewFolder { collection_id: String }, + NewRequest { collection_id: String, folder_id: Option }, + Rename { id: String, old_name: String }, +} + +impl Default for NamingTarget { + fn default() -> Self { + Self::NewCollection + } +} + +#[derive(Debug, Clone)] +pub struct NamingState { + pub target: NamingTarget, + pub input: String, + pub cursor: usize, + pub method: String, +} + +impl Default for NamingState { + fn default() -> Self { + Self { + target: NamingTarget::default(), + input: String::new(), + cursor: 0, + method: "GET".to_string(), + } + } +} + +// ─── Round 3: Delete confirmation popup ────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct ConfirmDeleteState { + pub message: String, + pub target_id: String, +} + +// ─── AppState ───────────────────────────────────────────────────────────────── + #[derive(Debug, Clone, Default)] pub struct AppState { pub mode: Mode, pub focus: Focus, - pub request: RequestState, - pub response: Option, - pub active_tab: ActiveTab, - pub response_tab: ResponseTab, - pub request_status: RequestStatus, pub sidebar_visible: bool, pub should_quit: bool, /// Set to `true` whenever visible state changes. The render loop skips /// `terminal.draw()` when `false`, avoiding redundant work on idle ticks. pub dirty: bool, - // Round 2: environments - pub environments: Vec, - pub active_env_idx: Option, pub active_popup: ActivePopup, pub env_editor: EnvEditorState, pub env_switcher: EnvSwitcherState, + + // Round 3 + pub workspace: WorkspaceState, + pub all_workspaces: Vec, + pub sidebar: SidebarState, + pub naming: NamingState, + pub confirm_delete: ConfirmDeleteState, + pub ws_switcher: WorkspaceSwitcherState, +} + +impl AppState { + /// Returns a reference to the currently active request tab, if any. + pub fn active_tab(&self) -> Option<&RequestTab> { + self.workspace.open_tabs.get(self.workspace.active_tab_idx) + } + + /// Returns a mutable reference to the currently active request tab, if any. + pub fn active_tab_mut(&mut self) -> Option<&mut RequestTab> { + self.workspace.open_tabs.get_mut(self.workspace.active_tab_idx) + } } diff --git a/src/state/collection.rs b/src/state/collection.rs new file mode 100644 index 0000000..09db8ec --- /dev/null +++ b/src/state/collection.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Collection { + pub id: String, + pub name: String, + pub items: Vec, +} + +impl Collection { + pub fn new(name: impl Into) -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: name.into(), + items: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Folder { + pub id: String, + pub name: String, + pub items: Vec, +} + +impl Folder { + pub fn new(name: impl Into) -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: name.into(), + items: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CollectionItem { + Folder(Folder), + Request(CollectionRequest), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionRequest { + pub id: String, + pub name: String, + pub method: String, + #[serde(default)] + pub url: String, + #[serde(default)] + pub body_raw: String, +} + +impl CollectionRequest { + pub fn new(name: impl Into) -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: name.into(), + method: "GET".into(), + url: String::new(), + body_raw: String::new(), + } + } +} diff --git a/src/state/focus.rs b/src/state/focus.rs index 79e6f98..a7c2dbc 100644 --- a/src/state/focus.rs +++ b/src/state/focus.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Focus { Sidebar, + RequestTabs, #[default] UrlBar, TabBar, @@ -9,10 +10,11 @@ pub enum Focus { } impl Focus { - /// Cycle order: Sidebar → UrlBar → TabBar → Editor → ResponseViewer → Sidebar + /// Cycle order: Sidebar → RequestTabs → UrlBar → TabBar → Editor → ResponseViewer → Sidebar pub fn next(&self) -> Focus { match self { - Focus::Sidebar => Focus::UrlBar, + Focus::Sidebar => Focus::RequestTabs, + Focus::RequestTabs => Focus::UrlBar, Focus::UrlBar => Focus::TabBar, Focus::TabBar => Focus::Editor, Focus::Editor => Focus::ResponseViewer, @@ -23,7 +25,8 @@ impl Focus { pub fn prev(&self) -> Focus { match self { Focus::Sidebar => Focus::ResponseViewer, - Focus::UrlBar => Focus::Sidebar, + Focus::RequestTabs => Focus::Sidebar, + Focus::UrlBar => Focus::RequestTabs, Focus::TabBar => Focus::UrlBar, Focus::Editor => Focus::TabBar, Focus::ResponseViewer => Focus::Editor, diff --git a/src/state/mod.rs b/src/state/mod.rs index 5474a7b..4455c70 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -1,4 +1,5 @@ pub mod app_state; +pub mod collection; pub mod environment; pub mod focus; pub mod mode; diff --git a/src/state/workspace.rs b/src/state/workspace.rs index fb07000..d7ec387 100644 --- a/src/state/workspace.rs +++ b/src/state/workspace.rs @@ -1 +1,51 @@ -// Workspace state types +use serde::{Deserialize, Serialize}; + +use crate::state::app_state::{ActiveTab, RequestStatus, ResponseTab}; +use crate::state::collection::Collection; +use crate::state::environment::Environment; +use crate::state::request_state::RequestState; +use crate::state::response_state::ResponseState; + +/// Persisted workspace metadata (saved to `workspace.toml`). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorkspaceFile { + pub name: String, + pub active_environment_idx: Option, +} + +/// A single open request tab (in-memory only). +#[derive(Debug, Clone)] +pub struct RequestTab { + pub request: RequestState, + pub response: Option, + pub active_tab: ActiveTab, + pub response_tab: ResponseTab, + pub is_dirty: bool, + pub collection_id: Option, + pub request_status: RequestStatus, +} + +impl Default for RequestTab { + fn default() -> Self { + Self { + request: RequestState::default(), + response: None, + active_tab: ActiveTab::default(), + response_tab: ResponseTab::default(), + is_dirty: false, + collection_id: None, + request_status: RequestStatus::default(), + } + } +} + +/// Full in-memory workspace state. +#[derive(Debug, Clone, Default)] +pub struct WorkspaceState { + pub name: String, + pub collections: Vec, + pub environments: Vec, + pub active_environment_idx: Option, + pub open_tabs: Vec, + pub active_tab_idx: usize, +} diff --git a/src/storage/collection.rs b/src/storage/collection.rs index d1cc9e2..9cf9ccd 100644 --- a/src/storage/collection.rs +++ b/src/storage/collection.rs @@ -1 +1,48 @@ -// Collection TOML persistence +use std::path::PathBuf; + +use crate::state::collection::Collection; + +fn collections_dir(ws_name: &str) -> PathBuf { + let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + base.join("forge").join("workspaces").join(ws_name).join("collections") +} + +/// Load all collections from a workspace's collections directory. +pub fn load_all_collections(ws_name: &str) -> Vec { + let dir = collections_dir(ws_name); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + + let mut collections = Vec::new(); + for entry in entries.flatten() { + let path = entry.path().join("collection.toml"); + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(col) = toml::from_str::(&content) { + collections.push(col); + } + } + } + collections.sort_by(|a, b| a.name.cmp(&b.name)); + collections +} + +/// Save a collection's metadata to `/collections//collection.toml`. +pub fn save_collection_meta(ws_name: &str, col: &Collection) -> anyhow::Result<()> { + let slug = col.name.to_lowercase().replace(' ', "_"); + let dir = collections_dir(ws_name).join(&slug); + std::fs::create_dir_all(&dir)?; + let content = toml::to_string_pretty(col)?; + std::fs::write(dir.join("collection.toml"), content)?; + Ok(()) +} + +/// Delete a collection directory identified by its name slug. +pub fn delete_collection(ws_name: &str, col_name: &str) -> anyhow::Result<()> { + let slug = col_name.to_lowercase().replace(' ', "_"); + let dir = collections_dir(ws_name).join(&slug); + if dir.exists() { + std::fs::remove_dir_all(dir)?; + } + Ok(()) +} diff --git a/src/storage/environment.rs b/src/storage/environment.rs index fcfa238..909c741 100644 --- a/src/storage/environment.rs +++ b/src/storage/environment.rs @@ -47,3 +47,50 @@ pub fn load_all() -> Vec { } envs } + +// ─── Workspace-scoped environment storage ──────────────────────────────────── + +fn ws_data_dir(ws_name: &str) -> PathBuf { + let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + base.join("forge").join("workspaces").join(ws_name).join("environments") +} + +/// Save an environment into the given workspace's environments directory. +pub fn save_ws(ws_name: &str, env: &Environment) -> anyhow::Result<()> { + let dir = ws_data_dir(ws_name); + std::fs::create_dir_all(&dir)?; + let path = dir.join(format!("{}.toml", env.id)); + let content = toml::to_string_pretty(env)?; + std::fs::write(path, content)?; + Ok(()) +} + +/// Delete an environment from the given workspace's environments directory. +pub fn delete_ws(ws_name: &str, id: &str) -> anyhow::Result<()> { + let path = ws_data_dir(ws_name).join(format!("{}.toml", id)); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} + +/// Load all environments from the given workspace's environments directory. +pub fn load_all_ws(ws_name: &str) -> Vec { + let dir = ws_data_dir(ws_name); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut envs = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("toml") { + continue; + } + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(env) = toml::from_str::(&content) { + envs.push(env); + } + } + } + envs +} diff --git a/src/storage/workspace.rs b/src/storage/workspace.rs index 6fd0e24..6b6f66a 100644 --- a/src/storage/workspace.rs +++ b/src/storage/workspace.rs @@ -1 +1,72 @@ -// Workspace TOML persistence +use std::path::PathBuf; + +use crate::state::workspace::{WorkspaceFile, WorkspaceState}; +use crate::storage::collection as col_storage; +use crate::storage::environment as env_storage; + +fn workspaces_dir() -> PathBuf { + let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + base.join("forge").join("workspaces") +} + +/// Return a sorted list of all workspace names (directory names under `workspaces/`). +/// Falls back to `["default"]` if the directory does not exist or is empty. +pub fn list_workspaces() -> Vec { + let dir = workspaces_dir(); + let Ok(entries) = std::fs::read_dir(&dir) else { + return vec!["default".to_string()]; + }; + let mut names: Vec = entries + .flatten() + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + if names.is_empty() { + names.push("default".to_string()); + } + names.sort(); + names +} + +/// Load the `workspace.toml` for `name`. Returns a default `WorkspaceFile` on any error. +pub fn load_workspace(name: &str) -> WorkspaceFile { + let path = workspaces_dir().join(name).join("workspace.toml"); + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(ws) = toml::from_str::(&content) { + return ws; + } + } + WorkspaceFile { + name: name.to_string(), + active_environment_idx: None, + } +} + +/// Persist the workspace file to disk, creating the directory if needed. +pub fn save_workspace(ws: &WorkspaceFile) -> anyhow::Result<()> { + let dir = workspaces_dir().join(&ws.name); + std::fs::create_dir_all(&dir)?; + let content = toml::to_string_pretty(ws)?; + std::fs::write(dir.join("workspace.toml"), content)?; + Ok(()) +} + +/// Load a `WorkspaceState` by name, including its collections and environments. +/// Open tabs start empty — they are not persisted. +pub fn load_workspace_full(name: &str) -> WorkspaceState { + let ws_file = load_workspace(name); + let collections = col_storage::load_all_collections(name); + let environments = env_storage::load_all_ws(name); + let active_environment_idx = ws_file.active_environment_idx + .filter(|&i| i < environments.len()) + .or_else(|| if environments.is_empty() { None } else { Some(0) }); + + WorkspaceState { + name: name.to_string(), + collections, + environments, + active_environment_idx, + open_tabs: Vec::new(), + active_tab_idx: 0, + } +} diff --git a/src/ui/confirm_delete.rs b/src/ui/confirm_delete.rs new file mode 100644 index 0000000..cf8038a --- /dev/null +++ b/src/ui/confirm_delete.rs @@ -0,0 +1,78 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::state::app_state::AppState; +use crate::ui::popup::centered_rect; + +const TEXT_MUTED: Color = Color::Rgb(86, 95, 137); +const TEXT_PRIMARY: Color = Color::Rgb(192, 202, 245); +const BG: Color = Color::Rgb(26, 27, 38); +const STATUS_ERR: Color = Color::Rgb(247, 118, 142); + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let popup_area = centered_rect(40, 20, area); + let popup_area = Rect { + height: popup_area.height.min(5).max(5), + ..popup_area + }; + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(STATUS_ERR)) + .title(" Confirm Delete ") + .style(Style::default().bg(BG)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + if inner.height < 3 { + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + // Message + let msg = &state.confirm_delete.message; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + msg.as_str(), + Style::default().fg(TEXT_PRIMARY), + ))), + chunks[0], + ); + + // Separator + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "─".repeat(inner.width as usize), + Style::default().fg(TEXT_MUTED), + ))), + chunks[1], + ); + + // Footer hints + let hint = Line::from(vec![ + Span::styled("y/Enter", Style::default().fg(STATUS_ERR)), + Span::styled(" Delete ", Style::default().fg(TEXT_MUTED)), + Span::styled("n/Esc", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" Cancel", Style::default().fg(TEXT_MUTED)), + ]); + frame.render_widget( + Paragraph::new(hint).style(Style::default().add_modifier(Modifier::DIM)), + chunks[2], + ); +} diff --git a/src/ui/env_editor.rs b/src/ui/env_editor.rs index 43b0ab5..7bbad8a 100644 --- a/src/ui/env_editor.rs +++ b/src/ui/env_editor.rs @@ -71,6 +71,7 @@ pub fn render_switcher(frame: &mut Frame, area: Rect, state: &AppState) { // Filtered environment list let filter = state.env_switcher.search.to_lowercase(); let envs_filtered: Vec<(usize, &str)> = state + .workspace .environments .iter() .enumerate() @@ -84,7 +85,7 @@ pub fn render_switcher(frame: &mut Frame, area: Rect, state: &AppState) { if y >= list_area.y + list_area.height { break; } - let is_active = state.active_env_idx == Some(orig_idx); + let is_active = state.workspace.active_environment_idx == Some(orig_idx); let is_selected = row == state.env_switcher.selected; let marker = if is_active { "● " } else { "○ " }; let marker_color = if is_active { Color::Rgb(158, 206, 106) } else { TEXT_MUTED }; @@ -134,7 +135,7 @@ pub fn render_editor(frame: &mut Frame, area: Rect, state: &AppState) { let popup_area = centered_rect(70, 70, area); frame.render_widget(Clear, popup_area); - let env = state.environments.get(state.env_editor.env_idx); + let env = state.workspace.environments.get(state.env_editor.env_idx); let env_name = env.map(|e| e.name.as_str()).unwrap_or("(none)"); let title = format!(" Environment: {} ", env_name); diff --git a/src/ui/layout.rs b/src/ui/layout.rs index f73ff80..4fe9023 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -6,9 +6,13 @@ use ratatui::{ use crate::state::app_state::{ActivePopup, ActiveTab, AppState}; use super::{ + confirm_delete, env_editor, + naming_popup, + request_tabs, sidebar, status_bar, + workspace_switcher, request::{ url_bar, tab_bar as req_tab_bar, headers_editor, body_editor, auth_editor, params_editor, scripts_editor, @@ -39,7 +43,7 @@ pub fn render(frame: &mut Frame, state: &AppState) { let right_area = if state.sidebar_visible { let horiz = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Length(24), Constraint::Min(0)]) + .constraints([Constraint::Length(28), Constraint::Min(0)]) .split(main_area); sidebar::render(frame, horiz[0], state); horiz[1] @@ -48,36 +52,47 @@ pub fn render(frame: &mut Frame, state: &AppState) { }; // Right panel vertical split - let editor_h = right_area.height.saturating_sub(3 + 1 + 1 + 1 + 1); - let editor_h = ((editor_h as u32 * 35 / 100) as u16).max(3); - let viewer_h = right_area - .height - .saturating_sub(3 + 1 + editor_h + 1 + 1); + // chunks[0] = open-tabs row (Length 1) + // chunks[1] = url bar (Length 3) + // chunks[2] = request tab bar (Length 1) + // chunks[3] = request editor (flexible) + // chunks[4] = response meta (Length 1) + // chunks[5] = response tab bar (Length 1) + // chunks[6] = response viewer (flexible) + let total_fixed: u16 = 1 + 3 + 1 + 1 + 1; // 7 rows fixed + let remaining = right_area.height.saturating_sub(total_fixed); + let editor_h = ((remaining as u32 * 35 / 100) as u16).max(3); + let viewer_h = remaining.saturating_sub(editor_h).max(3); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ + Constraint::Length(1), // open tabs bar Constraint::Length(3), // url bar Constraint::Length(1), // request tab bar - Constraint::Length(editor_h), // request editor (future) + Constraint::Length(editor_h), // request editor Constraint::Length(1), // response meta line Constraint::Length(1), // response tab bar Constraint::Min(viewer_h), // response viewer ]) .split(right_area); - url_bar::render(frame, chunks[0], state); - req_tab_bar::render(frame, chunks[1], state); - match state.active_tab { - ActiveTab::Headers => headers_editor::render(frame, chunks[2], state), - ActiveTab::Body => body_editor::render(frame, chunks[2], state), - ActiveTab::Auth => auth_editor::render(frame, chunks[2], state), - ActiveTab::Params => params_editor::render(frame, chunks[2], state), - ActiveTab::Scripts => scripts_editor::render(frame, chunks[2], state), + request_tabs::render(frame, chunks[0], state); + url_bar::render(frame, chunks[1], state); + req_tab_bar::render(frame, chunks[2], state); + + let active_tab = state.active_tab().map(|t| &t.active_tab); + match active_tab.unwrap_or(&ActiveTab::Headers) { + ActiveTab::Headers => headers_editor::render(frame, chunks[3], state), + ActiveTab::Body => body_editor::render(frame, chunks[3], state), + ActiveTab::Auth => auth_editor::render(frame, chunks[3], state), + ActiveTab::Params => params_editor::render(frame, chunks[3], state), + ActiveTab::Scripts => scripts_editor::render(frame, chunks[3], state), } - render_meta(frame, chunks[3], state); - resp_tab_bar::render(frame, chunks[4], state); - body_viewer::render(frame, chunks[5], state); + + render_meta(frame, chunks[4], state); + resp_tab_bar::render(frame, chunks[5], state); + body_viewer::render(frame, chunks[6], state); status_bar::render(frame, status_area, state); @@ -86,6 +101,9 @@ pub fn render(frame: &mut Frame, state: &AppState) { ActivePopup::None => {} ActivePopup::EnvSwitcher => env_editor::render_switcher(frame, area, state), ActivePopup::EnvEditor => env_editor::render_editor(frame, area, state), + ActivePopup::WorkspaceSwitcher => workspace_switcher::render(frame, area, state), + ActivePopup::CollectionNaming => naming_popup::render(frame, area, state), + ActivePopup::ConfirmDelete => confirm_delete::render(frame, area, state), } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e68ade2..5f46558 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,3 +7,7 @@ pub mod popup; pub mod highlight; pub mod request; pub mod response; +pub mod request_tabs; +pub mod naming_popup; +pub mod confirm_delete; +pub mod workspace_switcher; diff --git a/src/ui/naming_popup.rs b/src/ui/naming_popup.rs new file mode 100644 index 0000000..170f4f3 --- /dev/null +++ b/src/ui/naming_popup.rs @@ -0,0 +1,159 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::state::app_state::{AppState, NamingTarget}; +use crate::ui::layout::ACCENT_BLUE; +use crate::ui::popup::centered_rect; + +const TEXT_MUTED: Color = Color::Rgb(86, 95, 137); +const TEXT_PRIMARY: Color = Color::Rgb(192, 202, 245); +const BG: Color = Color::Rgb(26, 27, 38); + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let is_new_request = matches!(state.naming.target, NamingTarget::NewRequest { .. }); + + let popup_area = centered_rect(50, 30, area); + let popup_area = Rect { + height: if is_new_request { + popup_area.height.min(9).max(6) + } else { + popup_area.height.min(7).max(5) + }, + ..popup_area + }; + + frame.render_widget(Clear, popup_area); + + let title = match &state.naming.target { + NamingTarget::NewCollection => " New Collection ", + NamingTarget::NewFolder { .. } => " New Folder ", + NamingTarget::NewRequest { .. } => " New Request ", + NamingTarget::Rename { .. } => " Rename ", + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT_BLUE)) + .title(title) + .style(Style::default().bg(BG)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + if inner.height < 3 { + return; + } + + let constraints = if is_new_request { + vec![ + Constraint::Length(1), // input + Constraint::Length(1), // method row + Constraint::Length(1), // separator + Constraint::Length(1), // footer + ] + } else { + vec![ + Constraint::Min(1), // input + Constraint::Length(1), // separator + Constraint::Length(1), // footer + ] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + // Input field + let input = &state.naming.input; + let cursor = state.naming.cursor; + + let (before, cursor_char, after) = if cursor < input.len() { + let ch = input[cursor..].chars().next().unwrap_or(' '); + let next = cursor + ch.len_utf8(); + ( + input[..cursor].to_string(), + ch.to_string(), + input[next..].to_string(), + ) + } else { + (input.clone(), "_".to_string(), String::new()) + }; + + let input_line = Line::from(vec![ + Span::styled(before, Style::default().fg(TEXT_PRIMARY)), + Span::styled(cursor_char, Style::default().bg(Color::White).fg(Color::Black)), + Span::styled(after, Style::default().fg(TEXT_PRIMARY)), + ]); + + frame.render_widget(Paragraph::new(input_line), chunks[0]); + + // Set actual terminal cursor + let col_offset = input[..cursor.min(input.len())].chars().count() as u16; + frame.set_cursor_position(Position { + x: chunks[0].x + col_offset, + y: chunks[0].y, + }); + + if is_new_request { + // Method row + let method_line = Line::from(vec![ + Span::styled("◀ ", Style::default().fg(TEXT_MUTED)), + Span::styled( + state.naming.method.clone(), + Style::default().fg(ACCENT_BLUE).add_modifier(Modifier::BOLD), + ), + Span::styled(" ▶", Style::default().fg(TEXT_MUTED)), + ]); + frame.render_widget(Paragraph::new(method_line), chunks[1]); + + // Separator + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "─".repeat(inner.width as usize), + Style::default().fg(TEXT_MUTED), + ))), + chunks[2], + ); + + // Footer hints (with Tab method) + let hint = Line::from(vec![ + Span::styled("Enter", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" confirm ", Style::default().fg(TEXT_MUTED)), + Span::styled("Tab", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" method ", Style::default().fg(TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" cancel", Style::default().fg(TEXT_MUTED)), + ]); + frame.render_widget( + Paragraph::new(hint).style(Style::default().add_modifier(Modifier::DIM)), + chunks[3], + ); + } else { + // Separator + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "─".repeat(inner.width as usize), + Style::default().fg(TEXT_MUTED), + ))), + chunks[1], + ); + + // Footer hints + let hint = Line::from(vec![ + Span::styled("Enter", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" confirm ", Style::default().fg(TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" cancel", Style::default().fg(TEXT_MUTED)), + ]); + frame.render_widget( + Paragraph::new(hint).style(Style::default().add_modifier(Modifier::DIM)), + chunks[2], + ); + } +} diff --git a/src/ui/request/body_editor.rs b/src/ui/request/body_editor.rs index 3dcf398..8dd5c7d 100644 --- a/src/ui/request/body_editor.rs +++ b/src/ui/request/body_editor.rs @@ -34,14 +34,19 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } - let (text, lang) = match &state.request.body { + let Some(tab) = state.active_tab() else { + return; + }; + let request = &tab.request; + + let (text, lang) = match &request.body { RequestBody::Json(s) => (s.as_str(), "json"), RequestBody::Text(s) => (s.as_str(), "txt"), RequestBody::None | RequestBody::Form(_) | RequestBody::Binary(_) => ("", "json"), }; - let scroll = state.request.body_scroll_offset; - let cursor = state.request.body_cursor; + let scroll = request.body_scroll_offset; + let cursor = request.body_cursor; if text.is_empty() && state.mode != Mode::Insert { // Show placeholder when empty and not editing diff --git a/src/ui/request/headers_editor.rs b/src/ui/request/headers_editor.rs index b0dfb96..da722f5 100644 --- a/src/ui/request/headers_editor.rs +++ b/src/ui/request/headers_editor.rs @@ -52,8 +52,13 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { .style(Style::default().add_modifier(Modifier::DIM)); frame.render_widget(hint, hint_area); + let Some(tab) = state.active_tab() else { + return; + }; + let request = &tab.request; + // Placeholder when no headers - if state.request.headers.is_empty() { + if request.headers.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( "Press a to add a header", Style::default() @@ -72,10 +77,10 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let key_w = rest / 2; let val_w = rest - key_w; - let sel_row = state.request.headers_row; - let sel_col = state.request.headers_col; + let sel_row = request.headers_row; + let sel_col = request.headers_col; - for (i, pair) in state.request.headers.iter().enumerate() { + for (i, pair) in request.headers.iter().enumerate() { let row_y = body_area.y + i as u16; if row_y >= body_area.y + body_area.height { break; @@ -149,8 +154,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { // Cursor in Insert mode if focused && state.mode == Mode::Insert { - if let Some(pair) = state.request.headers.get(sel_row) { - let cursor = state.request.headers_cursor; + if let Some(pair) = request.headers.get(sel_row) { + let cursor = request.headers_cursor; let (cell_x, text) = if sel_col == 0 { (body_area.x + checkbox_w, pair.key.as_str()) } else { diff --git a/src/ui/request/tab_bar.rs b/src/ui/request/tab_bar.rs index 491830f..748972c 100644 --- a/src/ui/request/tab_bar.rs +++ b/src/ui/request/tab_bar.rs @@ -19,13 +19,14 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { ]; let tab_focused = state.focus == Focus::TabBar; + let active_tab = state.active_tab().map(|t| &t.active_tab); let mut spans: Vec> = Vec::new(); for (i, (name, tab)) in tabs.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); } - let is_active = *tab == state.active_tab; + let is_active = active_tab == Some(tab); let style = if is_active { Style::default() .fg(Color::Cyan) diff --git a/src/ui/request/url_bar.rs b/src/ui/request/url_bar.rs index 8e1c542..f7d2405 100644 --- a/src/ui/request/url_bar.rs +++ b/src/ui/request/url_bar.rs @@ -42,6 +42,13 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let inner = block.inner(area); frame.render_widget(block, area); + // Get request from active tab + let Some(tab) = state.active_tab() else { + return; + }; + let request = &tab.request; + let request_status = &tab.request_status; + // [method 9] [│] [url flex] [│] [send 8] let chunks = Layout::default() .direction(Direction::Horizontal) @@ -55,9 +62,9 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { .split(inner); // Method badge - let mc = method_color(&state.request.method); + let mc = method_color(&request.method); let method_para = Paragraph::new(Line::from(Span::styled( - state.request.method.as_str(), + request.method.as_str(), Style::default().fg(mc).add_modifier(Modifier::BOLD), ))); frame.render_widget(method_para, chunks[0]); @@ -70,7 +77,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { // URL input area — split vertically if there's room for ghost text let url_area = chunks[2]; - let has_vars = !parse_vars(&state.request.url).is_empty(); + let has_vars = !parse_vars(&request.url).is_empty(); if url_area.height >= 2 && has_vars { let url_chunks = Layout::default() .direction(Direction::Vertical) @@ -80,7 +87,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { frame.render_widget(Paragraph::new(url_line), url_chunks[0]); // Ghost resolved text let resolver = resolver_from_state(state); - let resolved = resolver.resolve_for_send(&state.request.url); + let resolved = resolver.resolve_for_send(&request.url); let ghost_line = Line::from(vec![ Span::styled("→ ", Style::default().fg(TEXT_MUTED)), Span::styled(resolved, Style::default().fg(TEXT_MUTED)), @@ -99,7 +106,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { // Send button — rendered per-branch to avoid a heap allocation for the // common idle case where the label is a &'static str. - match &state.request_status { + match request_status { RequestStatus::Loading { spinner_tick } => { let idx = (*spinner_tick as usize) % SPINNER_FRAMES.len(); frame.render_widget( @@ -123,8 +130,14 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { } fn build_url_line(state: &AppState, focused: bool) -> Line<'static> { - let url = &state.request.url; - let cursor = state.request.url_cursor; + let Some(tab) = state.active_tab() else { + return Line::from(Span::styled( + "No active tab", + Style::default().fg(Color::Rgb(65, 72, 104)), + )); + }; + let url = &tab.request.url; + let cursor = tab.request.url_cursor; if url.is_empty() { return Line::from(Span::styled( diff --git a/src/ui/request_tabs.rs b/src/ui/request_tabs.rs new file mode 100644 index 0000000..9e38736 --- /dev/null +++ b/src/ui/request_tabs.rs @@ -0,0 +1,64 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::state::app_state::AppState; +use crate::state::focus::Focus; +use crate::ui::layout::ACCENT_BLUE; + +const TEXT_MUTED: Color = Color::Rgb(86, 95, 137); +const TEXT_PRIMARY: Color = Color::Rgb(192, 202, 245); + +/// Render the open-tabs bar (1 row height) showing all open request tabs. +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + if state.workspace.open_tabs.is_empty() { + let hint = Paragraph::new(Line::from(Span::styled( + "No open tabs", + Style::default().fg(TEXT_MUTED).add_modifier(Modifier::DIM), + ))); + frame.render_widget(hint, area); + return; + } + + let mut spans: Vec> = Vec::new(); + + let tabs_focused = matches!(state.focus, Focus::RequestTabs); + + for (i, tab) in state.workspace.open_tabs.iter().enumerate() { + let is_active = i == state.workspace.active_tab_idx; + let method = tab.request.method.as_str(); + let name = if tab.request.name.is_empty() { + "Untitled".to_string() + } else { + tab.request.name.clone() + }; + let dirty = if tab.is_dirty { "*" } else { "" }; + + let tab_label = format!(" {} {}{} ", method, name, dirty); + + let style = if is_active && tabs_focused { + Style::default() + .fg(Color::Black) + .bg(Color::White) + .add_modifier(Modifier::BOLD) + } else if is_active { + Style::default() + .fg(ACCENT_BLUE) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + if i > 0 { + spans.push(Span::styled(" │ ", Style::default().fg(TEXT_MUTED))); + } + spans.push(Span::styled(tab_label, style)); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} diff --git a/src/ui/response/body_viewer.rs b/src/ui/response/body_viewer.rs index 4bee34a..9456780 100644 --- a/src/ui/response/body_viewer.rs +++ b/src/ui/response/body_viewer.rs @@ -16,8 +16,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let focused = matches!(state.focus, Focus::ResponseViewer); let border_color = if focused { ACCENT_BLUE } else { BORDER_INACTIVE }; - match &state.request_status { - RequestStatus::Loading { spinner_tick } => { + let request_status = state.active_tab().map(|t| &t.request_status); + let response = state.active_tab().and_then(|t| t.response.as_ref()); + + match request_status { + Some(RequestStatus::Loading { spinner_tick }) => { let idx = (*spinner_tick as usize) % SPINNER_FRAMES.len(); let text = Line::from(vec![ Span::styled( @@ -31,15 +34,16 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { ]); frame.render_widget(Paragraph::new(text), area); } - RequestStatus::Error(msg) => { + Some(RequestStatus::Error(msg)) => { + let msg = msg.clone(); let text = Line::from(Span::styled( format!(" Error: {}", msg), Style::default().fg(Color::Red), )); frame.render_widget(Paragraph::new(text), area); } - RequestStatus::Idle => { - match &state.response { + Some(RequestStatus::Idle) | None => { + match response { None => { let hint = Paragraph::new(Line::from(Span::styled( " Send a request to see the response", @@ -85,7 +89,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { } pub fn render_meta(frame: &mut Frame, area: Rect, state: &AppState) { - let line = match &state.response { + let response = state.active_tab().and_then(|t| t.response.as_ref()); + let line = match response { None => Line::from(Span::styled("─", Style::default().fg(BORDER_INACTIVE))), Some(resp) => { let status_color = match resp.status { @@ -112,4 +117,3 @@ pub fn render_meta(frame: &mut Frame, area: Rect, state: &AppState) { }; frame.render_widget(Paragraph::new(line), area); } - diff --git a/src/ui/response/tab_bar.rs b/src/ui/response/tab_bar.rs index b9ad8e9..8105543 100644 --- a/src/ui/response/tab_bar.rs +++ b/src/ui/response/tab_bar.rs @@ -16,12 +16,14 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { ("Timing", ResponseTab::Timing), ]; + let response_tab = state.active_tab().map(|t| &t.response_tab); + let mut spans: Vec> = Vec::new(); for (i, (name, tab)) in tabs.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); } - let style = if *tab == state.response_tab { + let style = if response_tab == Some(tab) { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::UNDERLINED) diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 5b07104..022440c 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,23 +1,280 @@ use ratatui::{ Frame, - layout::Rect, - style::Style, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; use crate::state::app_state::AppState; +use crate::state::collection::CollectionItem; use crate::state::focus::Focus; use super::layout::{ACCENT_BLUE, BORDER_INACTIVE}; +const TEXT_MUTED: Color = Color::Rgb(86, 95, 137); +const TEXT_PRIMARY: Color = Color::Rgb(192, 202, 245); +const SURFACE: Color = Color::Rgb(36, 40, 59); + +// ─── Flat tree model ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum NodeKind { + Collection { collapsed: bool }, + Folder { collapsed: bool }, + Request { method: String }, +} + +#[derive(Debug, Clone)] +pub struct SidebarNode { + pub depth: u16, + pub kind: NodeKind, + pub id: String, + pub label: String, +} + +/// Walk the workspace collections and produce a flat ordered list of visible nodes. +/// Collapsed collections/folders hide their children. +/// If `search_query` is non-empty, only nodes whose label contains the query are shown +/// (search ignores collapse state — all matching items are visible). +pub fn flatten_tree(state: &AppState) -> Vec { + let mut out = Vec::new(); + let query = state.sidebar.search_query.to_lowercase(); + let searching = state.sidebar.search_mode && !query.is_empty(); + + for col in &state.workspace.collections { + let collapsed = state.sidebar.collapsed_ids.contains(&col.id); + + if !searching { + out.push(SidebarNode { + depth: 0, + kind: NodeKind::Collection { collapsed }, + id: col.id.clone(), + label: col.name.clone(), + }); + } + + let col_match = searching && col.name.to_lowercase().contains(&query); + if col_match { + out.push(SidebarNode { + depth: 0, + kind: NodeKind::Collection { collapsed: false }, + id: col.id.clone(), + label: col.name.clone(), + }); + } + + // Show children if: not searching + not collapsed, OR searching + if !collapsed || searching { + push_items(&col.items, 1, &mut out, state, &query, searching); + } + } + + out +} + +fn push_items( + items: &[CollectionItem], + depth: u16, + out: &mut Vec, + state: &AppState, + query: &str, + searching: bool, +) { + for item in items { + match item { + CollectionItem::Folder(f) => { + let collapsed = state.sidebar.collapsed_ids.contains(&f.id); + let folder_match = searching && f.name.to_lowercase().contains(query); + + if !searching || folder_match { + out.push(SidebarNode { + depth, + kind: NodeKind::Folder { + collapsed: if searching { false } else { collapsed }, + }, + id: f.id.clone(), + label: f.name.clone(), + }); + } + + if !collapsed || searching { + push_items(&f.items, depth + 1, out, state, query, searching); + } + } + CollectionItem::Request(r) => { + if searching && !r.name.to_lowercase().contains(query) { + continue; + } + out.push(SidebarNode { + depth, + kind: NodeKind::Request { + method: r.method.clone(), + }, + id: r.id.clone(), + label: r.name.clone(), + }); + } + } + } +} + +fn method_badge_color(method: &str) -> Color { + match method { + "GET" => Color::Rgb(115, 218, 202), + "POST" => Color::Rgb(158, 206, 106), + "PUT" => Color::Rgb(224, 175, 104), + "PATCH" => Color::Rgb(187, 154, 247), + "DELETE" => Color::Rgb(247, 118, 142), + "HEAD" | "OPTIONS" => Color::Rgb(86, 95, 137), + _ => Color::White, + } +} + +// ─── Render ────────────────────────────────────────────────────────────────── + pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let focused = matches!(state.focus, Focus::Sidebar); let border_color = if focused { ACCENT_BLUE } else { BORDER_INACTIVE }; let block = Block::default() - .title("forge ⚒") + .title(" forge ") .borders(Borders::ALL) .border_style(Style::default().fg(border_color)); - let p = Paragraph::new("Collections (Round 3)").block(block); - frame.render_widget(p, area); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < 3 || inner.height < 2 { + return; + } + + let nodes = flatten_tree(state); + + // Always reserve the last 1 row for the footer (hints or search bar) + let (list_area, footer_area) = if inner.height < 3 { + (inner, None) + } else { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(inner); + (chunks[0], Some(chunks[1])) + }; + + // Empty state + if nodes.is_empty() && !state.sidebar.search_mode { + let hint = Paragraph::new(Line::from(Span::styled( + "Ctrl+n: new collection", + Style::default().fg(TEXT_MUTED).add_modifier(Modifier::DIM), + ))); + frame.render_widget(hint, list_area); + } else if nodes.is_empty() { + let hint = Paragraph::new(Line::from(Span::styled( + "No results", + Style::default().fg(TEXT_MUTED).add_modifier(Modifier::DIM), + ))); + frame.render_widget(hint, list_area); + } else { + let scroll = state.sidebar.scroll_offset; + let visible_nodes = nodes.iter().skip(scroll); + + for (i, node) in visible_nodes.enumerate() { + let y = list_area.y + i as u16; + if y >= list_area.y + list_area.height { + break; + } + let abs_idx = i + scroll; + let is_cursor = abs_idx == state.sidebar.cursor; + let row_bg = if is_cursor { SURFACE } else { Color::Reset }; + let row_area = Rect { y, height: 1, ..list_area }; + + let indent = " ".repeat(node.depth as usize); + let line = match &node.kind { + NodeKind::Collection { collapsed } => { + let arrow = if *collapsed { "▶ " } else { "▼ " }; + let label_style = if is_cursor { + Style::default() + .fg(Color::White) + .bg(row_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TEXT_PRIMARY).bg(row_bg).add_modifier(Modifier::BOLD) + }; + Line::from(vec![ + Span::styled( + format!("{}{}", indent, arrow), + Style::default().fg(ACCENT_BLUE).bg(row_bg), + ), + Span::styled(node.label.clone(), label_style), + ]) + } + NodeKind::Folder { collapsed } => { + let arrow = if *collapsed { "▶ " } else { "▼ " }; + let label_style = if is_cursor { + Style::default().fg(Color::White).bg(row_bg) + } else { + Style::default().fg(TEXT_PRIMARY).bg(row_bg) + }; + Line::from(vec![ + Span::styled( + format!("{}{}", indent, arrow), + Style::default().fg(TEXT_MUTED).bg(row_bg), + ), + Span::styled(node.label.clone(), label_style), + ]) + } + NodeKind::Request { method } => { + let color = method_badge_color(method); + let method_display = format!("{:<6} ", method); + let label_style = if is_cursor { + Style::default().fg(Color::White).bg(row_bg) + } else { + Style::default().fg(TEXT_PRIMARY).bg(row_bg) + }; + Line::from(vec![ + Span::styled( + format!("{} ", indent), + Style::default().bg(row_bg), + ), + Span::styled( + method_display, + Style::default().fg(color).bg(row_bg).add_modifier(Modifier::BOLD), + ), + Span::styled(node.label.clone(), label_style), + ]) + } + }; + + frame.render_widget(Paragraph::new(line), row_area); + } + } + + // Footer: search bar when searching, otherwise key hints + if let Some(fa) = footer_area { + if state.sidebar.search_mode { + let search_line = Line::from(vec![ + Span::styled("/ ", Style::default().fg(ACCENT_BLUE)), + Span::styled( + state.sidebar.search_query.clone(), + Style::default().fg(TEXT_PRIMARY), + ), + ]); + frame.render_widget(Paragraph::new(search_line), fa); + } else { + let hints = Line::from(vec![ + Span::styled("^n", Style::default().fg(ACCENT_BLUE)), + Span::styled(" col ", Style::default().fg(TEXT_MUTED)), + Span::styled("n", Style::default().fg(ACCENT_BLUE)), + Span::styled(" req ", Style::default().fg(TEXT_MUTED)), + Span::styled("d", Style::default().fg(ACCENT_BLUE)), + Span::styled(" del ", Style::default().fg(TEXT_MUTED)), + Span::styled("/", Style::default().fg(ACCENT_BLUE)), + Span::styled(" search", Style::default().fg(TEXT_MUTED)), + ]); + frame.render_widget( + Paragraph::new(hints).style(Style::default().add_modifier(Modifier::DIM)), + fa, + ); + } + } } diff --git a/src/ui/workspace_switcher.rs b/src/ui/workspace_switcher.rs new file mode 100644 index 0000000..76bae04 --- /dev/null +++ b/src/ui/workspace_switcher.rs @@ -0,0 +1,132 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::state::app_state::AppState; +use crate::ui::layout::ACCENT_BLUE; +use crate::ui::popup::centered_rect; + +const TEXT_MUTED: Color = Color::Rgb(86, 95, 137); +const TEXT_PRIMARY: Color = Color::Rgb(192, 202, 245); +const SURFACE: Color = Color::Rgb(36, 40, 59); +const BG: Color = Color::Rgb(26, 27, 38); + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let popup_area = centered_rect(50, 40, area); + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT_BLUE)) + .title(" Workspaces (Ctrl+W) ") + .style(Style::default().bg(BG)); + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + if inner.height < 3 { + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(inner); + + // Search / naming row + if state.ws_switcher.naming { + let new_name = &state.ws_switcher.new_name; + let name_line = Line::from(vec![ + Span::styled("Name: ", Style::default().fg(TEXT_MUTED)), + Span::styled(new_name.clone(), Style::default().fg(TEXT_PRIMARY)), + ]); + frame.render_widget(Paragraph::new(name_line), chunks[0]); + let col_offset = new_name[..state.ws_switcher.new_name_cursor.min(new_name.len())] + .chars() + .count() as u16; + frame.set_cursor_position(Position { + x: chunks[0].x + 6 + col_offset, + y: chunks[0].y, + }); + } else { + let search = &state.ws_switcher.search; + let search_line = if search.is_empty() { + Line::from(Span::styled("Search…", Style::default().fg(TEXT_MUTED))) + } else { + Line::from(vec![ + Span::styled("/ ", Style::default().fg(ACCENT_BLUE)), + Span::raw(search.clone()), + ]) + }; + frame.render_widget(Paragraph::new(search_line), chunks[0]); + } + + // Workspace list (filtered) + let filter = state.ws_switcher.search.to_lowercase(); + let filtered: Vec<&str> = state + .all_workspaces + .iter() + .filter(|w| filter.is_empty() || w.to_lowercase().contains(&filter)) + .map(|w| w.as_str()) + .collect(); + + let list_area = chunks[1]; + for (row, &name) in filtered.iter().enumerate() { + let y = list_area.y + row as u16; + if y >= list_area.y + list_area.height { + break; + } + let is_active = name == state.workspace.name; + let is_selected = row == state.ws_switcher.selected; + let marker = if is_active { "● " } else { "○ " }; + let marker_color = if is_active { + Color::Rgb(158, 206, 106) + } else { + TEXT_MUTED + }; + let name_style = if is_selected { + Style::default() + .fg(Color::White) + .bg(SURFACE) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + let row_area = Rect { y, height: 1, ..list_area }; + let line = Line::from(vec![ + Span::styled(marker, Style::default().fg(marker_color)), + Span::styled(name, name_style), + ]); + frame.render_widget(Paragraph::new(line), row_area); + } + + // Hint bar + let hint = if state.ws_switcher.naming { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" confirm ", Style::default().fg(TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" cancel", Style::default().fg(TEXT_MUTED)), + ]) + } else { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" switch ", Style::default().fg(TEXT_MUTED)), + Span::styled("Alt+n", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" new ", Style::default().fg(TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(TEXT_PRIMARY)), + Span::styled(" close", Style::default().fg(TEXT_MUTED)), + ]) + }; + frame.render_widget( + Paragraph::new(hint).style(Style::default().add_modifier(Modifier::DIM)), + chunks[2], + ); +}