diff --git a/.gitignore b/.gitignore index 39159a1..405ea14 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ Cargo.lock .knock/ *.local.toml +docs/.DS_Store +.DS_Store diff --git a/Cargo.toml b/Cargo.toml index fdda5d0..4ec1533 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/knock-core", "crates/knock-cli", "apps/knock-app/src-tauri"] +members = ["crates/knock-core", "crates/knock-cli", "crates/knock-mock", "crates/knock-mock-adapter", "apps/knock-app/src-tauri"] [workspace.package] version = "0.0.11" diff --git a/README.md b/README.md index 17aaa0e..3146136 100644 --- a/README.md +++ b/README.md @@ -5,146 +5,97 @@ HTTP client + workspace toolkit. Ships as a CLI (`knock`) and a Tauri desktop app (`Knock`). -Workspace layout: - -``` -apps/knock-app Tauri 2 desktop app (React + Vite frontend) -crates/knock-cli knock CLI binary -crates/knock-core Shared core library -``` +https://github.com/user-attachments/assets/040faf8b-1b0e-4e9a-8083-8d787347ab30 ## Install -### Quick install (Linux / macOS — CLI) +Pick your target and OS. Installers fetch the latest release, verify SHA256, and (on macOS) strip the Gatekeeper quarantine attribute. -One-liner. Downloads the latest release for your OS/arch, places `knock` in `~/.local/bin`, and (after confirming) strips the macOS Gatekeeper quarantine attribute so the unsigned binary can run. +| Target | OS | Command | +|---------|-----------------|--------------------------------------------------------------------------------------------------------| +| CLI | macOS / Linux | `curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.sh \| bash` | +| CLI | Windows | `iwr https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.ps1 \| iex` | +| Desktop | macOS / Linux | `curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.sh \| bash` | +| Desktop | Windows | `iwr https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.ps1 \| iex` | -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.sh | bash -``` +Install locations: CLI → `~/.local/bin` (unix) or `%LOCALAPPDATA%\Knock\bin` (Windows). Desktop → `/Applications`, `~/Applications`, or system path per OS. -Pin a version: +If `~/.local/bin` is not in `PATH`: `export PATH="$HOME/.local/bin:$PATH"`. On Windows, open a new terminal for PATH to refresh. -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.sh | bash -s -- --version v0.1.0 -``` +
+Advanced options — pin version, custom prefix, Linux format, silent install, SHA override -Custom prefix: +**Pin a version** -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.sh | KNOCK_PREFIX=/usr/local/bin bash -``` +| Platform | Command | +|------------|----------------------------------------------------------------------------------------------------------------------------------| +| Unix | `curl -fsSL .../install.sh \| bash -s -- --version v0.1.0` | +| PowerShell | `$env:KNOCK_VERSION = "v0.1.0"; iwr .../install.ps1 \| iex` | -If `~/.local/bin` is not in your `PATH`, add it: +**Other knobs** -```bash -export PATH="$HOME/.local/bin:$PATH" -``` +| Knob | Where | Example | +|----------------------------|-----------------|----------------------------------------------------------------------------------------| +| Custom CLI prefix | Unix env | `KNOCK_PREFIX=/usr/local/bin` | +| Linux desktop format | Unix env | `KNOCK_APP_FORMAT=deb` (also `rpm`; default `AppImage`) | +| Windows silent install | PowerShell env | `$env:KNOCK_SILENT = "1"` | +| Pin SHA256 manually | Unix env | `KNOCK_SHA256=` | +| Skip verification (unsafe) | Unix env | `KNOCK_SKIP_VERIFY=1` | -### Quick install (Windows — CLI) +
-PowerShell. Installs to `%LOCALAPPDATA%\Knock\bin` and adds it to user PATH. - -```powershell -iwr https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.ps1 | iex -``` - -Pin a version: - -```powershell -$env:KNOCK_VERSION = "v0.1.0" -iwr https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.ps1 | iex -``` - -Open a new terminal after install for the PATH update to apply. - -The installer verifies the asset's SHA256 against the `.sha256` sidecar published by the release workflow. If the file is missing or the hash does not match, installation aborts. Pin manually: - -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install.sh \ - | KNOCK_SHA256= bash -s -- --version v0.1.0 -``` - -Override (not recommended): `KNOCK_SKIP_VERIFY=1`. - -### Manual download +
+Manual download — verify SHA256, bypass Gatekeeper Grab assets from [Releases](https://github.com/jairoFernandez/knock/releases). Naming: ``` -knock---. -knock---..sha256 +knock---. # CLI +Knock__. # Desktop os: macos | linux | windows arch: x86_64 | aarch64 -ext: tar.gz (unix) | zip (windows) +ext: tar.gz (unix) | zip (windows) | dmg | AppImage | deb | rpm | msi ``` -Verify before extracting: +Verify: ```bash -# Linux -sha256sum -c knock-0.1.0-linux-x86_64.tar.gz.sha256 -# macOS -shasum -a 256 -c knock-0.1.0-macos-aarch64.tar.gz.sha256 +sha256sum -c knock-0.1.0-linux-x86_64.tar.gz.sha256 # Linux +shasum -a 256 -c knock-0.1.0-macos-aarch64.tar.gz.sha256 # macOS ``` -On macOS, after extracting: +macOS unsigned binary/bundle — strip quarantine: ```bash -xattr -dr com.apple.quarantine ./knock +xattr -dr com.apple.quarantine ./knock # CLI +xattr -dr com.apple.quarantine "/Applications/Knock.app" # Desktop ``` -### Desktop app — Quick install (Linux / macOS) +
-One-liner. Resolves latest release, downloads the right asset for your OS/arch, installs it, and (after confirming) strips macOS Gatekeeper quarantine. - -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.sh | bash -``` - -Pin a version: - -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.sh | bash -s -- --version v0.1.0 -``` +## Sponsor -Linux: defaults to `.AppImage`. Switch to `.deb` or `.rpm` (require sudo): +Knock is built in spare time. If it saves you some, consider chipping in: -```bash -curl -fsSL https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.sh | KNOCK_APP_FORMAT=deb bash -``` +- ☕ [Buy Me A Coffee](https://buymeacoffee.com/jairofernandez) +- 💖 [GitHub Sponsors](https://github.com/sponsors/jairoFernandez) -### Desktop app — Quick install (Windows) +## Development -PowerShell. Downloads `.msi` and runs it. +
+Workspace layout -```powershell -iwr https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.ps1 | iex ``` - -Silent install: - -```powershell -$env:KNOCK_SILENT = "1" -iwr https://raw.githubusercontent.com/jairoFernandez/knock/main/scripts/install-app.ps1 | iex +apps/knock-app Tauri 2 desktop app (React + Vite frontend) +crates/knock-cli knock CLI binary +crates/knock-core Shared core library ``` -### Desktop app — Manual download - -Download the installer for your platform from [Releases](https://github.com/jairoFernandez/knock/releases): - -- macOS: `Knock__aarch64.dmg` / `Knock__x64.dmg` -- Linux: `.AppImage` / `.deb` / `.rpm` (x86_64) -- Windows: `.msi` / `setup.exe` (x86_64) - -macOS unsigned bundle — bypass Gatekeeper: - -```bash -xattr -dr com.apple.quarantine "/Applications/Knock.app" -``` +
-## Build from source +
+Build from source Prereqs: Rust (stable), Node 20+, pnpm 9+. Linux desktop build also needs `libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev`. @@ -165,7 +116,10 @@ make cross TARGET=aarch64-apple-darwin make package-cross TARGET=aarch64-apple-darwin OS=macos ARCH=aarch64 ``` -## Release +
+ +
+Release Tag-driven. Pushing a `v*` tag triggers `.github/workflows/release.yml`, which: @@ -190,9 +144,4 @@ git push origin v0.2.0 Manual trigger is also available via the workflow's `workflow_dispatch` input. -## Sponsor - -Knock is built in spare time. If it saves you some, consider chipping in: - -- ☕ [Buy Me A Coffee](https://buymeacoffee.com/jairofernandez) -- 💖 [GitHub Sponsors](https://github.com/sponsors/jairoFernandez) +
diff --git a/apps/knock-app/src-tauri/Cargo.toml b/apps/knock-app/src-tauri/Cargo.toml index ae7a74e..929868a 100644 --- a/apps/knock-app/src-tauri/Cargo.toml +++ b/apps/knock-app/src-tauri/Cargo.toml @@ -13,6 +13,8 @@ tauri-build = { version = "2", features = [] } [dependencies] knock-core = { path = "../../../crates/knock-core" } +knock-mock = { path = "../../../crates/knock-mock" } +knock-mock-adapter = { path = "../../../crates/knock-mock-adapter" } tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" serde = { workspace = true } diff --git a/apps/knock-app/src-tauri/src/lib.rs b/apps/knock-app/src-tauri/src/lib.rs index 28ba87a..8b608a0 100644 --- a/apps/knock-app/src-tauri/src/lib.rs +++ b/apps/knock-app/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; mod kubeconfig_cmd; +mod mock_cmd; mod openapi_cmd; mod recents; mod terminal_cmd; @@ -9,6 +10,7 @@ use std::time::{Duration, SystemTime}; use tauri::{Manager, RunEvent}; use kubeconfig_cmd::TempCache; +use mock_cmd::MockState; use terminal_cmd::TerminalSessions; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -17,6 +19,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .manage(TempCache::default()) .manage(TerminalSessions::default()) + .manage(MockState::default()) .setup(|app| { sweep_stale_tmp_files(); #[cfg(target_os = "macos")] @@ -106,6 +109,13 @@ pub fn run() { updates_cmd::download_app_update, updates_cmd::install_app_update, updates_cmd::run_app_installer, + mock_cmd::mock_status, + mock_cmd::mock_preview, + mock_cmd::mock_start, + mock_cmd::mock_stop, + mock_cmd::mock_read_response, + mock_cmd::mock_save_response, + mock_cmd::mock_clear_response, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/apps/knock-app/src-tauri/src/mock_cmd.rs b/apps/knock-app/src-tauri/src/mock_cmd.rs new file mode 100644 index 0000000..bda404a --- /dev/null +++ b/apps/knock-app/src-tauri/src/mock_cmd.rs @@ -0,0 +1,368 @@ +use indexmap::IndexMap; +use knock_core::{parser, MockBody, RequestMock, Workspace}; +use knock_mock::{serve_with_logger, Handle, LogEntry}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tauri::{Emitter, State}; + +pub const LOG_EVENT: &str = "mock://log"; +pub const STATUS_EVENT: &str = "mock://status"; + +#[derive(Default)] +pub struct MockState { + inner: Mutex>, +} + +struct RunningMock { + workspace_root: PathBuf, + addr: SocketAddr, + started_at: u128, + route_count: usize, + handle: Option, +} + +#[derive(Serialize, Clone)] +pub struct MockStatus { + pub running: bool, + pub workspace_root: Option, + pub addr: Option, + pub route_count: Option, + pub started_at: Option, +} + +#[derive(Serialize)] +pub struct MockRouteSummary { + pub method: String, + pub path: String, + pub auth: Option, + pub status: u16, + pub request_rel: String, + /// "inline" | "sibling" | "openapi" | "stub" | "empty" + pub source: String, +} + +#[derive(Serialize)] +pub struct MockSpecPreview { + pub routes: Vec, + pub auth_schemes: Vec, + pub default_delay_ms: Option, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct MockResponseEdit { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub headers: std::collections::BTreeMap, + #[serde(default)] + pub auth: Option, + #[serde(default)] + pub delay_ms: Option, + /// raw JSON string the user typed + #[serde(default)] + pub body_json: Option, + /// raw text body (used when body_json is empty) + #[serde(default)] + pub body_text: Option, +} + +#[derive(Serialize)] +pub struct MockResponseRead { + pub origin: String, // "inline" | "sibling" | "none" + pub edit: MockResponseEdit, +} + +#[tauri::command] +pub fn mock_status(state: State<'_, MockState>) -> MockStatus { + let g = state.inner.lock().unwrap(); + match &*g { + Some(r) => MockStatus { + running: true, + workspace_root: Some(r.workspace_root.display().to_string()), + addr: Some(r.addr.to_string()), + route_count: Some(r.route_count), + started_at: Some(r.started_at), + }, + None => MockStatus { + running: false, + workspace_root: None, + addr: None, + route_count: None, + started_at: None, + }, + } +} + +#[tauri::command] +pub fn mock_preview(workspace_root: String) -> Result { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let out = knock_mock_adapter::build_with_origins(&ws).map_err(|e| e.to_string())?; + let routes = out + .spec + .routes + .iter() + .zip(out.origins.iter()) + .map(|(r, o)| MockRouteSummary { + method: r.method.clone(), + path: r.path.clone(), + auth: r.auth.clone(), + status: r.response.status, + request_rel: o.request_rel.clone(), + source: source_str(o.source).into(), + }) + .collect(); + let auth_schemes = out.spec.auth.iter().map(|a| a.name().to_string()).collect(); + Ok(MockSpecPreview { + routes, + auth_schemes, + default_delay_ms: out.spec.default_delay_ms, + }) +} + +fn source_str(s: knock_mock_adapter::BodySource) -> &'static str { + use knock_mock_adapter::BodySource as B; + match s { + B::Inline => "inline", + B::Sibling => "sibling", + B::OpenApi => "openapi", + B::Stub => "stub", + B::Empty => "empty", + } +} + +#[tauri::command] +pub fn mock_read_response( + workspace_root: String, + request_rel: String, +) -> Result { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let req_path = ws.requests_dir().join(&request_rel); + let req = parser::parse_request(&req_path) + .map_err(|e| format!("parse {}: {e}", req_path.display()))?; + if let Some(m) = req.mock { + return Ok(MockResponseRead { + origin: "inline".into(), + edit: edit_from_mock(&m), + }); + } + let sib = sibling_path(&ws, &request_rel); + if sib.is_file() { + let m = parser::parse_request_mock(&sib) + .map_err(|e| format!("parse {}: {e}", sib.display()))?; + return Ok(MockResponseRead { + origin: "sibling".into(), + edit: edit_from_mock(&m), + }); + } + Ok(MockResponseRead { + origin: "none".into(), + edit: MockResponseEdit::default(), + }) +} + +#[tauri::command] +pub fn mock_save_response( + workspace_root: String, + request_rel: String, + edit: MockResponseEdit, +) -> Result<(), String> { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let mock = mock_from_edit(&edit).map_err(|e| e.to_string())?; + let sib = sibling_path(&ws, &request_rel); + if let Some(parent) = sib.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let toml_str = toml::to_string_pretty(&mock).map_err(|e| e.to_string())?; + std::fs::write(&sib, toml_str).map_err(|e| format!("write {}: {e}", sib.display()))?; + Ok(()) +} + +#[tauri::command] +pub fn mock_clear_response(workspace_root: String, request_rel: String) -> Result<(), String> { + let ws = Workspace::discover(&PathBuf::from(&workspace_root)).map_err(|e| e.to_string())?; + let sib = sibling_path(&ws, &request_rel); + if sib.is_file() { + std::fs::remove_file(&sib).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn sibling_path(ws: &Workspace, rel: &str) -> PathBuf { + let stem = Path::new(rel).with_extension(""); + ws.root.join("mocks").join(stem).with_extension("toml") +} + +fn edit_from_mock(m: &RequestMock) -> MockResponseEdit { + let mut headers = std::collections::BTreeMap::new(); + for (k, v) in &m.headers { + headers.insert(k.clone(), v.clone()); + } + let ct_is_json = headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.contains("json")); + let (body_json, body_text) = match &m.body { + Some(b) => { + if let Some(j) = &b.json { + let s = serde_json::to_string_pretty(j).ok(); + (s, None) + } else if let Some(t) = &b.text { + // If content-type is JSON, surface this as JSON in the editor + // so the user sees a pretty-printed form. Falls back to text. + if ct_is_json { + match serde_json::from_str::(t) { + Ok(v) => (serde_json::to_string_pretty(&v).ok(), None), + Err(_) => (None, Some(t.clone())), + } + } else { + (None, Some(t.clone())) + } + } else { + (None, None) + } + } + None => (None, None), + }; + MockResponseEdit { + status: m.status, + headers, + auth: m.auth.clone(), + delay_ms: m.delay_ms, + body_json, + body_text, + } +} + +fn mock_from_edit(e: &MockResponseEdit) -> anyhow::Result { + let mut headers = IndexMap::new(); + for (k, v) in &e.headers { + headers.insert(k.clone(), v.clone()); + } + let body = match ( + e.body_json.as_ref().filter(|s| !s.trim().is_empty()), + e.body_text.as_ref().filter(|s| !s.is_empty()), + ) { + (Some(j), _) => { + // validate JSON before saving so the user gets immediate feedback + let value: serde_json::Value = + serde_json::from_str(j).map_err(|e| anyhow::anyhow!("invalid JSON: {e}"))?; + if !headers + .keys() + .any(|k| k.eq_ignore_ascii_case("content-type")) + { + headers.insert("content-type".into(), "application/json".into()); + } + // store as text to keep TOML clean and avoid toml::Value + // limitations (no null, no mixed-type arrays). The compact + // JSON form keeps the request file readable. + Some(MockBody { + text: Some(serde_json::to_string(&value)?), + json: None, + file: None, + }) + } + (None, Some(t)) => Some(MockBody { + json: None, + text: Some(t.clone()), + file: None, + }), + (None, None) => None, + }; + Ok(RequestMock { + status: e.status, + headers, + delay_ms: e.delay_ms, + auth: e.auth.clone(), + body, + }) +} + +#[tauri::command] +pub async fn mock_start( + app: tauri::AppHandle, + state: State<'_, MockState>, + workspace_root: String, + bind: Option, + port: Option, +) -> Result { + { + let g = state.inner.lock().unwrap(); + if g.is_some() { + return Err("mock server already running".into()); + } + } + + let root_pb = PathBuf::from(&workspace_root); + let ws = Workspace::discover(&root_pb).map_err(|e| e.to_string())?; + let spec = knock_mock_adapter::build(&ws).map_err(|e| e.to_string())?; + let route_count = spec.routes.len(); + + let cfg = ws.config.mock.as_ref(); + let bind_host = bind + .or_else(|| cfg.and_then(|c| c.bind.clone())) + .unwrap_or_else(|| "127.0.0.1".into()); + let bind_port = port.or_else(|| cfg.and_then(|c| c.port)).unwrap_or(3000); + let addr: SocketAddr = format!("{bind_host}:{bind_port}") + .parse() + .map_err(|e| format!("invalid bind {bind_host}:{bind_port}: {e}"))?; + + let emitter = app.clone(); + let sink: knock_mock::LogSink = Arc::new(move |e: LogEntry| { + let _ = emitter.emit(LOG_EVENT, e); + }); + + let handle = serve_with_logger(spec, addr, Some(sink)) + .await + .map_err(|e| e.to_string())?; + + let started_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let local = handle.addr; + + { + let mut g = state.inner.lock().unwrap(); + *g = Some(RunningMock { + workspace_root: ws.root.clone(), + addr: local, + started_at, + route_count, + handle: Some(handle), + }); + } + + let status = MockStatus { + running: true, + workspace_root: Some(ws.root.display().to_string()), + addr: Some(local.to_string()), + route_count: Some(route_count), + started_at: Some(started_at), + }; + let _ = app.emit(STATUS_EVENT, status.clone()); + Ok(status) +} + +#[tauri::command] +pub async fn mock_stop( + app: tauri::AppHandle, + state: State<'_, MockState>, +) -> Result { + let handle_opt = { + let mut g = state.inner.lock().unwrap(); + g.take().and_then(|mut r| r.handle.take()) + }; + if let Some(h) = handle_opt { + let _ = h.shutdown().await; + } + let status = MockStatus { + running: false, + workspace_root: None, + addr: None, + route_count: None, + started_at: None, + }; + let _ = app.emit(STATUS_EVENT, status.clone()); + Ok(status) +} diff --git a/apps/knock-app/src/App.tsx b/apps/knock-app/src/App.tsx index 23feeda..9cfff0c 100644 --- a/apps/knock-app/src/App.tsx +++ b/apps/knock-app/src/App.tsx @@ -28,6 +28,7 @@ import { WorkspaceAppearanceModal } from "./WorkspaceAppearanceModal"; import { Statusbar, SCALE_STEP, clampScale } from "./Statusbar"; import { OpenApiImportModal } from "./OpenApiImportModal"; import { OpenApiView } from "./OpenApiView"; +import { MockView } from "./MockView"; import { BottomTerminalDock } from "./BottomTerminalDock"; import { terminalStore, type KubeTerminalSpawnArgs } from "./kubeTerminalStore"; import type { DirEntryDto, RecentEntry } from "./types"; @@ -294,13 +295,15 @@ export function App() { async function refreshTree(root: string) { try { - const [list, dirList, colorMap, orderMap] = await Promise.all([ + const [list, envList, dirList, colorMap, orderMap] = await Promise.all([ invoke("list_tree", { root }), + invoke("list_envs", { root }), invoke("list_directories", { root }), invoke>("get_colors", { root }), invoke>("list_folder_orders", { root }), ]); setEntries(list); + setEnvs(envList); setDirectories(dirList.map((d) => d.rel)); setColors(colorMap); setFolderOrders(orderMap); @@ -1110,6 +1113,15 @@ export function App() { /> )} + {projectMode === "mock" && globalMode === "requests" && workspace && ( + <> +
Mock server
+
+ Start a local HTTP server built from this workspace's requests. + Live request logs appear on the right. +
+ + )} {globalMode === "kube" && ( <>
Kubeconfigs
@@ -1133,8 +1145,11 @@ export function App() { }} /> )} - {globalMode === "requests" && !selected &&
Select a request from the tree.
} - {globalMode === "requests" && selected && isRequest && form && ( + {globalMode === "requests" && projectMode === "mock" && workspace && ( + + )} + {globalMode === "requests" && projectMode !== "mock" && !selected &&
Select a request from the tree.
} + {globalMode === "requests" && projectMode !== "mock" && selected && isRequest && form && ( )} - {globalMode === "requests" && selected && isOpenApi && workspace && ( + {globalMode === "requests" && projectMode !== "mock" && selected && isOpenApi && workspace && ( setShowOpenApiImport(true)} /> )} - {globalMode === "requests" && selected && !isRequest && !isOpenApi && ( + {globalMode === "requests" && projectMode !== "mock" && selected && !isRequest && !isOpenApi && ( <>
{selected}{rawDirty && } diff --git a/apps/knock-app/src/MockView.tsx b/apps/knock-app/src/MockView.tsx new file mode 100644 index 0000000..ef55167 --- /dev/null +++ b/apps/knock-app/src/MockView.tsx @@ -0,0 +1,574 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +interface MockStatus { + running: boolean; + workspace_root: string | null; + addr: string | null; + route_count: number | null; + started_at: number | null; +} + +interface MockRouteSummary { + method: string; + path: string; + auth: string | null; + status: number; + request_rel: string; + source: "inline" | "sibling" | "openapi" | "stub" | "empty"; +} + +interface MockResponseEdit { + status: number | null; + headers: Record; + auth: string | null; + delay_ms: number | null; + body_json: string | null; + body_text: string | null; +} + +interface MockResponseRead { + origin: "inline" | "sibling" | "none"; + edit: MockResponseEdit; +} + +interface MockSpecPreview { + routes: MockRouteSummary[]; + auth_schemes: string[]; + default_delay_ms: number | null; +} + +interface LogEntry { + ts_ms: number; + method: string; + path: string; + status: number; + elapsed_ms: number; + remote: string | null; +} + +interface Props { + workspaceRoot: string; +} + +const MAX_LOGS = 500; + +export function MockView({ workspaceRoot }: Props) { + const [status, setStatus] = useState({ + running: false, + workspace_root: null, + addr: null, + route_count: null, + started_at: null, + }); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [port, setPort] = useState("3000"); + const [bind, setBind] = useState("127.0.0.1"); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const [logs, setLogs] = useState([]); + const [paused, setPaused] = useState(false); + const [filter, setFilter] = useState(""); + const [selectedRel, setSelectedRel] = useState(null); + const [editorOrigin, setEditorOrigin] = + useState("none"); + const [editorStatus, setEditorStatus] = useState("200"); + const [editorBody, setEditorBody] = useState(""); + const [editorBodyKind, setEditorBodyKind] = useState<"json" | "text">("json"); + const [editorAuth, setEditorAuth] = useState(""); + const [editorHeaders, setEditorHeaders] = useState(""); + const [editorSaving, setEditorSaving] = useState(false); + const [editorError, setEditorError] = useState(null); + const logsRef = useRef(null); + const autoscrollRef = useRef(true); + + const refreshStatus = async () => { + try { + const s = await invoke("mock_status"); + setStatus(s); + } catch { + // ignore + } + }; + + const refreshPreview = async () => { + setPreviewError(null); + try { + const p = await invoke("mock_preview", { + workspaceRoot, + }); + setPreview(p); + } catch (e) { + setPreview(null); + setPreviewError(String(e)); + } + }; + + useEffect(() => { + refreshStatus(); + refreshPreview(); + }, [workspaceRoot]); + + useEffect(() => { + let cancelled = false; + const pending: Promise[] = [ + listen("mock://status", (e) => setStatus(e.payload)), + listen("mock://log", (e) => { + if (paused) return; + setLogs((cur) => { + const next = [...cur, e.payload]; + if (next.length > MAX_LOGS) next.splice(0, next.length - MAX_LOGS); + return next; + }); + }), + ]; + return () => { + cancelled = true; + pending.forEach((p) => + p.then((un) => { + if (cancelled) un(); + }), + ); + }; + }, [paused]); + + useEffect(() => { + if (!autoscrollRef.current) return; + const el = logsRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [logs]); + + const onScroll = () => { + const el = logsRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoscrollRef.current = atBottom; + }; + + const start = async () => { + setBusy(true); + setActionError(null); + try { + const portNum = port.trim() ? parseInt(port, 10) : null; + const s = await invoke("mock_start", { + workspaceRoot, + bind: bind.trim() || null, + port: portNum && !Number.isNaN(portNum) ? portNum : null, + }); + setStatus(s); + } catch (e) { + setActionError(String(e)); + } finally { + setBusy(false); + } + }; + + const stop = async () => { + setBusy(true); + setActionError(null); + try { + const s = await invoke("mock_stop"); + setStatus(s); + } catch (e) { + setActionError(String(e)); + } finally { + setBusy(false); + } + }; + + const openEditor = async (rel: string) => { + setSelectedRel(rel); + setEditorError(null); + try { + const r = await invoke("mock_read_response", { + workspaceRoot, + requestRel: rel, + }); + setEditorOrigin(r.origin); + setEditorStatus(r.edit.status ? String(r.edit.status) : "200"); + setEditorAuth(r.edit.auth ?? ""); + setEditorHeaders( + Object.entries(r.edit.headers ?? {}) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"), + ); + if (r.edit.body_json) { + setEditorBodyKind("json"); + setEditorBody(r.edit.body_json); + } else if (r.edit.body_text) { + setEditorBodyKind("text"); + setEditorBody(r.edit.body_text); + } else { + setEditorBodyKind("json"); + setEditorBody(""); + } + } catch (e) { + setEditorError(String(e)); + } + }; + + const closeEditor = () => { + setSelectedRel(null); + setEditorError(null); + }; + + const saveEditor = async () => { + if (!selectedRel) return; + setEditorSaving(true); + setEditorError(null); + try { + const headers: Record = {}; + editorHeaders + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .forEach((l) => { + const idx = l.indexOf(":"); + if (idx > 0) { + const k = l.slice(0, idx).trim(); + const v = l.slice(idx + 1).trim(); + if (k) headers[k] = v; + } + }); + const statusNum = parseInt(editorStatus, 10); + const edit: MockResponseEdit = { + status: Number.isNaN(statusNum) ? 200 : statusNum, + headers, + auth: editorAuth.trim() || null, + delay_ms: null, + body_json: editorBodyKind === "json" ? editorBody : null, + body_text: editorBodyKind === "text" ? editorBody : null, + }; + await invoke("mock_save_response", { + workspaceRoot, + requestRel: selectedRel, + edit, + }); + await refreshPreview(); + setEditorOrigin("sibling"); + // hot-reload: if the server is running for this workspace, restart + // so the new response takes effect on the next request. + if (sameWorkspace) { + try { + await invoke("mock_stop"); + const portNum = port.trim() ? parseInt(port, 10) : null; + const s = await invoke("mock_start", { + workspaceRoot, + bind: bind.trim() || null, + port: portNum && !Number.isNaN(portNum) ? portNum : null, + }); + setStatus(s); + } catch (e) { + setEditorError(`saved, but reload failed: ${e}`); + } + } + } catch (e) { + setEditorError(String(e)); + } finally { + setEditorSaving(false); + } + }; + + const clearEditor = async () => { + if (!selectedRel) return; + setEditorSaving(true); + setEditorError(null); + try { + await invoke("mock_clear_response", { + workspaceRoot, + requestRel: selectedRel, + }); + await refreshPreview(); + setEditorOrigin("none"); + setEditorStatus("200"); + setEditorBody(""); + setEditorAuth(""); + setEditorHeaders(""); + } catch (e) { + setEditorError(String(e)); + } finally { + setEditorSaving(false); + } + }; + + const filteredLogs = useMemo(() => { + const f = filter.trim().toLowerCase(); + if (!f) return logs; + return logs.filter( + (l) => + l.path.toLowerCase().includes(f) || + l.method.toLowerCase().includes(f) || + String(l.status).includes(f), + ); + }, [logs, filter]); + + const sameWorkspace = + status.running && status.workspace_root === workspaceRoot; + const runningElsewhere = + status.running && status.workspace_root !== workspaceRoot; + + return ( +
+
+ Mock server + + {status.running && status.addr && ( + + http://{status.addr} + + )} +
+ +
+ + + {!status.running && ( + + )} + {sameWorkspace && ( + + )} + {runningElsewhere && ( + + Running for another workspace ({status.workspace_root}). Stop it + first. + + )} + +
+ + {actionError &&
{actionError}
} + {previewError &&
spec: {previewError}
} + +
+
+
+ Routes {preview ? `(${preview.routes.length})` : ""} +
+ {!preview && !previewError &&
Loading…
} + {preview && preview.routes.length === 0 && ( +
+ No routes detected. Add requests under requests/ and{" "} + optional [mock] blocks or files under{" "} + mocks/. +
+ )} + {preview && ( + + + + + + + + + + + + {preview.routes.map((r, i) => ( + openEditor(r.request_rel)} + title={r.request_rel} + > + + + + + + + ))} + +
MethodPathStatusAuthSource
+ {r.method} + {r.path}{r.status}{r.auth ?? "—"} + + {r.source} + +
+ )} + {preview && preview.auth_schemes.length > 0 && ( +
+ Auth schemes: {preview.auth_schemes.join(", ")} +
+ )} +
+ + {selectedRel && ( +
+
+ Response · {selectedRel} + + {editorOrigin === "inline" + ? "from request file" + : editorOrigin === "sibling" + ? `mocks/${selectedRel}` + : "not defined"} + + +
+
+ + +