From d9798e9748800385bf27aae8b7aa9dd2cc0530c4 Mon Sep 17 00:00:00 2001 From: jordandevai Date: Thu, 26 Feb 2026 12:50:40 -0500 Subject: [PATCH 1/2] feat: stabilize editor UX, response performance, secrets handling, and concurrent execution Implement a broad reliability and QoL pass across backend and frontend to address large-response freezes, unsafe edit flows, secret transform edge cases, and single-run throughput limits. Backend changes: - Preserve response body as text in runtime results and avoid unnecessary JSON deserialization. - Extend RequestResult metadata with content type, JSON/body flags, and body bytes. - Add targeted last-result upsert endpoint/storage path to avoid heavy full-file rewrites. - Refactor secret encryption/decryption transforms into reusable core modules with tests. Frontend changes: - Replace heavy JSON viewer path with virtualized response rendering for large payloads. - Add Raw response Copy Body action and keep Copy All behavior. - Introduce reusable sync/edit guards for request/environment forms to prevent overwrite and lockout behavior. - Convert URL editor and query param editor to shared dialog primitives with explicit save/discard semantics. - Add reusable unsaved-changes guard and apply to close/switch flows. - Improve table ergonomics with shared keyboard navigation (tab/enter/arrows/cmd-backspace), inline duplicate/missing-key feedback, and modular validation notices. - Add shared request preflight validation with focus-to-first-error behavior on Save/Send. - Add per-request running state and bulk folder execution helpers with configurable concurrency settings. - Tighten sidebar dialog state updates to avoid stale closure bugs. - Remove misleading readOnly input change handler in workspace passphrase UI. Validation run: - frontend build succeeds via npm run build. --- backend/app/api.py | 9 + backend/app/core/engine.py | 38 +- backend/app/core/secret_codec.py | 52 +++ backend/app/core/secret_transformers.py | 147 +++++++ backend/app/core/storage.py | 267 ++----------- backend/app/models.py | 3 + backend/tests/test_secret_transformers.py | 65 +++ .../environment/EnvironmentPanel.tsx | 96 +++-- .../src/components/history/HistoryDrawer.tsx | 9 +- .../src/components/request/FormDataTable.tsx | 53 ++- frontend/src/components/request/FormTable.tsx | 47 ++- .../src/components/request/HeadersTable.tsx | 45 ++- .../src/components/request/RequestEditor.tsx | 378 +++++++++++++----- .../request/TableValidationNotice.tsx | 21 + .../components/response/ResponseViewer.tsx | 174 +++++--- .../components/response/VirtualJsonViewer.tsx | 89 +++++ frontend/src/components/response/jsonTree.ts | 127 ++++++ .../components/sidebar/CollectionSidebar.tsx | 87 ++-- .../components/ui/KeyValueEditorDialog.tsx | 83 ++++ .../workspace/WorkspacePassphraseModal.tsx | 1 - frontend/src/hooks/useLastResults.ts | 2 +- frontend/src/lib/api.ts | 7 + .../src/lib/formSync/useBiDirectionalSync.ts | 39 ++ frontend/src/lib/forms/requestValidation.ts | 158 ++++++++ frontend/src/lib/forms/stableRows.ts | 20 + .../lib/forms/useKeyValueTableNavigation.ts | 107 +++++ frontend/src/lib/runtime/bulkRun.ts | 86 ++++ frontend/src/lib/runtime/requestExecution.ts | 27 ++ frontend/src/lib/state/useEditSessionGuard.ts | 13 + .../src/lib/state/useUnsavedChangesGuard.ts | 25 ++ frontend/src/stores/useActiveRequestStore.ts | 23 +- frontend/src/stores/useRunSettingsStore.ts | 73 ++++ 32 files changed, 1850 insertions(+), 521 deletions(-) create mode 100644 backend/app/core/secret_codec.py create mode 100644 backend/app/core/secret_transformers.py create mode 100644 backend/tests/test_secret_transformers.py create mode 100644 frontend/src/components/request/TableValidationNotice.tsx create mode 100644 frontend/src/components/response/VirtualJsonViewer.tsx create mode 100644 frontend/src/components/response/jsonTree.ts create mode 100644 frontend/src/components/ui/KeyValueEditorDialog.tsx create mode 100644 frontend/src/lib/formSync/useBiDirectionalSync.ts create mode 100644 frontend/src/lib/forms/requestValidation.ts create mode 100644 frontend/src/lib/forms/stableRows.ts create mode 100644 frontend/src/lib/forms/useKeyValueTableNavigation.ts create mode 100644 frontend/src/lib/runtime/bulkRun.ts create mode 100644 frontend/src/lib/runtime/requestExecution.ts create mode 100644 frontend/src/lib/state/useEditSessionGuard.ts create mode 100644 frontend/src/lib/state/useUnsavedChangesGuard.ts create mode 100644 frontend/src/stores/useRunSettingsStore.ts diff --git a/backend/app/api.py b/backend/app/api.py index 504e4eb..904e9d4 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -147,6 +147,15 @@ async def save_last_results(collection_id: str, payload: Dict[str, Any] = Body(. return {"status": "ok"} +@router.post("/collections/{collection_id}/last-results/{request_id}") +async def upsert_last_result(collection_id: str, request_id: str, result: RequestResult): + try: + storage.upsert_last_result(collection_id, request_id, result.model_dump()) + except VaultLockedError: + raise HTTPException(status_code=423, detail="workspace locked") + return {"status": "ok"} + + # --- Workspace --- @router.get("/workspace") async def get_workspace(): diff --git a/backend/app/core/engine.py b/backend/app/core/engine.py index b91faf9..68901d7 100644 --- a/backend/app/core/engine.py +++ b/backend/app/core/engine.py @@ -4,6 +4,7 @@ import time import uuid import random +import json from typing import Any, Dict, Tuple from app.models import HttpRequest, RequestResult, EnvironmentFile from app.core.storage import storage @@ -356,24 +357,36 @@ async def execute(self, collection_id: str, req: HttpRequest) -> RequestResult: duration = (time.perf_counter() - start_time) * 1000 - # 4. Parse Body + # 4. Parse Body (lazy) body_content = "" - is_json = False parsed_json = None - + content_type = resp_obj.headers.get("content-type") + content_type_l = (content_type or "").lower() + body_is_json = "application/json" in content_type_l or content_type_l.endswith("+json") or "+json;" in content_type_l + body_bytes = len(resp_obj.content or b"") + try: body_content = resp_obj.text - # Basic JSON detection - if "application/json" in resp_obj.headers.get("content-type", ""): - parsed_json = resp_obj.json() - is_json = True - except: - pass # Keep as text + except Exception: + try: + body_content = (resp_obj.content or b"").decode("utf-8", errors="replace") + except Exception: + body_content = "" # 5. Extraction Logic (Auto-Magic) vars_updated = False rule_errors = [] - if is_json and req.extract_rules: + if req.extract_rules: + if body_is_json: + try: + parsed_json = json.loads(body_content) if body_content else None + except Exception as ex: + parsed_json = None + rule_errors.append({"rule_id": "*", "error": f"Invalid JSON response body: {ex}"}) + else: + rule_errors.append({"rule_id": "*", "error": "Response body is not JSON"}) + + if parsed_json is not None and req.extract_rules: for rule in req.extract_rules: source_path = self._normalize_path(rule.source_path.strip()) try: @@ -395,7 +408,10 @@ async def execute(self, collection_id: str, req: HttpRequest) -> RequestResult: status_code=resp_obj.status_code, duration_ms=duration, headers=dict(resp_obj.headers), - body=parsed_json if is_json else body_content, + body=body_content, + body_is_json=body_is_json, + content_type=content_type, + body_bytes=body_bytes, ) if rule_errors: result.error = f"Extraction issues: {rule_errors}" diff --git a/backend/app/core/secret_codec.py b/backend/app/core/secret_codec.py new file mode 100644 index 0000000..f561b44 --- /dev/null +++ b/backend/app/core/secret_codec.py @@ -0,0 +1,52 @@ +import json +from typing import Any, Tuple + +from app.core.crypto import encrypt_value, decrypt_value, is_encrypted_string, CryptoError + + +def is_ciphertext(value: Any) -> bool: + return isinstance(value, str) and is_encrypted_string(value) + + +def encrypt_if_secret(value: Any, is_secret: bool, master_key: bytes | None) -> Any: + if not master_key or not is_secret: + return value + if not isinstance(value, str): + return value + if is_encrypted_string(value): + return value + return encrypt_value(value, master_key) + + +def encrypt_json_if_secret(value: Any, is_secret: bool, master_key: bytes | None) -> Any: + if not master_key or not is_secret: + return value + if isinstance(value, str): + if is_encrypted_string(value): + return value + return encrypt_value(value, master_key) + if isinstance(value, dict): + try: + return encrypt_value(json.dumps(value), master_key) + except Exception: + return value + return value + + +def decrypt_if_ciphertext(value: Any, master_key: bytes | None) -> Tuple[Any, str | None]: + if not master_key or not isinstance(value, str) or not is_encrypted_string(value): + return value, None + try: + return decrypt_value(value, master_key), None + except CryptoError as ex: + return value, str(ex) + + +def decrypt_body_if_ciphertext(value: Any, master_key: bytes | None) -> Tuple[Any, str | None]: + plain, err = decrypt_if_ciphertext(value, master_key) + if err or not isinstance(plain, str): + return plain, err + try: + return json.loads(plain), None + except Exception: + return plain, None diff --git a/backend/app/core/secret_transformers.py b/backend/app/core/secret_transformers.py new file mode 100644 index 0000000..5e9cf9e --- /dev/null +++ b/backend/app/core/secret_transformers.py @@ -0,0 +1,147 @@ +from typing import Any + +from app.core.secret_codec import ( + decrypt_body_if_ciphertext, + decrypt_if_ciphertext, + encrypt_if_secret, + encrypt_json_if_secret, + is_ciphertext, +) + + +def transform_request_for_encryption(req: Any, master_key: bytes | None) -> Any: + if not master_key: + return req + r = req.model_copy(deep=True) + + secret_headers = getattr(r, "secret_headers", {}) or {} + r.headers = { + k: encrypt_if_secret(v, bool(secret_headers.get(k)), master_key) + for k, v in (r.headers or {}).items() + } + + secret_q = getattr(r, "secret_query_params", {}) or {} + next_q = [] + for row in r.query_params or []: + row_copy = dict(row) + key = row_copy.get("key") or "" + row_copy["value"] = encrypt_if_secret(row_copy.get("value"), bool(secret_q.get(key)), master_key) + next_q.append(row_copy) + r.query_params = next_q + + secret_form = getattr(r, "secret_form_fields", {}) or {} + next_form = [] + for row in r.form_body or []: + row_copy = dict(row) + key = row_copy.get("key") or "" + row_type = (row_copy.get("type") or "text").lower() + if row_type == "text": + row_copy["value"] = encrypt_if_secret(row_copy.get("value"), bool(secret_form.get(key)), master_key) + next_form.append(row_copy) + r.form_body = next_form + + secret_auth = getattr(r, "secret_auth_params", {}) or {} + r.auth_params = { + k: encrypt_if_secret(v, bool(secret_auth.get(k)), master_key) + for k, v in (r.auth_params or {}).items() + } + + r.body = encrypt_json_if_secret(getattr(r, "body", None), bool(getattr(r, "secret_body", False)), master_key) + return r + + +def transform_request_for_decryption(req: Any, master_key: bytes | None) -> Any: + if not master_key: + return req + r = req + + # Decrypt ciphertext regardless of current secret flags so toggles/key renames + # do not strand encrypted values. + next_headers = {} + for k, v in (r.headers or {}).items(): + dec, _ = decrypt_if_ciphertext(v, master_key) + next_headers[k] = dec + r.headers = next_headers + + next_q = [] + for row in r.query_params or []: + row_copy = dict(row) + dec, _ = decrypt_if_ciphertext(row_copy.get("value"), master_key) + row_copy["value"] = dec + next_q.append(row_copy) + r.query_params = next_q + + next_form = [] + for row in r.form_body or []: + row_copy = dict(row) + row_type = (row_copy.get("type") or "text").lower() + if row_type == "text": + dec, _ = decrypt_if_ciphertext(row_copy.get("value"), master_key) + row_copy["value"] = dec + next_form.append(row_copy) + r.form_body = next_form + + next_auth = {} + for k, v in (r.auth_params or {}).items(): + dec, _ = decrypt_if_ciphertext(v, master_key) + next_auth[k] = dec + r.auth_params = next_auth + + body_dec, _ = decrypt_body_if_ciphertext(getattr(r, "body", None), master_key) + r.body = body_dec + return r + + +def request_contains_encrypted_values(req: Any) -> bool: + for _, v in (getattr(req, "headers", {}) or {}).items(): + if is_ciphertext(v): + return True + + for row in getattr(req, "query_params", []) or []: + if is_ciphertext((row or {}).get("value")): + return True + + for row in getattr(req, "form_body", []) or []: + row_type = ((row or {}).get("type") or "text").lower() + if row_type == "text" and is_ciphertext((row or {}).get("value")): + return True + + for _, v in (getattr(req, "auth_params", {}) or {}).items(): + if is_ciphertext(v): + return True + + if is_ciphertext(getattr(req, "body", None)): + return True + return False + + +def transform_environment_for_encryption(env_file: Any, master_key: bytes | None) -> Any: + if not master_key: + return env_file + env_copy = env_file.model_copy(deep=True) + for env_obj in env_copy.envs.values(): + secrets_map = getattr(env_obj, "secrets", {}) or {} + for key, is_secret in secrets_map.items(): + if not is_secret: + continue + env_obj.variables[key] = encrypt_if_secret(env_obj.variables.get(key), True, master_key) + return env_copy + + +def transform_environment_for_decryption(env_file: Any, master_key: bytes | None) -> Any: + if not master_key: + return env_file + # Decrypt ciphertext regardless of secret map to prevent orphaned ciphertext. + for env_obj in env_file.envs.values(): + for key, val in (env_obj.variables or {}).items(): + dec, _ = decrypt_if_ciphertext(val, master_key) + env_obj.variables[key] = dec + return env_file + + +def environment_contains_encrypted_values(env_file: Any) -> bool: + for env_obj in env_file.envs.values(): + for _, val in (env_obj.variables or {}).items(): + if is_ciphertext(val): + return True + return False diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index 3d8db8e..b67d5e1 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -4,7 +4,6 @@ import secrets import shutil import time -import json as jsonlib from pathlib import Path from typing import Any, List, Dict, Optional, Tuple from uuid import uuid4 @@ -28,6 +27,14 @@ KDF_NAME, KEY_LEN, ) +from app.core.secret_transformers import ( + transform_request_for_encryption, + transform_request_for_decryption, + request_contains_encrypted_values, + transform_environment_for_encryption, + transform_environment_for_decryption, + environment_contains_encrypted_values, +) class VaultLockedError(Exception): @@ -72,29 +79,6 @@ def refresh_workspace_gitignore(self) -> bool: self._ensure_workspace_gitignore() return True - def _ensure_workspace_gitignore(self): - """ - Ensure dynamic runtime artifacts are ignored when users version their workspace. - """ - patterns = [ - "collections/*/history.json", - "collections/*/last_results.json", - "collections/*/cookies.json", - ] - gitignore_path = self.base_dir / ".gitignore" - try: - existing = gitignore_path.read_text().splitlines() if gitignore_path.exists() else [] - updated = existing[:] - for p in patterns: - if p not in existing: - updated.append(p) - if updated != existing: - gitignore_path.parent.mkdir(parents=True, exist_ok=True) - gitignore_path.write_text("\n".join(updated) + ("\n" if updated else "")) - except Exception: - # Gitignore best-effort; ignore failures to avoid blocking runtime - pass - # --- Vault helpers --- def _vault_path(self) -> Path: return self.base_dir / ".litefetch" / "vault.key" @@ -171,15 +155,19 @@ def _history_path(self, collection_id: str) -> Path: def _cookies_path(self, collection_id: str) -> Path: return self._collection_dir(collection_id) / "cookies.json" - def _atomic_write(self, target_path: Path, data: Any): + def _is_runtime_artifact(self, target_path: Path) -> bool: + return target_path.name in {"history.json", "last_results.json", "cookies.json"} + + def _atomic_write(self, target_path: Path, data: Any, compact: bool = False): target_path.parent.mkdir(parents=True, exist_ok=True) tmp = target_path.with_suffix(".tmp") + dump_kwargs = {"separators": (",", ":")} if compact else {"indent": 2} if hasattr(data, "model_dump"): - payload = json.dumps(data.model_dump(), indent=2) + payload = json.dumps(data.model_dump(), **dump_kwargs) elif hasattr(data, "dict"): - payload = json.dumps(data.dict(), indent=2) + payload = json.dumps(data.dict(), **dump_kwargs) else: - payload = json.dumps(data, indent=2) + payload = json.dumps(data, **dump_kwargs) with open(tmp, "w", encoding="utf-8") as f: f.write(payload) f.flush() @@ -196,12 +184,12 @@ def _prepare_payload(self, data: Any) -> str: obj = data return json.dumps(obj, separators=(",", ":")) - def _secure_write_json(self, target_path: Path, data: Any): + def _secure_write_json(self, target_path: Path, data: Any, compact: bool = False): if not self.master_key: raise VaultLockedError("workspace locked") ciphertext = encrypt_value(self._prepare_payload(data), self.master_key) wrapper = {"v": 1, "ciphertext": ciphertext} - self._atomic_write(target_path, wrapper) + self._atomic_write(target_path, wrapper, compact=compact) def _secure_read_json(self, target_path: Path) -> Any: if not self.master_key: @@ -246,152 +234,23 @@ def _read_sensitive(self, path: Path, default: Any = None) -> Any: return json.load(f) def _write_sensitive(self, path: Path, data: Any): + compact = self._is_runtime_artifact(path) if self.master_key: - self._secure_write_json(path, data) + self._secure_write_json(path, data, compact=compact) return if self._vault_ready(): raise VaultLockedError("workspace locked") - self._atomic_write(path, data) + self._atomic_write(path, data, compact=compact) def _require_unlocked(self): if self._vault_ready() and not self.master_key: raise VaultLockedError("workspace locked") def _encrypt_request_secrets(self, req: Any) -> Any: - if not self.master_key: - return req - r = req.model_copy(deep=True) - # Headers - secret_headers = getattr(r, "secret_headers", {}) or {} - next_headers = {} - for k, v in (r.headers or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and not is_encrypted_string(v): - next_headers[k] = encrypt_value(v, self.master_key) - else: - next_headers[k] = v - r.headers = next_headers - - # Query params - secret_q = getattr(r, "secret_query_params", {}) or {} - next_q = [] - for row in r.query_params or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - if secret_q.get(key) and isinstance(val, str) and not is_encrypted_string(str(val)): - row_copy["value"] = encrypt_value(str(val), self.master_key) - next_q.append(row_copy) - r.query_params = next_q - - # Form body - secret_form = getattr(r, "secret_form_fields", {}) or {} - next_form = [] - for row in r.form_body or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - row_type = (row_copy.get("type") or "text").lower() - # Only encrypt textual form values; do not encrypt file paths or blobs - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and not is_encrypted_string(str(val)): - row_copy["value"] = encrypt_value(str(val), self.master_key) - next_form.append(row_copy) - r.form_body = next_form - - # Auth - secret_auth = getattr(r, "secret_auth_params", {}) or {} - next_auth = {} - for k, v in (r.auth_params or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and not is_encrypted_string(v): - next_auth[k] = encrypt_value(v, self.master_key) - else: - next_auth[k] = v - r.auth_params = next_auth - - # Body - if getattr(r, "secret_body", False): - body_val = r.body - if isinstance(body_val, str) and not is_encrypted_string(body_val): - r.body = encrypt_value(body_val, self.master_key) - elif isinstance(body_val, dict): - try: - raw = jsonlib.dumps(body_val) - r.body = encrypt_value(raw, self.master_key) - except Exception: - pass - return r + return transform_request_for_encryption(req, self.master_key) def _decrypt_request_secrets(self, req: Any) -> Any: - if not self.master_key: - return req - r = req - # Headers - secret_headers = getattr(r, "secret_headers", {}) or {} - next_headers = {} - for k, v in (r.headers or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and is_encrypted_string(v): - try: - next_headers[k] = decrypt_value(v, self.master_key) - except CryptoError: - next_headers[k] = v - else: - next_headers[k] = v - r.headers = next_headers - - # Query params - secret_q = getattr(r, "secret_query_params", {}) or {} - next_q = [] - for row in r.query_params or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - if secret_q.get(key) and isinstance(val, str) and is_encrypted_string(val): - try: - row_copy["value"] = decrypt_value(val, self.master_key) - except CryptoError: - pass - next_q.append(row_copy) - r.query_params = next_q - - # Form body - secret_form = getattr(r, "secret_form_fields", {}) or {} - next_form = [] - for row in r.form_body or []: - row_copy = dict(row) - key = row_copy.get("key") or "" - val = row_copy.get("value") - row_type = (row_copy.get("type") or "text").lower() - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and is_encrypted_string(val): - try: - row_copy["value"] = decrypt_value(val, self.master_key) - except CryptoError: - pass - next_form.append(row_copy) - r.form_body = next_form - - # Auth - secret_auth = getattr(r, "secret_auth_params", {}) or {} - next_auth = {} - for k, v in (r.auth_params or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and is_encrypted_string(v): - try: - next_auth[k] = decrypt_value(v, self.master_key) - except CryptoError: - next_auth[k] = v - else: - next_auth[k] = v - r.auth_params = next_auth - - # Body - if getattr(r, "secret_body", False) and isinstance(r.body, str) and is_encrypted_string(r.body): - try: - raw = decrypt_value(r.body, self.master_key) - try: - r.body = jsonlib.loads(raw) - except Exception: - r.body = raw - except CryptoError: - pass - return r + return transform_request_for_decryption(req, self.master_key) def _walk_requests(self, items: list, fn) -> list: result = [] @@ -428,49 +287,13 @@ def _walk_requests_any(self, items: list, predicate) -> bool: return False def _request_contains_encrypted_secret(self, req: Any) -> bool: - secret_headers = getattr(req, "secret_headers", {}) or {} - for k, v in (getattr(req, "headers", {}) or {}).items(): - if secret_headers.get(k) and isinstance(v, str) and is_encrypted_string(v): - return True - - secret_q = getattr(req, "secret_query_params", {}) or {} - for row in getattr(req, "query_params", []) or []: - key = (row or {}).get("key") or "" - val = (row or {}).get("value") - if secret_q.get(key) and isinstance(val, str) and is_encrypted_string(val): - return True - - secret_form = getattr(req, "secret_form_fields", {}) or {} - for row in getattr(req, "form_body", []) or []: - key = (row or {}).get("key") or "" - val = (row or {}).get("value") - row_type = ((row or {}).get("type") or "text").lower() - if row_type == "text" and secret_form.get(key) and isinstance(val, str) and is_encrypted_string(val): - return True - - secret_auth = getattr(req, "secret_auth_params", {}) or {} - for k, v in (getattr(req, "auth_params", {}) or {}).items(): - if secret_auth.get(k) and isinstance(v, str) and is_encrypted_string(v): - return True - - if getattr(req, "secret_body", False) and isinstance(getattr(req, "body", None), str): - if is_encrypted_string(getattr(req, "body")): - return True - return False + return request_contains_encrypted_values(req) def _collection_contains_encrypted_secrets(self, col: Collection) -> bool: return self._walk_requests_any(col.items or [], self._request_contains_encrypted_secret) def _environment_contains_encrypted_secrets(self, env_file: EnvironmentFile) -> bool: - for env_obj in env_file.envs.values(): - secrets_map = getattr(env_obj, "secrets", {}) or {} - for key, is_secret in secrets_map.items(): - if not is_secret: - continue - val = env_obj.variables.get(key) - if isinstance(val, str) and is_encrypted_string(val): - return True - return False + return environment_contains_encrypted_values(env_file) def reencrypt_sensitive(self) -> Dict[str, int]: """ @@ -634,17 +457,7 @@ def load_environment(self, collection_id: str) -> EnvironmentFile: raise VaultLockedError("workspace locked") return env_file - for env_obj in env_file.envs.values(): - secrets_map = getattr(env_obj, "secrets", {}) or {} - for key, is_secret in secrets_map.items(): - if not is_secret: - continue - val = env_obj.variables.get(key) - if isinstance(val, str) and is_encrypted_string(val): - try: - env_obj.variables[key] = decrypt_value(val, self.master_key) - except CryptoError: - continue + env_file = transform_environment_for_decryption(env_file, self.master_key) if was_wrapped: try: self.save_environment(collection_id, env_file) @@ -655,19 +468,7 @@ def load_environment(self, collection_id: str) -> EnvironmentFile: def save_environment(self, collection_id: str, env: EnvironmentFile): if self._vault_ready() and not self.master_key: raise VaultLockedError("workspace locked") - env_copy = env.model_copy(deep=True) - if self.master_key: - for env_obj in env_copy.envs.values(): - secrets_map = getattr(env_obj, "secrets", {}) or {} - for key, is_secret in secrets_map.items(): - if not is_secret: - continue - val = env_obj.variables.get(key) - if isinstance(val, str) and is_encrypted_string(val): - # already encrypted, leave as-is - continue - if isinstance(val, str): - env_obj.variables[key] = encrypt_value(val, self.master_key) + env_copy = transform_environment_for_encryption(env, self.master_key) self._atomic_write(self._env_path(collection_id), env_copy) self._touch_meta(collection_id) @@ -689,7 +490,15 @@ def save_last_results(self, collection_id: str, results: dict): if not isinstance(results, dict): results = {} self._write_sensitive(self._last_results_path(collection_id), results) - self._touch_meta(collection_id) + + def upsert_last_result(self, collection_id: str, request_id: str, result: dict): + if not request_id: + return + results = self.load_last_results(collection_id) + if not isinstance(results, dict): + results = {} + results[request_id] = result if isinstance(result, dict) else {} + self._write_sensitive(self._last_results_path(collection_id), results) def load_history(self, collection_id: str) -> List[dict]: return self._read_sensitive(self._history_path(collection_id), default=[]) @@ -699,7 +508,6 @@ def append_history(self, collection_id: str, result: dict): history.insert(0, result) history = history[:50] self._write_sensitive(self._history_path(collection_id), history) - self._touch_meta(collection_id) # --- Cookies --- def _load_cookies_blob(self, collection_id: str) -> Dict[str, list]: @@ -728,21 +536,18 @@ def load_env_cookies(self, collection_id: str, env_id: str) -> list: if cleaned != entries: data[env_id] = cleaned self._write_sensitive(self._cookies_path(collection_id), data) - self._touch_meta(collection_id) return cleaned def save_env_cookies(self, collection_id: str, env_id: str, cookies: list): data = self._load_cookies_blob(collection_id) data[env_id] = cookies if isinstance(cookies, list) else [] self._write_sensitive(self._cookies_path(collection_id), data) - self._touch_meta(collection_id) def clear_env_cookies(self, collection_id: str, env_id: str): data = self._load_cookies_blob(collection_id) if env_id in data: data[env_id] = [] self._write_sensitive(self._cookies_path(collection_id), data) - self._touch_meta(collection_id) def load_bundle(self, collection_id: str) -> CollectionBundle: meta = self.load_meta(collection_id) diff --git a/backend/app/models.py b/backend/app/models.py index fb0ce69..41766de 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -79,6 +79,9 @@ class RequestResult(BaseModel): duration_ms: float headers: Dict[str, str] body: Any + body_is_json: bool = False + content_type: Optional[str] = None + body_bytes: int = 0 error: Optional[str] = None timestamp: float = Field(default_factory=time.time) diff --git a/backend/tests/test_secret_transformers.py b/backend/tests/test_secret_transformers.py new file mode 100644 index 0000000..3dd7da8 --- /dev/null +++ b/backend/tests/test_secret_transformers.py @@ -0,0 +1,65 @@ +import os +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from app.models import HttpRequest, Environment, EnvironmentFile +from app.core.secret_transformers import ( + transform_request_for_encryption, + transform_request_for_decryption, + transform_environment_for_encryption, + transform_environment_for_decryption, + request_contains_encrypted_values, + environment_contains_encrypted_values, +) + + +def test_request_decrypts_even_if_secret_flags_change(): + master = os.urandom(32) + req = HttpRequest( + headers={"Authorization": "Bearer token"}, + secret_headers={"Authorization": True}, + body="super-secret", + secret_body=True, + ) + + enc = transform_request_for_encryption(req, master) + assert request_contains_encrypted_values(enc) + + # Simulate user toggling secret flags off or key drift before load/decrypt. + enc.secret_headers = {} + enc.secret_body = False + dec = transform_request_for_decryption(enc, master) + + assert dec.headers["Authorization"] == "Bearer token" + assert dec.body == "super-secret" + + +def test_environment_decrypts_even_if_secret_map_changes(): + master = os.urandom(32) + env = EnvironmentFile( + active_env="default", + envs={ + "default": Environment( + name="default", + variables={"API_KEY": "abc123"}, + secrets={"API_KEY": True}, + ) + }, + ) + + enc_env = transform_environment_for_encryption(env, master) + assert environment_contains_encrypted_values(enc_env) + + # Simulate secret map mismatch that previously stranded ciphertext. + enc_env.envs["default"].secrets = {} + dec_env = transform_environment_for_decryption(enc_env, master) + assert dec_env.envs["default"].variables["API_KEY"] == "abc123" + + +def test_non_secret_values_remain_plaintext(): + master = os.urandom(32) + req = HttpRequest(headers={"X-Trace": "abc"}, secret_headers={}) + out = transform_request_for_encryption(req, master) + assert out.headers["X-Trace"] == "abc" diff --git a/frontend/src/components/environment/EnvironmentPanel.tsx b/frontend/src/components/environment/EnvironmentPanel.tsx index 8731f00..37f05ee 100644 --- a/frontend/src/components/environment/EnvironmentPanel.tsx +++ b/frontend/src/components/environment/EnvironmentPanel.tsx @@ -4,6 +4,8 @@ import { useEnvironmentQuery, useSaveEnvironmentMutation } from '../../hooks/use import type { EnvironmentFile } from '../../lib/api'; import { cn } from '../../lib/utils'; import { useWorkspaceLockStore } from '../../stores/useWorkspaceLockStore'; +import { renameKeyInMap, uniqueKey } from '../../lib/forms/stableRows'; +import { useUnsavedChangesGuard } from '../../lib/state/useUnsavedChangesGuard'; type EnvPanelProps = { open: boolean; @@ -21,18 +23,23 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { const { data } = useEnvironmentQuery(); const { mutateAsync: saveEnv, isPending } = useSaveEnvironmentMutation(); const [localEnv, setLocalEnv] = useState(emptyEnvFile); + const [isDirty, setIsDirty] = useState(false); const [status, setStatus] = useState<{ tone: 'info' | 'success' | 'error'; message: string } | null>(null); const { isLocked } = useWorkspaceLockStore(); + const { confirmDiscard } = useUnsavedChangesGuard(); + const markDirty = () => setIsDirty(true); // keep local copy for editing useEffect(() => { - if (data) setLocalEnv(data); - }, [data]); + if (!open || !data || isDirty) return; + setLocalEnv(data); + }, [data, isDirty, open]); const envNames = useMemo(() => Object.keys(localEnv.envs || {}), [localEnv]); const activeEnvKey = localEnv.active_env; const activeEnv = localEnv.envs[activeEnvKey] || { name: activeEnvKey, variables: {}, secrets: {} }; const addEnv = () => { + markDirty(); const base = 'env'; let idx = 1; while (localEnv.envs[`${base}-${idx}`]) idx += 1; @@ -49,7 +56,8 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const addVar = () => { - const key = 'KEY'; + markDirty(); + const key = uniqueKey(Object.keys(activeEnv.variables || {}), 'KEY'); const next = { ...localEnv, envs: { @@ -65,9 +73,8 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const updateVar = (k: string, v: string) => { - const nextVars = { ...activeEnv.variables }; - delete nextVars[k]; - nextVars[k] = v; + markDirty(); + const nextVars = { ...activeEnv.variables, [k]: v }; const next = { ...localEnv, envs: { @@ -82,16 +89,11 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const renameVar = (oldKey: string, newKey: string) => { - const trimmed = newKey.trim(); - if (!trimmed) return; - const nextVars = { ...activeEnv.variables }; - const val = nextVars[oldKey]; - const nextSecrets = { ...(activeEnv.secrets || {}) }; - const secretFlag = nextSecrets[oldKey]; - delete nextVars[oldKey]; - delete nextSecrets[oldKey]; - nextVars[trimmed] = val; - if (secretFlag !== undefined) nextSecrets[trimmed] = secretFlag; + markDirty(); + if (!newKey || oldKey === newKey) return; + if (Object.prototype.hasOwnProperty.call(activeEnv.variables || {}, newKey)) return; + const nextVars = renameKeyInMap(activeEnv.variables || {}, oldKey, newKey); + const nextSecrets = renameKeyInMap(activeEnv.secrets || {}, oldKey, newKey); const next = { ...localEnv, envs: { @@ -103,6 +105,7 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const removeVar = (key: string) => { + markDirty(); const nextVars = { ...activeEnv.variables }; const nextSecrets = { ...(activeEnv.secrets || {}) }; delete nextVars[key]; @@ -118,42 +121,22 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { }; const renameEnv = (newName: string) => { - const trimmed = newName.trim() || activeEnvKey; - if (trimmed === activeEnvKey) { - setLocalEnv((prev) => ({ - ...prev, - envs: { ...prev.envs, [activeEnvKey]: { ...activeEnv, name: trimmed } }, - })); - return; - } - - // Ensure unique key - let candidate = trimmed; - let counter = 1; - while (localEnv.envs[candidate]) { - candidate = `${trimmed}-${counter}`; - counter += 1; - } - - const { [activeEnvKey]: _unused, ...rest } = localEnv.envs; // eslint-disable-line @typescript-eslint/no-unused-vars - const nextEnvs = { - ...rest, - [candidate]: { ...activeEnv, name: trimmed }, - }; - setLocalEnv({ - ...localEnv, - active_env: candidate, - envs: nextEnvs, - }); + markDirty(); + setLocalEnv((prev) => ({ + ...prev, + envs: { ...prev.envs, [activeEnvKey]: { ...activeEnv, name: newName } }, + })); }; const handleSave = async () => { await saveEnv(localEnv); + setIsDirty(false); setStatus({ tone: 'success', message: 'Environment saved' }); onClose(); }; const toggleSecret = (key: string) => { + markDirty(); const nextSecrets = { ...(activeEnv.secrets || {}) }; nextSecrets[key] = !nextSecrets[key]; setLocalEnv((prev) => ({ @@ -165,6 +148,16 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { })); }; + const handleClose = () => { + const shouldClose = confirmDiscard({ + isDirty, + message: 'Discard unsaved environment changes?', + }); + if (!shouldClose) return; + setIsDirty(false); + onClose(); + }; + if (!open) return null; if (isLocked) { @@ -178,7 +171,7 @@ export const EnvironmentPanel = ({ open, onClose }: EnvPanelProps) => { +
+ + +
void; showSecrets?: boolean; + tableId?: string; + duplicateKeyIndexes?: Set; + missingKeyIndexes?: Set; }; -export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, showSecrets = false }: Props) => { - const tableRef = useRef(null); +export const FormTable = ({ + rows, + onChange, + showDescription = false, + onEditRow, + showSecrets = false, + tableId = 'form-table', + duplicateKeyIndexes, + missingKeyIndexes, +}: Props) => { + const displayRows = rows && rows.length > 0 ? rows : [{ key: '', value: '', enabled: true, description: '' }]; // Ensure there's always at least one empty row useEffect(() => { @@ -42,6 +56,15 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, const addRow = () => { onChange([...(rows || []), { key: '', value: '', enabled: true, description: '' }]); }; + const { getCellProps } = useKeyValueTableNavigation({ + tableId, + rowCount: displayRows.length, + fields: showDescription ? ['key', 'value', 'description'] : ['key', 'value'], + addRow, + deleteRow, + canDeleteRow: (rowIndex) => + Boolean(displayRows[rowIndex]?.key?.trim() || displayRows[rowIndex]?.value?.trim()) || displayRows.length > 1, + }); // Auto-expand: add new row when user types in the last row const handleChange = (idx: number, patch: Partial) => { @@ -64,10 +87,8 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, onChange(rows.map(r => ({ ...r, enabled: !allEnabled }))); }; - const displayRows = rows && rows.length > 0 ? rows : [{ key: '', value: '', enabled: true, description: '' }]; - return ( -
+
{/* Table Header */}
@@ -115,11 +136,15 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow,
handleChange(idx, { key: e.target.value })} disabled={row.enabled === false} + {...getCellProps(idx, 'key')} />
@@ -133,6 +158,7 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, onChange={(e) => handleChange(idx, { value: e.target.value })} disabled={row.enabled === false} onDoubleClick={() => onEditRow?.(idx)} + {...getCellProps(idx, 'value')} />
@@ -146,6 +172,7 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, value={row.description || ''} onChange={(e) => updateRow(idx, { description: e.target.value })} disabled={row.enabled === false} + {...getCellProps(idx, 'description')} />
)} @@ -189,6 +216,12 @@ export const FormTable = ({ rows, onChange, showDescription = false, onEditRow, {/* Add Row Button */}
+
); @@ -182,23 +195,30 @@ const HeadersTab = React.memo(({ control, onHeadersChange }: HeadersTabProps) => HeadersTab.displayName = 'HeadersTab'; export const RequestEditor = () => { - const { activeRequestId, setIsRunning, setResult, setSentRequest } = useActiveRequestStore(); + const { activeRequestId, runningByRequest, setRequestRunning, setResult, setSentRequest, setRequestSelectionGuard } = useActiveRequestStore(); const activeRequest = useActiveRequestFromCollection(activeRequestId); const { mutateAsync: saveRequest, isPending: isSaving } = useSaveRequestMutation(); const { mutateAsync: saveLastResult } = useSaveLastResultMutation(); const [activeTab, setActiveTab] = useState<'body' | 'headers' | 'params' | 'auth' | 'settings'>('body'); const [showUrlEditor, setShowUrlEditor] = useState(false); + const [urlDraft, setUrlDraft] = useState(''); + const [urlDraftDirty, setUrlDraftDirty] = useState(false); + const [saveState, setSaveState] = useState<'saved' | 'unsaved' | 'saving' | 'error'>('saved'); + const [validationError, setValidationError] = useState(null); const { isLocked, openModal } = useWorkspaceLockStore(); - const syncingFromUrl = useRef(false); - const syncingFromParams = useRef(false); + const [isEditingUrl, setIsEditingUrl] = useState(false); + const urlInputRef = useRef(null); + const { confirmDiscard } = useUnsavedChangesGuard(); + const { markFromLeft, markFromRight, consumeIfFromLeft, consumeIfFromRight, resetSyncOrigin } = useBiDirectionalSync(); const [paramEditor, setParamEditor] = useState<{ index: number; key: string; value: string; } | null>(null); useHotkeys([ - { combo: 'mod+s', handler: () => handleSave() }, - { combo: 'mod+enter', handler: () => handleRun() }, + { combo: 'mod+s', handler: () => void handleSave() }, + { combo: 'mod+enter', handler: () => void handleRun() }, + { combo: 'mod+l', handler: () => urlInputRef.current?.focus() }, ]); const { @@ -208,6 +228,7 @@ export const RequestEditor = () => { watch, setValue, getValues, + formState, } = useForm({ defaultValues: { name: '', @@ -231,12 +252,22 @@ export const RequestEditor = () => { }); const urlValue = watch('url'); const queryParams = watch('query_params'); + const headersRows = watch('headers'); + const formBodyRows = watch('form_body'); const authType = watch('auth_type'); const authParams = watch('auth_params'); const secretBody = watch('secret_body'); const secretAuthParams = watch('secret_auth_params'); + const autoSaveSnapshot = useWatch({ control }); + const isDirty = formState.isDirty; const queryClient = useQueryClient(); + const headerDuplicateIndexes = useMemo(() => findDuplicateKeyIndexes(headersRows || []), [headersRows]); + const headerMissingKeyIndexes = useMemo(() => findMissingKeyIndexes(headersRows || []), [headersRows]); + const queryDuplicateIndexes = useMemo(() => findDuplicateKeyIndexes(queryParams || []), [queryParams]); + const queryMissingKeyIndexes = useMemo(() => findMissingKeyIndexes(queryParams || []), [queryParams]); + const formDuplicateIndexes = useMemo(() => findDuplicateKeyIndexes(formBodyRows || []), [formBodyRows]); + const formMissingKeyIndexes = useMemo(() => findMissingKeyIndexes(formBodyRows || []), [formBodyRows]); const activeCollectionId = useActiveCollectionStore((s) => s.activeId); const { fields: ruleFields, append: appendRule, remove: removeRule } = useFieldArray({ @@ -323,6 +354,7 @@ export const RequestEditor = () => { // Reset form when active request changes useEffect(() => { if (!activeRequest) return; + resetSyncOrigin(); const headersRecord = activeRequest.headers || {}; const derived = deriveAuthFromHeaders(headersRecord, (activeRequest.auth_type as any) || 'none', activeRequest.auth_params || {}); const secretHeaderMap = activeRequest.secret_headers || {}; @@ -364,31 +396,32 @@ export const RequestEditor = () => { secret_body: Boolean(activeRequest.secret_body), binary: activeRequest.binary || null, }); - }, [activeRequest, reset, parseQueryParamsFromUrl, deriveAuthFromHeaders]); + setSaveState('saved'); + }, [activeRequest, reset, parseQueryParamsFromUrl, deriveAuthFromHeaders, resetSyncOrigin]); // Sync URL -> Params useEffect(() => { - if (syncingFromParams.current) { - syncingFromParams.current = false; + if (consumeIfFromRight()) { return; } + if (isEditingUrl) return; const parsed = parseQueryParamsFromUrl(urlValue || ''); - syncingFromUrl.current = true; + markFromLeft(); setValue('query_params', parsed, { shouldDirty: false }); - }, [parseQueryParamsFromUrl, setValue, urlValue]); + }, [consumeIfFromRight, isEditingUrl, markFromLeft, parseQueryParamsFromUrl, setValue, urlValue]); // Sync Params -> URL useEffect(() => { - if (syncingFromUrl.current) { - syncingFromUrl.current = false; + if (consumeIfFromLeft()) { return; } + if (isEditingUrl) return; const nextUrl = buildUrlWithParams(urlValue || '', queryParams || []); if (nextUrl !== urlValue) { - syncingFromParams.current = true; + markFromRight(); setValue('url', nextUrl, { shouldDirty: true }); } - }, [buildUrlWithParams, queryParams, setValue, urlValue]); + }, [buildUrlWithParams, consumeIfFromLeft, isEditingUrl, markFromRight, queryParams, setValue, urlValue]); const buildRequest = useMemo(() => { return (values: FormValues): HttpRequest => { @@ -482,10 +515,49 @@ export const RequestEditor = () => { return { ...req, headers, body: body ?? null, form_body: formBody, binary: binaryPayload }; }; + const performSave = useCallback( + async (mode: 'manual' | 'auto') => { + if (!activeRequest || !activeCollectionId || isLocked) return; + if (mode === 'auto' && isSaving) return; + const values = getValues(); + const updated = buildRequest(values); + try { + setSaveState('saving'); + await saveRequest(updated); + reset(values); + setSaveState('saved'); + } catch (err) { + console.error(err); + setSaveState('error'); + } + }, + [activeCollectionId, activeRequest, buildRequest, getValues, isLocked, isSaving, reset, saveRequest], + ); + const handleSave = async () => { if (!activeRequest || !activeCollectionId) return; - const updated = buildRequest(getValues()); - await saveRequest(updated); + const issues = validateRequestForSubmit({ + url: getValues('url'), + body_mode: getValues('body_mode'), + headers: getValues('headers') || [], + query_params: getValues('query_params') || [], + form_body: getValues('form_body') || [], + binary: getValues('binary'), + }); + if (issues.length) { + const first = issues[0]; + setValidationError(first.message); + setActiveTab(first.tab); + if (first.selector) { + window.setTimeout(() => { + const node = document.querySelector(first.selector!); + node?.focus(); + }, 0); + } + return; + } + setValidationError(null); + await performSave('manual'); }; const handleRun = async () => { @@ -493,10 +565,31 @@ export const RequestEditor = () => { return; } if (!activeRequest || !activeCollectionId) return; + const issues = validateRequestForSubmit({ + url: getValues('url'), + body_mode: getValues('body_mode'), + headers: getValues('headers') || [], + query_params: getValues('query_params') || [], + form_body: getValues('form_body') || [], + binary: getValues('binary'), + }); + if (issues.length) { + const first = issues[0]; + setValidationError(first.message); + setActiveTab(first.tab); + if (first.selector) { + window.setTimeout(() => { + const node = document.querySelector(first.selector!); + node?.focus(); + }, 0); + } + return; + } + setValidationError(null); const baseReq = buildRequest(getValues()); const prepared = prepareForSend(baseReq); setSentRequest(prepared.id, prepared); - setIsRunning(true); + setRequestRunning(prepared.id, true); try { await saveRequest(baseReq); const res = await LiteAPI.runRequest(activeCollectionId, prepared); @@ -515,13 +608,68 @@ export const RequestEditor = () => { timestamp: Date.now() / 1000, }); } finally { - setIsRunning(false); + setRequestRunning(prepared.id, false); } }; + const openUrlEditor = useCallback(() => { + setUrlDraft(getValues('url') || ''); + setUrlDraftDirty(false); + setShowUrlEditor(true); + setIsEditingUrl(true); + }, [getValues]); + + const closeUrlEditor = useCallback(() => { + const shouldClose = confirmDiscard({ + isDirty: urlDraftDirty, + message: 'Discard unsaved URL changes?', + }); + if (!shouldClose) return; + setShowUrlEditor(false); + setIsEditingUrl(false); + setUrlDraftDirty(false); + }, [confirmDiscard, urlDraftDirty]); + + const saveAndCloseUrlEditor = useCallback(() => { + const nextUrl = urlDraft.trim(); + const currentUrl = getValues('url') || ''; + setValue('url', nextUrl, { shouldDirty: nextUrl !== currentUrl }); + setShowUrlEditor(false); + setIsEditingUrl(false); + setUrlDraftDirty(false); + }, [getValues, setValue, urlDraft]); + + useEffect(() => { + setSaveState((prev) => (isDirty ? (prev === 'saving' ? prev : 'unsaved') : 'saved')); + }, [isDirty]); + + useEffect(() => { + if (validationError) setValidationError(null); + }, [autoSaveSnapshot]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!activeRequest || !activeCollectionId || isLocked || !isDirty) return; + const timer = window.setTimeout(() => { + void performSave('auto'); + }, 800); + return () => window.clearTimeout(timer); + }, [activeCollectionId, activeRequest, autoSaveSnapshot, isDirty, isLocked, performSave]); + + useEffect(() => { + setRequestSelectionGuard(() => + confirmDiscard({ + isDirty, + message: 'You have unsaved request changes. Discard and switch requests?', + }), + ); + return () => setRequestSelectionGuard(null); + }, [confirmDiscard, isDirty, setRequestSelectionGuard]); + const bodyValue = watch('body'); const bodyMode = watch('body_mode'); const binary = watch('binary'); + const activeRequestRunning = activeRequestId ? Boolean(runningByRequest[activeRequestId]) : false; + const urlField = register('url'); if (isLocked) { return ( @@ -562,13 +710,39 @@ export const RequestEditor = () => { { + urlField.ref(el); + urlInputRef.current = el; + }} + data-req-field="url" placeholder="http://localhost:8000/api..." - onDoubleClick={() => setShowUrlEditor(true)} + onDoubleClick={openUrlEditor} + onFocus={() => setIsEditingUrl(true)} + onBlur={() => setIsEditingUrl(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void handleRun(); + } + }} /> +
+ {saveState === 'saved' ? 'Saved' : saveState === 'unsaved' ? 'Unsaved' : saveState === 'saving' ? 'Saving…' : 'Save failed'} +
+
- {showUrlEditor && ( -
-
-
-
Edit URL
-
- - -
-
+ {validationError && ( +
+ {validationError} +
+ )} + !isOpen && closeUrlEditor()}> + + + Edit URL + +