diff --git a/.agents/skills/customization-guide/SKILL.md b/.agents/skills/customization-guide/SKILL.md new file mode 100644 index 0000000..19c7655 --- /dev/null +++ b/.agents/skills/customization-guide/SKILL.md @@ -0,0 +1,116 @@ +--- +name: customization-guide +description: "How to create, update, or debug root and nested AGENTS.md files, .agents/skills, and project agent conventions. Use when adding a new agent instruction file, creating a new skill, or troubleshooting how instructions are discovered." +--- + +# Agent customization guide + +How to create and manage **nested `AGENTS.md` files** and **on-demand skills** +(`.agents/skills//SKILL.md`) for the Postmark project. + +## When to use nested AGENTS.md vs skills + +| Feature | Nested `AGENTS.md` | Agent skills | +|---------|-------------------|--------------| +| **Location** | Repo root (`AGENTS.md`) or under a subtree (e.g. `src/ui/AGENTS.md`) | `.agents/skills//` | +| **Filename** | `AGENTS.md` | `SKILL.md` | +| **Loading** | Agents merge root + nearest nested files along the path to edited files | Read when the task matches the skill `description` (listed in root `AGENTS.md`) | +| **Best for** | Core rules per directory tree | Reference material, step-by-step guides, catalogues | +| **Context cost** | Included whenever working under that subtree | Only when relevant | + +**Rule of thumb:** If an agent needs the information for *every* change under a +directory (e.g. all UI code), put it in `src/ui/AGENTS.md`. If it only applies +to *specific tasks* (e.g. "add a widget", "debug signals"), put it in a skill. + +## Creating nested agent instructions + +1. Add or extend **`AGENTS.md`** in the directory whose code the rules belong to: + + ``` + src/ui/AGENTS.md # UI / PySide6 conventions + src/database/AGENTS.md # SQLAlchemy / repository conventions + src/AGENTS.md # Cross-cutting architecture under src/ + tests/AGENTS.md # Pytest / fixture conventions + docs/AGENTS.md # Documentation authoring rules + ``` + +2. Plain Markdown is enough — no glob metadata. Scope is defined by **where the file lives** + (nested files merge with the root `AGENTS.md`). + +3. Write concise, imperative rules. Start with a "Quick rules" section. + +4. Register the file in **root [`AGENTS.md`](../../../AGENTS.md)** (nested files + sync checklist tables). + +5. Run `poetry run python scripts/check_md_links.py` to verify links. + +### Nested file guidelines + +- Keep files lean — they apply whenever editing under that tree. +- Use imperative tone ("Do X", "Never Y"). +- Start with numbered "Quick rules" for the most critical constraints. +- Never duplicate rules across files — link to the canonical nested file instead. + +## Creating a new skill + +1. Create a directory under `.agents/skills/`: + + ``` + .agents/skills/my-skill/ + ``` + +2. Create `SKILL.md` with YAML frontmatter: + + ```yaml + --- + name: my-skill + description: >- + Detailed description of what this skill does and when an agent should + read it. Include trigger phrases like "Use when adding new X" or + "Use when debugging Y". + --- + ``` + +3. Write the skill body in Markdown — procedures, templates, tables, checklists. + +4. Add the skill to the **skills table** in root [`AGENTS.md`](../../../AGENTS.md). + +### Skill naming conventions + +- Directory name: lowercase, hyphens (e.g. `signal-flow`). +- `name` in frontmatter: matches folder name. +- `description`: tells humans and agents **when** to open this file. + +## Existing structure + +### Nested `AGENTS.md` (merged by path) + +| File | Scope | +|------|-------| +| [`AGENTS.md`](../../../AGENTS.md) | Project-wide — overview, architecture tree, validation gate | +| [`src/AGENTS.md`](../../../src/AGENTS.md) | Architecture & data flow for `src/` | +| [`src/ui/AGENTS.md`](../../../src/ui/AGENTS.md) | PySide6 / UI | +| [`src/database/AGENTS.md`](../../../src/database/AGENTS.md) | SQLAlchemy / DB | +| [`tests/AGENTS.md`](../../../tests/AGENTS.md) | Testing | +| [`docs/AGENTS.md`](../../../docs/AGENTS.md) | Docs authoring | + +### Skills (on-demand) + +| Skill | Trigger | +|-------|---------| +| `signal-flow` | Signals, wiring, data flow | +| `service-repository-reference` | Repository/service APIs, TypedDicts | +| `widget-patterns` | Widgets, delegates, workers | +| `test-writing` | New tests | +| `import-parser` | New import format | +| `customization-guide` | Changing agent layout | + +## Mandatory sync after changes + +After modifying any `AGENTS.md`, skill, or linked doc, follow the checklist in +root [`AGENTS.md`](../../../AGENTS.md) under **CRITICAL — Keeping instructions in sync**. + +After modifying any `.md` file, run: + +```bash +poetry run python scripts/check_md_links.py +``` diff --git a/.github/skills/import-parser/SKILL.md b/.agents/skills/import-parser/SKILL.md similarity index 95% rename from .github/skills/import-parser/SKILL.md rename to .agents/skills/import-parser/SKILL.md index 529c307..50b9a12 100644 --- a/.github/skills/import-parser/SKILL.md +++ b/.agents/skills/import-parser/SKILL.md @@ -126,8 +126,8 @@ class TestYourParser: After adding a new parser: -1. Add the file to the architecture tree in `copilot-instructions.md`. -2. Add test file to the test tree in `testing.instructions.md`. +1. Add the file to the architecture tree in root [`AGENTS.md`](../../../AGENTS.md). +2. Add the test file to the test tree in [`tests/AGENTS.md`](../../../tests/AGENTS.md). 3. Add the parser to the ImportService section in the `service-repository-reference` skill. diff --git a/.github/skills/service-repository-reference/SKILL.md b/.agents/skills/service-repository-reference/SKILL.md similarity index 62% rename from .github/skills/service-repository-reference/SKILL.md rename to .agents/skills/service-repository-reference/SKILL.md index 944d1d6..93a6707 100644 --- a/.github/skills/service-repository-reference/SKILL.md +++ b/.agents/skills/service-repository-reference/SKILL.md @@ -67,6 +67,39 @@ cross-layer data interchange. | `delete_environment(id)` | `None` | Delete environment | | `update_environment_values(id, values)` | `None` | Replace key-value pairs | +### Run history repository (`run_history_repository.py`) + +| Function | Returns | Purpose | +|----------|---------|---------| +| `create_run(collection_id, source?)` | `RunHistoryModel` | Start a new run record | +| `finish_run(run_id, **stats)` | `None` | Finalise a run with duration, test counts, status | +| `add_result(run_id, **fields)` | `RunResultModel` | Add a per-request result | +| `get_runs_for_collection(collection_id, limit?)` | `list[RunHistoryModel]` | Runs for a collection, newest first | +| `get_run_results(run_id)` | `list[RunResultModel]` | Results for a run, ordered by ID | +| `delete_run(run_id)` | `bool` | Delete a single run (True if found) | +| `delete_runs_for_collection(collection_id)` | `int` | Delete all runs for a collection, return count | + +### Local script repository (`local_script_repository.py`) + +| Function | Returns | Purpose | +|----------|---------|---------| +| `create_folder(name, parent_id?)` | `LocalScriptFolderModel` | Create folder | +| `create_script(folder_id, name, *, language, module_format="esm", content)` | `LocalScriptModel` | Create script; ``module_format`` validated via ``_normalize_module_format`` | +| `rename_script_and_rewrite_refs(script_id, new_name, *, language?, module_format?)` | `int` | Rename + rewrite ``pm.require("local:…")`` when virtual path changes (``.js`` ↔ ``.cjs``) | +| `move_script_and_rewrite_refs(script_id, new_folder_id)` | `int` | Move + rewrite local refs | +| `update_script_content(script_id, content, language?, module_format?)` | `None` | Persist editor body | + +``module_format="commonjs"`` is only valid when ``language=="javascript"``; otherwise +``ValueError``. TypeScript/Python rows always store ``"esm"``. + +### Local script query repository (`local_script_query_repository.py`) + +| Function | Returns | Purpose | +|----------|---------|---------| +| `fetch_all_local_scripts_tree()` | `dict[str, Any]` | Nested tree; script nodes include ``module_format`` | +| `get_script_by_id(script_id)` | `LocalScriptModel \| None` | PK lookup | +| `get_local_script_breadcrumb(script_id)` | `list[dict[str, Any]]` | Breadcrumb segments | + ## Service method catalogue ### CollectionService @@ -164,6 +197,41 @@ variable substitution via `{{variable}}` syntax. | `add_variable(source, source_id, key, value)` | Add (or update) a variable to a collection or environment | | `substitute(text, variables)` | Replace `{{key}}` placeholders in text | +### RunHistoryService + +All methods are `@staticmethod`. Wraps `run_history_repository` for run +history CRUD. + +| Method | Purpose | +|--------|---------| +| `create_run(collection_id, source?)` | Start a new run record | +| `finish_run(run_id, **stats)` | Finalise a run with stats (incl. `skipped`) | +| `add_result(run_id, **fields)` | Add a per-request result | +| `get_runs(collection_id, limit?)` | Runs for a collection as list of dicts | +| `get_results(run_id)` | Results for a run as list of dicts | +| `delete_run(run_id)` | Delete a single run | +| `delete_runs(collection_id)` | Delete all runs for a collection | + +### LocalScriptService (`services/local_script_service.py`) + +All methods are `@staticmethod`. UI must use this module, not `database/`. + +| Method | Purpose | +|--------|---------| +| `fetch_all()` | Nested local-scripts tree dict (includes ``module_format`` on script nodes) | +| `list_virtual_paths(*, language)` | Virtual paths for ``pm.require("local:…")`` autocomplete | +| `get_script_load_dict(script_id)` | Editor open payload (see ``LocalScriptLoadDict``) | +| `create_script(folder_id, name, *, language, module_format="esm", content)` | Create script | +| `rename_script(script_id, new_name, *, language?, module_format?)` | Rename + ref rewrite | +| `save_script_content(script_id, content, language?, module_format?)` | Persist buffer | + +**CJS policy:** ``.cjs`` local scripts are leaf modules — no ``pm.require("local:…")`` +inside CJS bodies (enforced in ``local_script_modules.resolve_required``). Consumers +use ``pm.require("local:…/file.cjs")`` from ESM pre-request/test scripts only. + +**UI signals (local scripts tree):** ``new_script_clicked(str, str)`` (language, +module_format); ``new_script_requested(object, str, str)`` on header; ``script_rename_requested(int, str, str, str)`` on ``CollectionTree`` and ``CollectionWidget``. + ### GraphQLSchemaService All methods are `@staticmethod`. @@ -228,6 +296,70 @@ fragment capture), Password Credentials (direct POST), Client Credentials (direct POST). Browser-based flows open the system browser and start a local HTTP server to capture the callback. +### ScriptService + +All methods are `@staticmethod`. Resolves inherited script chains +by walking the collection ancestor tree. + +| Method | Returns | Purpose | +|--------|---------|---------| +| `build_script_chain(request_id)` | `tuple[list[ScriptEntry], list[ScriptEntry]]` | Collect pre-request and test scripts from ancestors + self | + +### ScriptEngine + +All methods are `@staticmethod`. Orchestrates script execution across +`DenoRuntime` / `JSRuntime` (Deno ``deno run`` subprocess) and `PyRuntime` (RestrictedPython subprocess). + +| Method | Returns | Purpose | +|--------|---------|---------| +| `run_pre_request_scripts(chain, context)` | `ScriptOutput` | Run pre-request chain, merge outputs | +| `run_test_scripts(chain, context)` | `ScriptOutput` | Run test chain, merge outputs | +| `run_single(script, language, context)` | `ScriptOutput` | Run one script in specified runtime | + +Module-level helper in `services/scripting/engine.py` (used by the script +editor gutter; not a `ScriptEngine` method): + +| Function | Returns | Purpose | +|----------|---------|---------| +| `find_pm_tests(source, language)` | `list[dict[str, Any]]` | `{"name", "line"}` (1-based) for each `pm.test` (Python AST, JS esprima + regex fallback) | +| `find_top_level_statement_lines(source, language)` | `set[int]` | 0-based lines of top-level statements (step-debugger checkpoints); empty set means do not style breakpoints as unreachable | + +`ScriptLinter` exposes `_esprima_parse_result` for the shared esprima JSON +parse (linting + `find_pm_tests` and `find_top_level_statement_lines`). + +**Response assertions (Postman-compat):** In `data/scripts/pm_bootstrap.js`, +`pm.response.to` is a getter that returns a new `__Expectation` wrapping the +response (so `pm.response.to.have.status(200)` works). **`pm.require`** in JS +loads `npm:` / `jsr:` packages only when the specifier is a **string literal** +in the user script: `js_runtime._detect_pm_require_specs` validates the name and +exact semver, `js_runtime._pm_require_imports_block` emits static ESM `import`s +and registers `globalThis.__pm_require_modules` (see `deno_runtime.deno_ipc_argv_and_env` +for cache + optional `--allow-net`). In +`services/scripting/_py_sandbox.py`, `_PmResponse.to` returns `_Expectation(self)`; +`jsonBody` is aliased to `json_body` on the Python side. + +**Python (Pyodide):** When `data/scripts/vendor_pyodide/pyodide.asm.wasm` exists and +Deno is available, `PyRuntime.execute` uses `pyodide_runtime.PyodideRuntime` → +`data/scripts/pyodide_run.mjs` (`loadPyodide` from `./vendor_pyodide/pyodide.mjs`, +`micropip` for `py_runtime.detect_pm_require_py_specs` literals, then +`data/scripts/pm_bootstrap.py` — hand-maintained Pyodide bootstrap (mirrors +`_py_sandbox.py` / `pm_bootstrap.js`; do **not** run deprecated +`scripts/gen_pm_bootstrap_pyodide.py`), with +`postmark_ipc.send_request_sync`, `pm.require`, safe builtins including `print` → `_console_emit`, +and `collect_pm_output`. Otherwise execution +stays on `_py_sandbox.py` + RestrictedPython (`PyRuntime.execute_restricted`). + +Context builders and utilities in `services/scripting/context.py`: + +| Function | Purpose | +|----------|---------| +| `build_pre_request_context(...)` | Build `ScriptInput` for pre-request scripts | +| `build_test_context(...)` | Build `ScriptInput` for test/post-response scripts | +| `normalize_events(events)` | Convert Postman-style event list to `{pre_request, test}` dict | +| `execute_sub_request(spec)` | HTTP bridge for `pm.sendRequest()` (scheme whitelist, rate-limited) | +| `load_globals()` | Load persisted global variables from `data/globals.json` | +| `save_globals(changes)` | Merge changes into persisted globals file | + ## TypedDict schemas ### SnippetOptions (`services/http/snippet_generator/generator.py`) @@ -316,6 +448,17 @@ class RequestLoadDict(TypedDict, total=False): auth: dict[str, Any] | None ``` +### LocalScriptService TypedDicts (`services/local_script_service.py`) + +```python +class LocalScriptLoadDict(TypedDict, total=False): + id: int + name: str + language: str + module_format: str # "esm" | "commonjs" + content: str +``` + ### EnvironmentService TypedDicts (`services/environment_service.py`) ```python @@ -360,6 +503,46 @@ class ImportSummary(TypedDict): ... See `services/import_parser/models.py` for full field definitions. +### Scripting TypedDicts (`services/scripting/__init__.py`) + +```python +class ScriptInput(TypedDict): + request: dict[str, Any] # method, url, headers, body + response: dict[str, Any] # status, headers, body, elapsed_ms (test only) + variables: dict[str, str] # combined environment + collection vars + environment_vars: dict[str, str] # environment-scoped variables + collection_vars: dict[str, str] # collection-scoped variables + global_vars: NotRequired[dict[str, str]] # persisted global variables + info: dict[str, Any] # request name, iteration index + iteration_data: NotRequired[dict[str, Any]] # data-driven row (runner only) + +class ScriptOutput(TypedDict): + test_results: list[TestResult] # pm.test() assertion results + console_logs: list[ConsoleLog] # console.log/warn/error output + variable_changes: dict[str, str] # pm.variables/environment/collection changes + global_variable_changes: NotRequired[dict[str, str]] # pm.globals changes + request_mutations: dict[str, Any] | None # pm.request.* mutations + next_request: NotRequired[str | None] # pm.execution.setNextRequest() + skip_request: NotRequired[bool] # pm.execution.skipRequest() + +class ScriptEntry(TypedDict): + code: str # script source code + language: str # "javascript", "typescript", or "python" + source_name: str # display label (e.g. "Collection > Test") + +class TestResult(TypedDict): + name: str # test description + passed: bool # assertion outcome + error: str | None # failure message + duration_ms: float # execution time + +class ConsoleLog(TypedDict): + level: str # "log", "warn", "error", "info" + message: str # formatted message + timestamp: float # time.time() value + source_line: NotRequired[int | None] # 0-based editor line (best-effort) +``` + ### Theme TypedDict (`ui/styling/theme.py`) ```python @@ -370,8 +553,8 @@ See `ui/styling/theme.py` for full field definitions. ## Response viewer and popup system -`ResponseViewerWidget` displays the HTTP response with four tabs: -Body, Headers, Cookies, and Saved. +`ResponseViewerWidget` displays the HTTP response with five tabs: +Body, Headers, Cookies, Test Results (hidden), and Pre-request (hidden). ### Body tab diff --git a/.github/skills/signal-flow/SKILL.md b/.agents/skills/signal-flow/SKILL.md similarity index 79% rename from .github/skills/signal-flow/SKILL.md rename to .agents/skills/signal-flow/SKILL.md index 2f1c1c9..0032a3e 100644 --- a/.github/skills/signal-flow/SKILL.md +++ b/.agents/skills/signal-flow/SKILL.md @@ -37,8 +37,9 @@ Tree context menu → "Add request" / Placeholder "Add a request" link ``` Tree context menu → "Rename" (folder) - → CollectionTree._rename_folder() → Qt's editItem() inline editor - → itemChanged signal → _on_item_changed() + → CollectionTree._rename_folder() / _rename_request() → overlay QLineEdit (`scriptTreeRenameEdit`) + → TreeRenameClickAway commits on outside click; Escape cancels + → collection_rename_requested / request_rename_requested → CollectionTree.collection_rename_requested(id, new_name) → CollectionWidget._on_collection_rename(id, new_name) → CollectionService.rename_collection(id, new_name) @@ -126,6 +127,13 @@ CollectionWidget.item_action_triggered("request", id, "Open") MainWindow back_action / forward_action → _navigate_back / _navigate_forward → _open_request(history[index]) + +MainWindow._on_tab_changed + → _record_tab_activation(index) # _TabNavHistoryMixin + +MainWindow tab_back_action / tab_forward_action (Go menu, Ctrl+Alt+arrows) + → _navigate_tab_back / _navigate_tab_forward + → setCurrentIndex(_index_for_nav_token(...)) ``` ### Import operations @@ -196,7 +204,10 @@ RequestTabBar.close_all_requested / force_close_all_requested ``` BreadcrumbBar.item_clicked(type, id) → MainWindow._on_breadcrumb_clicked - → _open_request(id) or _open_folder(id) + → local_scripts_root → LeftSidebar.open_panel("local_scripts") + → folder (local_script tab) → open local_scripts flyout + local_scripts_widget.select_and_scroll_to + → folder (otherwise) → _open_folder(id) + collection_widget.select_and_scroll_to + → request → _open_request(id) BreadcrumbBar.last_segment_renamed(new_name) → MainWindow._on_breadcrumb_rename @@ -204,16 +215,17 @@ BreadcrumbBar.last_segment_renamed(new_name) → else: CollectionService.rename_request / rename_collection ``` -### Environment selector flow +### Environment sidebar flow ``` -EnvironmentSelector.environment_changed(env_id | None) +EnvironmentSidebarPanel.environment_changed(env_id | None) → MainWindow._on_environment_changed → _refresh_variable_map() -EnvironmentSelector.manage_requested +EnvironmentSidebarPanel.manage_requested → MainWindow._on_manage_environments - → show EnvironmentEditor dialog + → MainWindow._open_environments_tab() (focus existing or add **Environments** tab) + → EnvironmentEditorWidget.environments_changed → `_env_selector.refresh` + `_on_environments_data_changed` ``` ### Save response flow @@ -278,7 +290,7 @@ MainWindow._toggle_response_action.triggered → _toggle_response_pane (show/hide response viewer) MainWindow._toggle_sidebar_action.triggered - → _toggle_sidebar (show/hide collection sidebar) + → _toggle_sidebar (collapse/expand left flyout; rail stays visible; stacked page unchanged) MainWindow._toggle_bottom_action.triggered → _toggle_bottom_panel (show/hide console/history) @@ -303,33 +315,70 @@ MainWindow snippet_act.triggered ``` MainWindow settings_act.triggered → _on_settings - → SettingsDialog(ThemeManager) - → theme changes applied via ThemeManager + → _open_settings_dialog(initial_category="Appearance") + → SettingsDialog(..., initial_category=...) + +RequestEditorWidget / FolderEditorWidget open_scripting_settings_requested + → MainWindow._on_open_scripting_settings + → _open_settings_dialog(initial_category="Scripting") + → editor._update_runtime_banners() after dialog closes ``` ### Collection runner flow ``` MainWindow run_act.triggered - → _on_run_collection - → CollectionRunnerWidget(collection_id) - → CollectionRunnerWidget.progress(index, result_dict) - → CollectionRunnerWidget.finished(results_list) - → CollectionRunnerWidget.error(message) -``` + → _on_run_collection → _on_run_collection_by_id(collection_id) + +CollectionWidget.run_collection_requested(int) + → MainWindow._on_run_collection_by_id(collection_id) + +_on_run_collection_by_id(collection_id) + → _open_folder(collection_id, focus_runner_panel=True) + → FolderEditorWidget (Runs → New run) + → _RunnerPanel.load_collection(collection_id) + → RunnerConfigView: env combo, request checklist, iterations, delay + → RunnerWorker.set_environment_vars(env_vars) + → RunnerWorker.set_requests(selected_requests) + → RunnerWorker.progress(index, result_dict) + → RunnerResultsView.add_result(result_dict) + → RunHistoryService.add_result(run_id, result_dict) + → RunnerWorker.finished(results_list) + → RunnerResultsView.show_summary(results_list) + → RunHistoryService.finish_run(run_id, stats incl. skipped) + → _RunnerPanel.run_finished → FolderEditorWidget.load_runs(...) + → RunnerWorker.error(message) + → _RunnerPanel.run_finished → FolderEditorWidget.load_runs(...) +``` + +Runner supports `pm.execution.setNextRequest()` / `skipRequest()` flow +control, data-driven iterations via CSV/JSON files, `{{var}}` substitution +from the merged scope (data row, then environment on key clash), +per-request detail view (Test Results always shown), and CSV/JSON export. +On success, `_on_finished` updates the config info label (not only the +summary bar). ### Send request flow ``` RequestEditorWidget.send_requested → MainWindow._on_send_request() + → ScriptService.build_script_chain(request_id) + → (pre_chain, test_chain) → HttpSendWorker.set_request(method, url, headers, body, auth, settings) → QThread.started → HttpSendWorker.run() → EnvironmentService.substitute() (variable replacement) + → ScriptEngine.run_pre_request_scripts(pre_chain, context) + → apply request mutations (headers, body changes) → HttpService.send_request() (httpx + timing/network/size) + → ScriptEngine.run_test_scripts(test_chain, context) + → collect TestResult list + ConsoleLog list → HttpSendWorker.finished(HttpResponseDict) → MainWindow._on_response_ready(data) → ResponseViewerWidget.load_response(data) + → ResponseViewerWidget.load_test_results(results) + → ResponseViewerWidget.load_pre_request_data(...) + → ConsolePanelWidget.append_logs(console) → HttpSendWorker.error(str) → ResponseViewerWidget.show_error(message) ``` @@ -439,12 +488,14 @@ All other signals in the flow diagrams above are fully wired. | `CollectionTree` | `new_collection_requested` | `Signal(object)` | | `CollectionTree` | `new_request_requested` | `Signal(object)` | | `CollectionTree` | `selected_collection_changed` | `Signal(object)` | +| `CollectionTree` | `run_collection_requested` | `Signal(int)` — collection ID | | `DraggableTreeWidget` | `request_moved` | `Signal(int, int)` | | `DraggableTreeWidget` | `collection_moved` | `Signal(int, object)` | | `CollectionWidget` | `item_action_triggered` | `Signal(str, int, str)` | | `CollectionWidget` | `item_name_changed` | `Signal(str, int, str)` | | `CollectionWidget` | `load_finished` | `Signal()` | | `CollectionWidget` | `draft_request_requested` | `Signal()` | +| `CollectionWidget` | `run_collection_requested` | `Signal(int)` — forwarded from tree | | `NewItemPopup` | `new_request_clicked` | `Signal()` | | `NewItemPopup` | `new_collection_clicked` | `Signal()` | @@ -456,6 +507,9 @@ All other signals in the flow diagrams above are fully wired. | `RequestEditorWidget` | `save_requested` | `Signal()` | | `RequestEditorWidget` | `dirty_changed` | `Signal(bool)` | | `RequestEditorWidget` | `request_changed` | `Signal(dict)` | +| `RequestEditorWidget` | `open_collection_requested` | `Signal(int)` | +| `RequestEditorWidget` | `open_scripting_settings_requested` | `Signal()` | +| `_RunnerPanel` | `run_finished` | `Signal()` — refresh run history | | `ResponseViewerWidget` | `save_response_requested` | `Signal(dict)` | | `HttpSendWorker` | `finished` | `Signal(dict)` — `HttpResponseDict` | | `HttpSendWorker` | `error` | `Signal(str)` | @@ -491,13 +545,17 @@ All other signals in the flow diagrams above are fully wired. |-------|--------|-----------| | `EnvironmentSelector` | `environment_changed` | `Signal(object)` — `int \| None` | | `EnvironmentSelector` | `manage_requested` | `Signal()` | -| `EnvironmentEditor` | `environments_changed` | `Signal()` | +| `EnvironmentSidebarPanel` | `environment_changed` | `Signal(object)` — `int \| None` | +| `EnvironmentSidebarPanel` | `manage_requested` | `Signal()` | +| `EnvironmentEditorWidget` | `environments_changed` | `Signal()` | +| `EnvironmentEditorDialog` | `environments_changed` | `Signal()` — forwards from embedded widget | ### Folder editor | Class | Signal | Signature | |-------|--------|-----------| | `FolderEditor` | `collection_changed` | `Signal(dict)` | +| `FolderEditor` | `open_scripting_settings_requested` | `Signal()` | ### Dialogs @@ -507,9 +565,9 @@ All other signals in the flow diagrams above are fully wired. | `ImportDialog._ImportWorker` | `error` | `Signal(str)` | | `ImportDialog._DropZone` | `files_dropped` | `Signal(list)` | | `ImportDialog` | `import_completed` | `Signal()` | -| `CollectionRunnerWidget` | `progress` | `Signal(int, dict)` | -| `CollectionRunnerWidget` | `finished` | `Signal(list)` | -| `CollectionRunnerWidget` | `error` | `Signal(str)` | +| `_RunnerWorker` | `progress` | `Signal(int, dict)` | +| `_RunnerWorker` | `finished` | `Signal(list)` | +| `_RunnerWorker` | `error` | `Signal(str)` | ### Other widgets @@ -519,6 +577,8 @@ All other signals in the flow diagrams above are fully wired. | `ClickableLabel` | `clicked` | `Signal()` | | `KeyValueTable` | `data_changed` | `Signal()` | | `CodeEditorWidget` | `validation_changed` | `Signal(list)` | +| `CodeEditorWidget` | `run_single_test_requested` | `Signal(str)` — per-`pm.test` gutter Run | +| `CodeEditorWidget` | `debug_single_test_requested` | `Signal(str)` — per-`pm.test` gutter Debug | | `HistoryPanel` | `entry_clicked` | `Signal(str, str)` | ## MainWindow signal wiring summary @@ -542,13 +602,17 @@ All connections made in `MainWindow.__init__` (and `_create_menus`): - `_breadcrumb_bar.item_clicked` → `_on_breadcrumb_clicked` - `_breadcrumb_bar.last_segment_renamed` → `_on_breadcrumb_rename` -**From environment selector:** +**From environment sidebar (`_env_selector` is ``EnvironmentSidebarPanel``):** - `_env_selector.environment_changed` → `_on_environment_changed` - `_env_selector.manage_requested` → `_on_manage_environments` -**From toolbar / menus:** -- `back_action.triggered` → `_navigate_back` +**From window shortcuts (no toolbar strip):** +- `back_action.triggered` → `_navigate_back` (request open history) - `forward_action.triggered` → `_navigate_forward` +- `tab_back_action.triggered` → `_navigate_tab_back` (tab activation history) +- `tab_forward_action.triggered` → `_navigate_tab_forward` + +**From menus:** - `import_act.triggered` → `_on_import` - `save_act.triggered` → `_on_save_request` - `snippet_act.triggered` → `_on_snippet_shortcut` diff --git a/.github/skills/test-writing/SKILL.md b/.agents/skills/test-writing/SKILL.md similarity index 98% rename from .github/skills/test-writing/SKILL.md rename to .agents/skills/test-writing/SKILL.md index 5929a7c..bba4560 100644 --- a/.github/skills/test-writing/SKILL.md +++ b/.agents/skills/test-writing/SKILL.md @@ -6,7 +6,7 @@ description: Guide for writing tests for Postmark components. Use when creating # Test writing guide Step-by-step guide for writing tests in the Postmark project. For core -test rules (fixtures, imports), see `testing.instructions.md`. +test rules (fixtures, imports), see [`tests/AGENTS.md`](../../../tests/AGENTS.md). ## Choosing the right test layer diff --git a/.github/skills/widget-patterns/SKILL.md b/.agents/skills/widget-patterns/SKILL.md similarity index 81% rename from .github/skills/widget-patterns/SKILL.md rename to .agents/skills/widget-patterns/SKILL.md index 131dd3c..9eba2ae 100644 --- a/.github/skills/widget-patterns/SKILL.md +++ b/.agents/skills/widget-patterns/SKILL.md @@ -6,7 +6,7 @@ description: Detailed PySide6 widget implementation patterns for the Postmark co # Widget implementation patterns Detailed patterns for building PySide6 widgets in the Postmark codebase. -For core rules (enums, layouts, cursors), see `pyside6.instructions.md`. +For core rules (enums, layouts, cursors), see [`src/ui/AGENTS.md`](../../../src/ui/AGENTS.md). ## Tree item badge rendering @@ -37,11 +37,19 @@ semantics: |---|---|---| | **Folder** | Display name (text + icon) | Type metadata only (via data roles) | | **Request** | Empty text `""` (delegate paints badge + name) | Raw name text (used for rename and delegate display) | +| **Script** (local_scripts) | Empty text `""` (delegate paints icon + basename + muted extension) | **Basename only** in column 1 (extension from `ROLE_LANGUAGE` + `ROLE_MODULE_FORMAT`) | Because of this asymmetry: -- Folder rename uses Qt's built-in `editItem()` on column 0. -- Request rename creates an overlay `QLineEdit` on the tree viewport. +- Folder and request rename use overlay `QLineEdit` (`scriptTreeRenameEdit`) via `_TreeOverlayRenameMixin` and `TreeRenameClickAway` (click-away commit, Escape cancel). +- Request rename creates an overlay `QLineEdit` on the full row. +- **Script** rename (`local_scripts` tree): VS Code-style `scriptTreeRenameEdit` overlay on the name column only — single `QLineEdit` with full `basename.ext`; `script_parse_filename_input()` strips suffix and maps `.js`/`.cjs`/`.ts`/`.py` to language + `module_format` (see `ui/local_scripts/script_filename.py`). +- **+ New** local script popup: four tiles — JavaScript (ESM), TypeScript, Python, **JavaScript (CommonJS)**; `new_script_clicked` emits `(language, module_format)`. + +## Sidebar section info (i) + +Collections, Local scripts, and Environments headers use `sidebarSectionInfoButton` + `SidebarSectionInfoPopup` from `ui/widgets/sidebar_section_info.py` (subclass of `InfoPopup`). Click toggles the popup below the icon; click again or outside closes it. - Reading a request's display name: use `item.text(1)` (column 1). +- Reading a script's basename: use `item.text(1)`; full file-style label via `script_display_name(basename, item.data(0, ROLE_LANGUAGE), item.data(0, ROLE_MODULE_FORMAT))`. ## Data role layout on QTreeWidgetItems @@ -56,6 +64,8 @@ All constants are defined in `ui/collections/tree/constants.py`. | `ROLE_NAME_LABEL` | `UserRole + 4` | Column 1 | (legacy — unused with delegate approach) | | `ROLE_MIME_DATA` | `UserRole + 5` | Column 3 | (legacy — unused, drag reads data roles directly) | | `ROLE_METHOD` | `UserRole + 6` | Column 0 | HTTP method string (requests only) | +| `ROLE_LANGUAGE` | `UserRole + 7` | Column 0 | Script language code (`javascript` / `typescript` / `python`) | +| `ROLE_MODULE_FORMAT` | `UserRole + 9` | Column 0 | `esm` or `commonjs` (JS only; `.cjs` virtual paths) | | `ROLE_PLACEHOLDER` | `UserRole + 10` | Column 1 | `"placeholder"` marker string | Gap at `+7` through `+9` is reserved for future roles. @@ -189,8 +199,8 @@ When creating a new widget: `setStyleSheet()`. 7. Import colours from `ui.styling.theme`, icons from `ui.styling.icons.phi()`. -8. Add the widget to the architecture tree in `copilot-instructions.md`. +8. Add the widget to the architecture tree in root [`AGENTS.md`](../../../AGENTS.md). 9. Create a matching test file in `tests/ui//`. -10. Add the test file to the test tree in `testing.instructions.md`. +10. Add the test file to the test tree in [`tests/AGENTS.md`](../../../tests/AGENTS.md). 11. If the widget emits signals wired in MainWindow, update `signal-flow` skill. diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..756b02e --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"9ea9398f-ac16-4afe-96c4-0200faa36aa0","pid":2209021,"procStart":"29210819","acquiredAt":1780086540080} \ No newline at end of file diff --git a/.cursor/plans/local-script-modules-full-plan.md b/.cursor/plans/local-script-modules-full-plan.md new file mode 100644 index 0000000..5603953 --- /dev/null +++ b/.cursor/plans/local-script-modules-full-plan.md @@ -0,0 +1,2070 @@ +# Local script modules (`pm.require("local:…")`) + left-pane toggle row + +This document is the **complete** implementation plan: it is a verbatim copy of the original multi-PR specification (historically `we-need-a-much-fluffy-glade.md`, 2046 lines) with **inline amendments** merged so nothing was removed. Amendments fix internal contradictions and add UI/editor requirements agreed in review (Collections-parity Scripts header, `NewItemPopup`-style **Create New** for modules, full script surface reuse clarified in §6.0). If a paragraph below is labeled **(Amended)** or a subsection **5.0a**, it **supersedes** any conflicting sentence in the same section that was left for traceability. + +## Context + +Postman ships **Package Library** (cloud-only, JS-only, no composition, owner-locked, no Newman, no git, no PyPI). We beat it by making reusable scripts **local files on disk**. + +A user drops a file like `auth-helpers.js` under one workspace folder. From any request pre/post script they call `pm.require("local:auth-helpers.js")` and get the module. Files are git-friendly, diffable, editable with LSP. Local modules can also import each other and import `npm:` / `jsr:` / PyPI packages. + +**UI shape**: the **existing left sidebar pane gets a horizontal icon toggle row added at its top**. Two compact icon buttons sit in that row: **Collections** (active by default — shows the existing tree) and **Scripts** (shows the **folder-grouped module tree** described in §5.-1 — not a flat list). Clicking swaps the content below via a `QStackedWidget`. This is the Cursor IDE pattern (per Cursor community docs: "Cursor has a distinctive design where the row of icons on the primary sidebar are arranged **horizontally** rather than vertically like VS Code"). + +**What is and isn't changing in the left pane slot:** +- The main `QSplitter` keeps the **same slot** for the left sidebar that `collection_widget` occupies today. **Width, position, and behavior unchanged.** +- The slot's content widget becomes a tiny new wrapper (`LeftSidebarPane`) that owns: (a) the horizontal toggle row, and (b) a `QStackedWidget` holding the existing `CollectionWidget` (page 0) and a new `ScriptsPanel` (page 1). +- `CollectionWidget` is **untouched** — same class, same tree, same header buttons. It just becomes page 0 of the stack. + +### UI anti-patterns (a prior implementation attempt failed by violating these) + +- **NO new splitter pane / column / section.** The left sidebar slot count stays the same as today. +- **NO `QDockWidget`** anywhere for the Scripts panel. Scripts is a page inside the same left sidebar pane. +- **NO vertical activity rail** on the far-left edge of the window. The toggle row is **horizontal** and lives **inside** the existing left pane. +- **NO separate top-level menu items / shortcuts** that bypass the toggle row. `Ctrl+1` / `Ctrl+2` call the toggle row's `set_active_panel(name)` method like the icons do. +- **NO modifications to `CollectionWidget`'s internal layout.** It enters the stack as-is. +- **NO read-only ScriptsPanel.** The panel must let users create / rename / delete modules from inside the app (**primary header actions + context menu** — see §5.0a; a compact secondary toolbar remains acceptable for power actions). Without that the feature is unusable; a prior implementation made exactly this mistake. +- **NO iconless toggle row.** Both toggle buttons in §4 must have an icon (Phosphor font via `phi()`). Icon-only or icon+text — pick one, but never label-only. +- **NO new code editor for script-module tabs.** Script-module tabs use the **same entire script editor surface** as pre/post-request scripts (not only `CodeEditorWidget`): toolbar with Find/Replace/Go to line, **Undo/Redo**, Save, status bar, vertical splitter, `ScriptOutputPanel` with **Output + Problems**, LSP wiring — see §6.0. The bare `CodeEditorWidget`-only sample in §6.1 is **illustrative** of persistence hooks; the shipped tab must call the extracted `build_script_editor_surface(..., script_type="module")` from §6.0. + +**Out of scope** (do not implement, even if related): per-collection-scoped modules; "Extract to module" refactor; where-used panel; snippet palette integration for `local:` (defer until shape stabilises); hot reload; TypeScript `.d.ts` autogen; in-app test runner for modules; cross-language imports (JS calling Python or vice versa); **RestrictedPython subprocess support for `local:` Python modules** (Pyodide-only — RestrictedPython path explicitly errors out); **standalone "Run" button for module files** (modules are imported by request scripts; they have no entry point on their own — Output panel exists for chrome parity but stays inactive). LSP **is in scope** — wired via the same auto-attach path the scripts editor uses today. + +**Scope expansion vs prior plan revisions**: **subdirectories are now supported** under the local-modules root. Specifier accepts a relative path (e.g. `pm.require("local:utils/jwt.js")`). The Scripts panel displays a tree grouped by folder, matching the visual pattern of the collections tree. + +--- + +## Composition story (resolver + bundle) + +**Critical**: a local module can call `pm.require("npm:...")`, `pm.require("jsr:...")`, `pm.require("local:other")` (JS) or `pm.require("pkg==X.Y.Z")`, `pm.require("local:other")` (Python). The user script's static scan would miss specifiers that only appear inside reachable local modules. + +**Rule**: registry/PyPI specifier detection runs as a **union scan over the user source PLUS the source of every transitively reachable local module**. + +Order of operations for each runtime path: +1. `LocalModuleResolver.resolve_required(user_source, language=...)` → builds `{name: LocalModule}` map (transitive closure with cycle detection). +2. Collect specifiers via `_detect_pm_require_specs(user_source + "\n" + "\n".join(local_sources))` (JS) or the Python equivalent. +3. Build bundle / IPC payload with the union of specifiers + the resolved local modules. + +This is the only design that makes the composition example in Verification step 7 actually pass. + +## Specifier rules + +Form: **extension is mandatory; relative path is allowed**. Accepted shapes: + +- JS: `pm.require("local:.js")` or `pm.require("local:.ts")` +- Python: `pm.require("local:.py")` + +Where `` is one or more `/`-separated segments. Examples: +``` +pm.require("local:jwt.js") // top-level file +pm.require("local:utils/jwt.js") // one subfolder deep +pm.require("local:auth/oauth/google.ts") // nested +pm.require("local:helpers/shout.py") // Python under helpers/ +``` + +Rules: +- Each segment matches `^[A-Za-z0-9_][\w.-]*$` (JS) / `^[A-Za-z_][A-Za-z0-9_]*$` (Python — segments are Python identifiers so the loader can register dotted names like `utils.jwt`). +- **No `..`, no leading `/`, no `@scope/`, no version, no Windows backslashes.** Reject these at parse time. +- The extension **must match a file on disk** at the resolved path. If only `utils/jwt.ts` exists, `pm.require("local:utils/jwt.js")` is a "not found" error. +- Path **must resolve under the configured local-modules root** — resolver enforces `resolve(strict=True)` + `relative_to(root)` (same traversal guard as before). +- The same file may be referenced by exactly one path; there's no implicit barrel/index resolution. + +Why extension-mandatory: +- Matches Deno / ESM / Python import convention — fewer surprises. +- No silent-ambiguity class possible at the call site. + +Why allow subdirectories: +- Users with many modules want logical grouping (`auth/`, `utils/`, `validators/`) like collections. +- The Scripts panel (§5) renders the tree visually, matching the collections-tree pattern. + +The `local:` prefix cannot collide with `npm:` / `jsr:` / bare PyPI names. + +--- + +## File system layout + +- Root folder, default: `/postmark/scripts/` + - Linux: `~/.local/share/postmark/scripts/` + - macOS: `~/Library/Application Support/postmark/scripts/` + - Windows: `%LOCALAPPDATA%\postmark\scripts\` +- Use the same per-OS resolver as [DenoManager.runtime_dir()](../../src/services/scripting/deno_manager.py) (see lines around 80-90 of that file). +- **(Amended)** The tree and resolver **recurse into subdirectories** under this root (see §1.1 `LocalModuleResolver.discover()`, specifier rules above, and §5.-1). Ignore the older “top-level only” MVP sentence — it contradicted the rest of this document. +- Auto-create the folder when read for the first time (`mkdir(parents=True, exist_ok=True)`). + +--- + +## Section 1 — Resolver and settings (PR 1) + +### 1.1 — New file `src/services/scripting/local_modules.py` + +Create this file. Add the constants, dataclass, and class below verbatim. + +```python +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Iterable, Literal + +MAX_LOCAL_MODULES = 500 +ALLOWED_EXTS = {".js", ".ts", ".py"} +EXT_TO_LANGUAGE: dict[str, Literal["javascript", "typescript", "python"]] = { + ".js": "javascript", + ".ts": "typescript", + ".py": "python", +} + +ScanFn = Callable[[str], Iterable[str]] + + +@dataclass(frozen=True) +class LocalModule: + name: str + language: Literal["javascript", "typescript", "python"] + path: Path + source: str = "" # populated by resolve_required(); empty after discover() + + +class LocalModuleResolver: + """Discovers and validates local script modules under a root. + + Walks subdirectories recursively. Modules are keyed by their relative + POSIX path (e.g. ``"utils/jwt.js"``, not just ``"jwt"``). + """ + + def __init__(self, root: Path | None = None) -> None: + from services.scripting.runtime_settings import RuntimeSettings + self._root = (root or RuntimeSettings.local_modules_dir()).resolve() + self._root.mkdir(parents=True, exist_ok=True) + + @property + def root(self) -> Path: + return self._root + + def discover(self) -> dict[str, LocalModule]: + """Recursive scan. Returns ``{rel_posix_path: LocalModule(source="")}``. + + Keys look like ``"jwt.js"`` for top-level files or + ``"utils/jwt.js"`` for nested ones (forward-slash separator, always). + Raises ValueError on cap exceeded or unsafe paths. + """ + modules: dict[str, LocalModule] = {} + for entry in sorted(self._root.rglob("*")): + if not entry.is_file(): + continue + if entry.suffix not in ALLOWED_EXTS: + continue + if not self._is_safe(entry): + continue + rel = entry.relative_to(self._root) + # Reject hidden dirs / files anywhere in the path (e.g. ``.git/``). + if any(part.startswith(".") for part in rel.parts): + continue + key = rel.as_posix() + modules[key] = LocalModule( + name=key, + language=EXT_TO_LANGUAGE[entry.suffix], + path=entry, + source="", + ) + if len(modules) > MAX_LOCAL_MODULES: + raise ValueError(f"too many local modules (> {MAX_LOCAL_MODULES})") + return modules + + def resolve_required( + self, + user_source: str, + scan_specs: ScanFn, + language: Literal["javascript", "python"], + ) -> dict[str, LocalModule]: + """Transitive closure of ``local:`` requires. + + ``scan_specs(source)`` yields **relative POSIX paths with extension** + (e.g. ``"utils/jwt.js"``). Returns ``{rel_path: LocalModule}`` with + ``source`` populated. Raises ValueError on cycles, missing modules, + unsafe paths, or cross-language imports. + """ + available = self.discover() + same_lang = { + "javascript": {"javascript", "typescript"}, + "python": {"python"}, + }[language] + reachable: dict[str, LocalModule] = {} + + def visit(rel: str, chain: tuple[str, ...]) -> None: + # Cheap path-shape rejection before any disk lookup. + if (".." in rel.split("/")) or rel.startswith("/") or "\\" in rel: + raise ValueError(f"pm.require: unsafe local path {rel!r}") + if rel in chain: + raise ValueError(f"local module cycle: {' -> '.join((*chain, rel))}") + if rel in reachable: + return + mod = available.get(rel) + if mod is None: + raise ValueError( + f"pm.require: local module {rel!r} not found in {self._root}" + ) + if mod.language not in same_lang: + raise ValueError( + f"pm.require: local module {rel!r} is {mod.language}; " + f"cannot be imported from {language}" + ) + src = mod.path.read_text(encoding="utf-8") + reachable[rel] = mod.with_source(src) + for inner in scan_specs(src): + visit(inner, (*chain, rel)) + + for n in scan_specs(user_source): + visit(n, ()) + return reachable + + def _is_safe(self, p: Path) -> bool: + """Rejects anything whose resolved real path escapes root. + + ``self._root`` is already ``resolve()``d in ``__init__`` so both + sides of ``relative_to`` are canonical (no symlink-in-the-root + edge case). ``p.resolve(strict=True)`` follows symlinks; if the + target lives outside the canonical root, ``relative_to`` raises. + """ + try: + resolved = p.resolve(strict=True) + except (FileNotFoundError, RuntimeError, OSError): + return False + try: + resolved.relative_to(self._root) + except ValueError: + return False + return True +``` + +Acceptance criteria: +- `discover()` lists only top-level `.js`/`.ts`/`.py` files. +- Two files `foo.js` + `foo.ts` → raises. +- > 500 files → raises. +- Symlink whose target is outside root → not included. +- File named `../escape.js` (via os call, not panel) → not included. + +### 1.2 — Modify `src/services/scripting/runtime_settings.py` + +Use the module's existing `_get_settings()` helper (line 128) — **do not** call `QSettings()` directly: that would write to a different namespace and break test isolation. + +Find the block of `_KEY_*` constants. Add: + +```python +_KEY_LOCAL_MODULES_DIR = "scripting/local_modules_dir" +``` + +Add these methods to `RuntimeSettings` (style copied from `deno_path()` / `set_deno_path()`): + +```python +@staticmethod +def local_modules_dir() -> Path: + s = _get_settings() + raw = str(s.value(_KEY_LOCAL_MODULES_DIR, "") or "") + p = Path(raw).expanduser() if raw else _default_local_modules_dir() + p.mkdir(parents=True, exist_ok=True) + return p + +@staticmethod +def set_local_modules_dir(p: Path) -> None: + s = _get_settings() + s.setValue(_KEY_LOCAL_MODULES_DIR, str(p)) +``` + +For the default path, **reuse the existing per-OS helper used by `DenoManager.runtime_dir()`** (see [src/services/scripting/deno_manager.py](../../src/services/scripting/deno_manager.py) around lines 80-90). Either: +- Extract that helper into a shared module function (`_user_data_dir() -> Path`) and call it from both, or +- Make `_default_local_modules_dir()` import `DenoManager` and use its base dir. + +Preferred: extract a small `_postmark_user_data_dir() -> Path` shared helper to avoid Windows/Linux drift. Add it in `runtime_settings.py`: + +```python +def _postmark_user_data_dir() -> Path: + """Returns the OS data dir base used across the scripting layer. + Single source of truth; DenoManager.runtime_dir() should delegate here too. + """ + import os, sys + if sys.platform.startswith("linux"): + base = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share").expanduser() + elif sys.platform == "darwin": + base = Path("~/Library/Application Support").expanduser() + elif sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA") or "~/AppData/Local").expanduser() + else: + base = Path("~/.local/share").expanduser() + return base / "postmark" + + +def _default_local_modules_dir() -> Path: + return _postmark_user_data_dir() / "scripts" +``` + +If `DenoManager.runtime_dir()` already inlines this logic, also refactor it to call `_postmark_user_data_dir()` in the same PR — single source of truth. + +Acceptance: +- `RuntimeSettings.local_modules_dir()` returns a `Path` that exists on disk. +- Setting a custom path persists across app restarts (verified by writing, dropping the `_get_settings()` reference, re-reading via a fresh `_get_settings()` instance). + +### 1.3 — Tests `tests/unit/services/test_local_modules_resolver.py` + +New file. Cases: +1. `test_default_dir_created` — `RuntimeSettings.local_modules_dir()` exists after call. +2. `test_discover_returns_js_ts_py_only` — drop `.txt`, `.md` files: not included. +3. `test_discover_skips_subdirs` — file inside a subfolder is not returned. +4. `test_discover_ambiguous_name_raises` — both `foo.js` and `foo.ts` → ValueError. +5. `test_discover_cap_raises` — create 501 files → ValueError. +6. `test_discover_rejects_symlink_outside_root` — symlink `link.js → /etc/passwd` not in result. +7. `test_resolve_required_transitive_js` — file A requires B; user script requires A → both in result. +8. `test_resolve_required_cycle_raises` — A requires B, B requires A → ValueError with cycle message. +9. `test_resolve_required_missing_raises` — user requires `local:missing` → ValueError naming missing. +10. `test_resolve_required_cross_language_raises` — Python user requires JS module → ValueError. + +### 1.4 — Tests `tests/unit/services/test_runtime_settings.py` (extend) + +Add: +- `test_local_modules_dir_default` — unset → returns default per-OS path under `postmark/scripts/`. +- `test_local_modules_dir_roundtrip` — set then get returns the same path. +- `test_local_modules_dir_autocreates` — getter creates the folder. + +PR 1 ships once the resolver tests and settings tests pass. No UI, no runtime change. + +--- + +## Section 2 — JS runtime `local:` support (PR 2) + +### 2.1 — Modify `src/services/scripting/js_runtime.py` + +**Specifier shape (extension mandatory)**: `pm.require("local:.js")` or `pm.require("local:.ts")`. Two regexes — keep registry detection clean: + +```python +_PM_REQUIRE_REGISTRY_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]""" + r"""(?Pnpm|jsr):(?P@?[\w./-]+?)""" + r"""(?:@(?P[^'"]+))?['"]\s*\)""", +) +# Local: accepts one or more segments separated by ``/``. Each segment must +# start with a letter/underscore/digit and contain only word chars / dots / dashes. +# No ``..``, no leading ``/``, no backslashes. +_PM_REQUIRE_LOCAL_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]local:""" + r"""(?P[A-Za-z0-9_][\w.-]*(?:/[A-Za-z0-9_][\w.-]*)*)\.(?Pjs|ts)""" + r"""['"]\s*\)""", +) +_NPM_NAME_RE = re.compile(r"^(@[a-z0-9][\w.-]*/)?[a-z0-9][\w.-]*(/[\w./-]+)?$", re.IGNORECASE) +_EXACT_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+([-+][\w.\-+]+)?$") +``` + +`PmRequireSpec` keeps three fields. For `local:` entries: `name` holds the **relative POSIX path without extension** (e.g. `"utils/jwt"`); `version` holds the suffix (`.js` / `.ts`). For registry entries: same as before (package name + version). + +```python +class PmRequireSpec(NamedTuple): + registry: str # "npm" | "jsr" | "local" + name: str # package (npm/jsr) or stem path "utils/jwt" (local) + version: str # version (npm/jsr) or ".js"/".ts" suffix (local) + + @property + def rel_path(self) -> str: + """Relative POSIX path with extension (``local:`` only).""" + assert self.registry == "local" + return f"{self.name}{self.version}" + + @property + def specifier(self) -> str: + if self.registry == "local": + return f"local:{self.rel_path}" + if self.version: + return f"{self.registry}:{self.name}@{self.version}" + return f"{self.registry}:{self.name}" + + @property + def ident(self) -> str: + """Safe identifier suffix for generated ``__pm_req_*`` symbols. + + Slashes and dots collapse to underscores so ``utils/jwt.js`` becomes + ``utils_jwt_js``. + """ + if self.registry == "local": + raw = f"local_{self.name}_{self.version.lstrip('.')}" + else: + raw = f"{self.registry}_{self.name}_{self.version or 'latest'}" + return re.sub(r"[^A-Za-z0-9_]", "_", raw) +``` + +`_detect_pm_require_specs` runs both regexes: + +```python +def _detect_pm_require_specs(script: str) -> list[PmRequireSpec]: + seen: dict[tuple[str, str, str], PmRequireSpec] = {} + for m in _PM_REQUIRE_REGISTRY_RE.finditer(script): + reg, name, ver = m.group("reg"), m.group("name"), m.group("ver") or "" + if not _NPM_NAME_RE.match(name): + raise ValueError(f"pm.require: invalid {reg} package name {name!r}") + if ver and not _EXACT_VERSION_RE.match(ver): + raise ValueError( + f"pm.require: version must be exact (got {ver!r}). " + "Ranges and tags like '^1.0' or 'latest' are not supported." + ) + seen[(reg, name, ver)] = PmRequireSpec(reg, name, ver) + for m in _PM_REQUIRE_LOCAL_RE.finditer(script): + path, ext = m.group("path"), m.group("ext") + suf = f".{ext}" + seen[("local", path, suf)] = PmRequireSpec("local", path, suf) + return list(seen.values()) + + +def _iter_pm_require_local_paths(source: str) -> Iterable[str]: + """Yield unique local **relative paths with extension** for the resolver. + + e.g. ``"jwt.js"``, ``"utils/jwt.js"``. + """ + seen: set[str] = set() + for m in _PM_REQUIRE_LOCAL_RE.finditer(source): + rel = f"{m.group('path')}.{m.group('ext')}" + if rel not in seen: + seen.add(rel) + yield rel +``` + +**Modify** `_pm_require_imports_block` (currently around line 148). Local file layout in the bundle workdir mirrors the disk tree under a `local/` subfolder so relative imports resolve naturally: + +``` +workdir/ +├── bundle.mjs ← the user-script bundle +├── local/ +│ ├── jwt.js ← copied from /jwt.js +│ ├── utils/ +│ │ └── jwt.js ← copied from /utils/jwt.js +│ └── auth/ +│ └── oauth/ +│ └── google.ts +``` + +So the emitted static import is `from "./local/utils/jwt.js"`. No file-renaming, no name flattening — disk path === bundle path. + +```python +def _pm_require_imports_block( + specs: list[PmRequireSpec], + local_paths: set[str] | None = None, +) -> str: + """Emit static ESM imports plus globalThis.__pm_require_modules registration. + + For ``local:`` specs, the caller MUST have written the source file to + ``/local/`` before invoking Deno (see ``deno_runtime``). + ``local_paths`` is the resolved closure (set of rel POSIX paths with + extension) — used to validate that every emitted import has a backing file. + """ + if not specs: + return "" + lines: list[str] = [] + entries: list[str] = [] + local_paths = local_paths or set() + for s in specs: + var = f"__pm_req_{s.ident}" + if s.registry == "local": + rel = s.rel_path + if rel not in local_paths: + raise ValueError( + f"pm.require: local module {rel!r} is not in the resolved closure" + ) + lines.append(f"import * as {var} from \"./local/{rel}\";") + entries.append(f" {json.dumps(s.specifier)}: {var}.default ?? {var}") + else: + lines.append(f"import * as {var} from {json.dumps(s.specifier)};") + entries.append(f" {json.dumps(s.specifier)}: {var}.default ?? {var}") + bare = f"{s.registry}:{s.name}" + if s.version and bare != s.specifier: + entries.append(f" {json.dumps(bare)}: {var}.default ?? {var}") + lines.append("globalThis.__pm_require_modules = Object.assign(") + lines.append(" globalThis.__pm_require_modules || {}, {") + lines.append(",\n".join(entries)) + lines.append("});") + return "\n".join(lines) + "\n" +``` + +### 2.2 — Modify `src/services/scripting/deno_runtime.py` + +**Critical algorithm change**: registry specifier detection must scan the union of user source + every reachable local module source. Otherwise `local:auth` calling `pm.require("npm:jose@5.2.0")` would never appear in the bundle. + +**Find** `_build_bundle_text` (around line 281) and `build_debug_bundle_text` (around line 321). Replace the specifier-detection step with this two-pass algorithm: + +```python +from services.scripting.local_modules import LocalModuleResolver +from services.scripting.js_runtime import _iter_pm_require_local_paths + +# Step 1: resolve local closure (yields paths with extension). +resolver = LocalModuleResolver() +local_mods = resolver.resolve_required( + user_source, _iter_pm_require_local_paths, language="javascript" +) + +# Step 2: union scan for registry/jsr specifiers (user + all local sources). +union_source = user_source + "\n" + "\n".join(m.source for m in local_mods.values()) +specs = _detect_pm_require_specs(union_source) + +# Split for emission: locals get relative file imports; npm/jsr get static imports. +registry_specs = [s for s in specs if s.registry in ("npm", "jsr")] +local_paths_set = set(local_mods.keys()) +local_specs_for_emit = [] +for rel in local_mods: + # Split rel "utils/jwt.js" → name="utils/jwt", version=".js" + base, _, ext = rel.rpartition(".") + local_specs_for_emit.append(PmRequireSpec("local", base, f".{ext}")) + +imports_block = _pm_require_imports_block( + registry_specs + local_specs_for_emit, local_paths=local_paths_set +) + +# `needs_net` derives from the SAME union-scanned specs so .npmrc + --allow-net +# stay in sync with what the bundle actually imports. +needs_net = any(s.registry in ("npm", "jsr") for s in specs) + +# Canonical 3-tuple return contract. +return bundle_text, local_mods, needs_net +``` + +**Error policy for `_build_bundle_text`.** All failures (invalid specifier, missing local, cycle, cross-language) propagate as `ValueError` from `_build_bundle_text`. The caller (`_run_bundle`) catches and converts to `_error_output(str(exc))`. **Do not** mix `ValueError` raises with `_error_output(...)` returns inside `_build_bundle_text`. + +**Find** `_run_bundle` (around line 471). Unpack the new 3-tuple; convert errors here; mirror the disk layout under `tdir/local/`: + +```python +try: + bundle_text, local_mods, needs_net = _build_bundle_text(...) +except ValueError as exc: + return _error_output(str(exc)) + +with tempfile.TemporaryDirectory(prefix="postmark-deno-") as tdir: + tdir_path = Path(tdir) + local_root = tdir_path / "local" + for rel, mod in local_mods.items(): + # rel is POSIX-style "utils/jwt.js" — preserves the original tree + # so the bundle's `import "./local/utils/jwt.js"` resolves. + dest = local_root / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(mod.source, encoding="utf-8") + argv, env = deno_ipc_argv_and_env(..., needs_net=needs_net, ...) + # ... existing bundle.mjs write, Deno spawn, etc. +``` + +**Verify** the `--allow-read=` argument already covers `tdir`. Look at `deno_ipc_argv_and_env` (around lines 180-225) — it should already include the workdir. If yes, no change. + +**`needs_net` — single source of truth.** Today `deno_ipc_argv_and_env` derives `needs_net` from `script_for_network_scan` (the user script only). Update its signature to accept `needs_net` as an **explicit parameter** instead of recomputing internally. Remove (or gate) any internal call to `_detect_pm_require_specs(script_for_network_scan)` that derives `needs_net` from the user script alone. + +**Every caller of `deno_ipc_argv_and_env` must pass the new `needs_net`** — including: +- `_run_bundle` (this PR) +- `build_debug_bundle_text` path / `deno_debug.py` callers (parity bullet below) +- Any utility helper that wraps spawn — grep with `grep -rn "deno_ipc_argv_and_env" src/` to catch them all. + +**ValueError policy.** Today an invalid specifier from `_detect_pm_require_specs` may force `needs_net = True` as a safe fallback (so the run fails with a clear network-permission error rather than silently dropping the spec). Under the union scan, an invalid specifier in *any* local module must instead **fail the run upfront** via `ValueError → _error_output`. Do **not** widen network access on parse failure — surface the error. + +**Interaction with private `.npmrc`** (shipped earlier): the per-execution `.npmrc` is only emitted when `needs_net` is True. With only `local:` specs, `needs_net=False` → no `.npmrc` written, no `--node-modules-dir` needed. Verify by reading the `.npmrc` emission code in `deno_ipc_argv_and_env` and gating it on the new explicit `needs_net` parameter. + +**Debug bundle parity.** `build_debug_bundle_text` (line 321) and the debug code path in [src/services/scripting/debug/deno_debug.py](../../src/services/scripting/debug/deno_debug.py) must use the **same** union scan + local-file materialization + `needs_net` computation as `_build_bundle_text`. Recommended: extract the shared algorithm into a helper + +```python +def _resolve_locals_and_specs(user_source: str) -> tuple[ + list[PmRequireSpec], # union specs + dict[str, LocalModule], # local closure + bool, # needs_net +]: + ... +``` + +called from both `_build_bundle_text` and `build_debug_bundle_text`. The debug spawn path must also pass `needs_net=...` into `deno_ipc_argv_and_env`. + +Acceptance for PR 2: running the same script through Send (normal) and Debug must produce identical local-module file writes and identical `needs_net` outcomes. Add a test `test_debug_bundle_matches_normal_bundle_for_local_modules`. + +### 2.3 — Tests for the unified API contract + +(Tests for the resolver and union-scan behavior are in §2.4 below; this section is intentionally short — error handling lives entirely in `_run_bundle`.) + +### 2.4 — Tests `tests/unit/services/test_pm_require_local_js.py` + +New file. Cases: +1. `test_regex_accepts_top_level_file` — `_PM_REQUIRE_LOCAL_RE.search('pm.require("local:foo.js")')` matches with `path=foo`, `ext=js`. +2. `test_regex_accepts_subdir_path` — `'pm.require("local:utils/jwt.ts")'` matches with `path=utils/jwt`, `ext=ts`. +3. `test_regex_accepts_deep_path` — `'pm.require("local:auth/oauth/google.ts")'` matches with `path=auth/oauth/google`. +4. `test_regex_rejects_local_without_extension` — `'pm.require("local:foo")'` returns no `local` specs. +5. `test_regex_rejects_local_with_version` — `'pm.require("local:foo.js@1.2.3")'` does not match. +6. `test_regex_rejects_dotdot_in_path` — `'pm.require("local:../escape.js")'` does not match (regex doesn't allow `..`). +7. `test_regex_rejects_leading_slash` — `'pm.require("local:/etc/foo.js")'` does not match. +8. `test_imports_block_emits_relative_import` — `_pm_require_imports_block([PmRequireSpec("local","utils/jwt",".ts")], local_paths={"utils/jwt.ts"})` contains `from "./local/utils/jwt.ts"`. +9. `test_imports_block_rejects_unresolved_local` — passing a spec whose path isn't in `local_paths` raises ValueError. +10. `test_build_bundle_writes_local_files_preserving_tree` — fake `local_modules_dir` with `utils/jwt.ts`. Build bundle. Assert `tdir/local/utils/jwt.ts` exists. +11. `test_transitive_local_require_resolved` — `local:utils/a.js` requires `local:utils/b.js`. User script requires `local:utils/a.js`. Both files appear under `tdir/local/utils/`. +12. `test_union_scan_picks_up_registry_specs_inside_local` — `local:auth/oauth.js` source contains `pm.require("npm:jose@5.2.0")`; user script doesn't. Bundle imports block contains `from "npm:jose@5.2.0"`. +13. `test_missing_local_returns_error_output` — script requires `local:nope.js` → `_error_output` mentions `nope`. +14. `test_extension_mismatch_returns_error_output` — only `foo.ts` on disk; script requires `local:foo.js` → error mentions mismatch. +15. `test_local_only_does_not_set_needs_net` — only `local:` specs → `needs_net` is False. +16. `test_cycle_returns_error_output` — A → B → A → `_error_output` with "cycle". +17. `test_debug_bundle_matches_normal_bundle_for_local_modules` — `_build_bundle_text` and `assemble_debug_bundle_with_meta` agree on `local_mods` + `needs_net` for path-bearing specs. +18. `test_resolver_rejects_symlink_outside_root` — symlink in a subdirectory pointing outside is excluded from `discover()`. + +### 2.5 — Modify `data/scripts/pm_bootstrap.js` + +Find the `pm.require` implementation (around lines 1019-1060). In the existing "module not found" error branch, add a hint for `local:` prefix: + +```js +if (spec.startsWith("local:")) { + throw new Error( + `pm.require(${JSON.stringify(spec)}): local module not found. ` + + `Create a file named ${spec.slice(6)}.js or ${spec.slice(6)}.ts ` + + `in your local modules folder.` + ); +} +// existing error path for npm/jsr ... +``` + +PR 2 ships once `test_pm_require_local_js.py` is green and a manual JS smoke works (see Verification). + +--- + +## Section 3 — Python runtime `local:` support (PR 3) + +### 3.1 — Modify `src/services/scripting/py_runtime.py` + +**Specifier shape**: `pm.require("local:.py")` where `` is one or more `/`-separated **Python-identifier** segments (so `importlib` can register dotted names like `__pm_local_utils.jwt`). + +Add a second regex (the existing `_PM_REQUIRE_PY_RE` matches only bare PyPI names). Insert after the existing regex (line 53): + +```python +_PM_REQUIRE_PY_LOCAL_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]local:""" + r"""(?P[A-Za-z_][A-Za-z0-9_]*(?:/[A-Za-z_][A-Za-z0-9_]*)*)\.py""" + r"""['"]\s*\)""", +) +``` + +**Modify** `_PM_REQUIRE_PY_RE` so it cannot accidentally match `local:foo.py`. Add a negative lookahead to the bare-name regex: + +```python +_PM_REQUIRE_PY_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]""" + r"""(?!local:)(?P[a-z0-9][a-z0-9._-]*)""" + r"""(?:==(?P[^'"]+))?['"]\s*\)""", + re.IGNORECASE, +) +``` + +**Extend** `PmPyRequireSpec` with a `kind` field. Put `kind` first so test constructors read naturally (`PmPyRequireSpec("pip", "requests", "2.31.0")` reads better than `PmPyRequireSpec("requests", "2.31.0", "pip")`): + +```python +class PmPyRequireSpec(NamedTuple): + kind: Literal["pip", "local"] + name: str # PyPI package or local stem + version: str # version (pip only — empty for local) + + @property + def pip_spec(self) -> str: + if self.kind == "local": + raise ValueError("local modules have no pip spec") + return f"{self.name}=={self.version}" if self.version else self.name +``` + +**Modify** `detect_pm_require_py_specs`: + +```python +def detect_pm_require_py_specs(source: str) -> list[PmPyRequireSpec]: + seen: dict[tuple[str, str, str], PmPyRequireSpec] = {} + for m in _PM_REQUIRE_PY_LOCAL_RE.finditer(source): + path = m.group("path") # e.g. "utils/jwt" + seen[("local", path, "")] = PmPyRequireSpec("local", path, "") + for m in _PM_REQUIRE_PY_RE.finditer(source): + name = m.group("name").lower() + ver = m.group("ver") or "" + if ver and not _PY_EXACT_VERSION_RE.match(ver): + raise ValueError( + f"pm.require: version must be exact (got {ver!r})." + ) + seen[("pip", name, ver)] = PmPyRequireSpec("pip", name, ver) + return list(seen.values()) + + +def _iter_pm_require_py_local_paths(source: str) -> Iterable[str]: + """Yield unique local **relative paths with extension** for the resolver. + + e.g. ``"jwt.py"``, ``"utils/jwt.py"`` — matches the resolver's key format. + """ + seen: set[str] = set() + for m in _PM_REQUIRE_PY_LOCAL_RE.finditer(source): + rel = f"{m.group('path')}.py" + if rel not in seen: + seen.add(rel) + yield rel +``` + +### 3.2 — Modify `src/services/scripting/pyodide_runtime.py` + +**Find** `PyodideRuntime.execute` (currently lines 192-264). Replace specifier detection with the same two-pass algorithm as JS (resolve locals → union scan for pip specs): + +```python +from services.scripting.py_runtime import ( + detect_pm_require_py_specs, _iter_pm_require_py_local_stems, +) +from services.scripting.local_modules import LocalModuleResolver + +# Step 1: resolve local closure. +resolver = LocalModuleResolver() +try: + local_mods = resolver.resolve_required( + script, _iter_pm_require_py_local_stems, language="python" + ) +except ValueError as exc: + return _err(str(exc)) + +# Step 2: scan pip specs across user + all local sources. +union_source = script + "\n" + "\n".join(m.source for m in local_mods.values()) +try: + all_specs = detect_pm_require_py_specs(union_source) +except ValueError as exc: + return _err(str(exc)) +pip_specs = [s.pip_spec for s in all_specs if s.kind == "pip"] + +local_py_modules_payload = {stem: m.source for stem, m in local_mods.items()} +``` + +Then in the payload (lines 224-230) — call the key `local_py_modules` (parallel to existing `pm_require` / `pypi_index_urls`, narrows to "this is the Python local-module bundle"): + +```python +payload = { + "user_script": script, + "context": dict(context), + "pm_require": pip_specs, # PyPI only (drives micropip.install) + "pypi_index_urls": pypi_index_urls, + "local_py_modules": local_py_modules_payload, # NEW: {stem: source} +} +``` + +`needs_net = bool(pip_specs)` — locals never trigger network. + +**RestrictedPython subprocess path** (in `py_runtime.py`, both `PyRuntime.execute` and `PyRuntime.execute_restricted` non-Pyodide branches): before running, scan user source for `_PM_REQUIRE_PY_LOCAL_RE.search(...)`; if any present, return a `_runtime_error_output` with message: `'Local script modules (pm.require("local:.py")) require the Pyodide Python runtime (Deno + vendor_pyodide). The RestrictedPython sandbox cannot load them.'` Tests must cover both paths (see Section 3.6). + +### 3.3 — New file `data/scripts/pm_local_loader.py` + +Runs **inside Pyodide**. Stores under a namespaced **dotted** name so subdirectory layouts map cleanly to Python's module system. Path `utils/jwt.py` → `__pm_local_utils.jwt`. Never registers under the bare segment alone, so stdlib (`json`, `re`, …) cannot be shadowed. + +```python +r"""Register ``pm.require("local:.py")`` modules under ``__pm_local_.``. + +Loaded by ``pyodide_run.mjs`` before ``pm_bootstrap.py``. Sources arrive as +``{rel_posix_path: source_text}`` from the host. Each is exec'd into a fresh +module and registered under ``sys.modules`` using a dotted name derived from +the relative path (slashes → dots, ``.py`` stripped). The top-level +``__pm_local_`` package node is created on first call so dotted lookups work. +""" +from __future__ import annotations + +import re +import sys +import types + +_PACKAGE_ROOT = "__pm_local_" +_SAFE_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _ensure_package(dotted: str) -> None: + """Create empty parent package modules so dotted import resolves.""" + parts = dotted.split(".") + for i in range(1, len(parts)): + parent = ".".join(parts[:i]) + if parent not in sys.modules: + mod = types.ModuleType(parent) + mod.__path__ = [] # mark as package + sys.modules[parent] = mod + + +def register_pm_local_modules(sources: dict[str, str]) -> None: + """Register each *rel_path → source* pair under ``__pm_local_.``. + + Example: ``{"utils/jwt.py": "..."}`` becomes ``sys.modules["__pm_local_.utils.jwt"]``. + Raises ValueError for invalid segments, duplicate registration, or unsafe paths. + """ + # Ensure the root package node exists. + if _PACKAGE_ROOT not in sys.modules: + root = types.ModuleType(_PACKAGE_ROOT) + root.__path__ = [] + sys.modules[_PACKAGE_ROOT] = root + + for rel, src in sources.items(): + if not rel.endswith(".py") or ".." in rel.split("/") or rel.startswith("/"): + raise ValueError(f"invalid local module path {rel!r}") + segments = rel[:-3].split("/") # strip .py + for seg in segments: + if not _SAFE_SEGMENT.match(seg): + raise ValueError(f"invalid segment {seg!r} in {rel!r}") + dotted = f"{_PACKAGE_ROOT}." + ".".join(segments) + if dotted in sys.modules: + raise ValueError(f"local module {dotted!r} is already registered") + _ensure_package(dotted) + module = types.ModuleType(dotted) + module.__file__ = f"" + exec(compile(src, module.__file__, "exec"), module.__dict__) + sys.modules[dotted] = module +``` + +### 3.4 — Modify `data/scripts/pm_bootstrap.py` + +Find `pm.require` (around line 1104). Prepend a `local:` branch **before** the existing pip-import path. Resolves via dotted name derived from the specifier path: `local:utils/jwt.py` → `__pm_local_.utils.jwt`. + +```python +def require(self, spec): + if not isinstance(spec, str): + raise RuntimeError("pm.require: specifier must be a string") + raw = spec.strip() + if raw.startswith("local:"): + body = raw[len("local:"):] + if not body.endswith(".py"): + raise RuntimeError( + 'pm.require: local Python modules must use an explicit ".py" suffix' + ) + if ".." in body.split("/") or body.startswith("/"): + raise RuntimeError(f"pm.require: unsafe local path {raw!r}") + segments = body[:-3].split("/") + if not segments or any(not s for s in segments): + raise RuntimeError("pm.require: empty local module name") + dotted = "__pm_local_." + ".".join(segments) + try: + return importlib.import_module(dotted) + except ModuleNotFoundError as exc: + raise RuntimeError( + f"pm.require({spec!r}): local module not registered " + f"(expected importable {dotted!r})" + ) from exc + # ... existing path: importlib.import_module(spec) etc. +``` + +### 3.5 — Modify `data/scripts/pyodide_run.mjs` + +Locate `main()` (around line 130-200). After `micropip.install(...)` and **before** `pm_bootstrap.py` is loaded: + +```javascript +// After micropip.install(...) of pip specs: +const localMap = + inp.local_py_modules && + typeof inp.local_py_modules === "object" && + !Array.isArray(inp.local_py_modules) + ? inp.local_py_modules + : {}; +if (Object.keys(localMap).length > 0) { + const loaderPath = join(_here, "pm_local_loader.py"); + const loaderSrc = readFileSync(loaderPath, { encoding: "utf-8" }); + await pyodide.runPythonAsync(loaderSrc); + const localsJson = JSON.stringify(localMap); + await pyodide.runPythonAsync( + `register_pm_local_modules(__import__("json").loads(${JSON.stringify( + localsJson, + )}))`, + ); +} +// then existing: load pm_bootstrap.py, then exec user_script +``` + +Update the file's docstring at the top to mention the new payload field: +``` +// Stdin: one JSON line +// { user_script, context, pm_require: string[], pypi_index_urls?: string[], +// local_py_modules?: { [stem: string]: string } } +``` + +### 3.6 — Tests `tests/unit/services/test_pm_require_local_py.py` + +New file. Cases: +1. `test_detect_specs_recognizes_local` — `detect_pm_require_py_specs('pm.require("local:util.py")')` returns one spec `PmPyRequireSpec("local","util","")`. +2. `test_detect_specs_keeps_pip_separate` — mixed `pm.require("requests")` + `pm.require("local:util.py")` returns one of each kind. +3. `test_detect_specs_rejects_local_without_py_suffix` — `pm.require("local:foo")` is not matched as either kind; `_iter_pm_require_py_local_stems` yields nothing for it. +4. `test_pip_regex_does_not_match_local_prefix` — negative lookahead works: `pm.require("local:foo.py")` does not produce a pip spec. +5. `test_payload_includes_local_py_modules` — mock `LocalModuleResolver` + `subprocess.Popen`; parsed payload JSON contains `local_py_modules: {"util": "..."}`. +6. `test_pip_specs_unaffected_by_local` — when both kinds present, `pm_require` field contains only pip specs. +7. `test_payload_union_scans_for_pip` — local module source contains `pm.require("requests==2.31")`; user script does not. Payload `pm_require` field contains `requests==2.31` (union scan). +8. `test_pm_local_loader_registers_under_namespace` — call `register_pm_local_modules({"foo": "x=1"})`; assert `sys.modules["__pm_local_foo"]` has `x == 1`; assert `sys.modules.get("foo") is None`. +9. `test_pm_local_loader_does_not_shadow_stdlib` — `register_pm_local_modules({"json": "POISONED=True"})`; verify `import json` still returns the real stdlib `json`. +10. `test_pm_local_loader_rejects_invalid_stem` — `register_pm_local_modules({"123bad": "x=1"})` raises `ValueError`. +11. `test_pm_local_loader_rejects_duplicate_registration` — calling `register_pm_local_modules({"foo": "x=1"})` twice raises (defensive). +12. `test_pm_bootstrap_local_branch_uses_namespaced_lookup` — register `__pm_local_foo`; call `pm.require("local:foo.py")`; assert identity. +13. `test_pm_bootstrap_local_branch_rejects_missing_py_suffix` — `pm.require("local:foo")` raises with the "explicit '.py' suffix" message. +14. `test_missing_local_returns_error` — user script requires `local:nope.py` with empty modules dir → `PyodideRuntime.execute` returns `{"error": ...}` mentioning `nope`. +15. `test_execute_path_rejects_local` — invoke `PyRuntime.execute` (non-Pyodide branch) with a script containing `pm.require("local:foo.py")`; assert error result includes "Pyodide". +16. `test_execute_restricted_path_rejects_local` — same for `PyRuntime.execute_restricted`. +17. `test_cross_language_python_requires_js_raises` — `local:helper.js` exists, user Python script requires `local:helper.py` → resolver "not found" error (file with that stem+`.py` doesn't exist; the `.js` file is ignored for Python). + +### 3.7 — Modify `tests/unit/services/test_pm_python_parity.py` + +Add cases: +- Both JS regex and Python regex detect their respective `local:` shapes. +- Both regexes reject `local:foo` (no extension). +- Both regexes reject `local:foo.txt` (wrong extension). + +PR 3 ships once Python tests are green and a manual Python smoke works. + +--- + +## Section 4 — Left-pane toggle row (PR 4) + +### 4.0 — Architecture diagram (Cursor primary-sidebar pattern) + +**Before** (today): +``` +┌────────────────┬───────────────┬───────────────┐ +│ collection_ │ request + │ right │ +│ widget │ response │ sidebar │ +│ │ │ │ +└────────────────┴───────────────┴───────────────┘ + splitter slot 0 slot 1 slot 2 +``` + +**After** (this PR — slot 0 only changes its contents): +``` +┌────────────────┬───────────────┬───────────────┐ +│ LeftSidebarPane│ request + │ right │ +│ ┌────────────┐ │ response │ sidebar │ +│ │[📁][] │ │ (unchanged) │ (unchanged) │ +│ ├────────────┤ │ │ │ +│ │ Stack page0│ │ │ │ +│ │ Collection │ │ │ │ +│ │ Widget │ │ │ │ +│ │ (verbatim) │ │ │ │ +│ │ OR │ │ │ │ +│ │ Stack page1│ │ │ │ +│ │ Scripts │ │ │ │ +│ │ Panel │ │ │ │ +│ └────────────┘ │ │ │ +└────────────────┴───────────────┴───────────────┘ + slot 0 slot 1 slot 2 +``` + +`main_splitter` keeps **exactly the same number of slots** (3, as today). Slot 0's child widget changes from `CollectionWidget` directly → `LeftSidebarPane` wrapper. Inside the wrapper, the existing `CollectionWidget` is page 0 of a `QStackedWidget`; the new `ScriptsPanel` is page 1. The toggle row sits above the stack. + +This is the Cursor primary-sidebar pattern: icon row at top of the pane, content below. + +### 4.1 — New file `src/ui/sidebar/left_pane.py` + +Single widget — `LeftSidebarPane(QWidget)`. Owns the toggle row + a `QStackedWidget` for content. Takes the existing `CollectionWidget` instance and a new `ScriptsPanel` instance via constructor (no re-parent gymnastics). + +```python +from __future__ import annotations + +from PySide6.QtCore import QSettings, Qt, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QSizePolicy, QStackedWidget, QToolButton, QVBoxLayout, QWidget, +) + +from ui.styling.icons import phi + + +_TOGGLE_BTN_HEIGHT = 24 +_TOGGLE_ROW_PADDING = 4 + + +class LeftSidebarPane(QWidget): + """Horizontal toggle row at the top + stacked content below. + + Pages: 0 = Collections (existing CollectionWidget), 1 = Scripts (ScriptsPanel). + """ + + panel_changed = Signal(str) # emits "collections" or "scripts" + + def __init__( + self, + collections_widget: QWidget, + scripts_widget: QWidget, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("leftSidebarPane") + + # --- Toggle row ----------------------------------------------- + row = QWidget(self) + row.setObjectName("leftPaneToggleRow") + row.setFixedHeight(_TOGGLE_BTN_HEIGHT + _TOGGLE_ROW_PADDING * 2) + row_lay = QHBoxLayout(row) + row_lay.setContentsMargins( + _TOGGLE_ROW_PADDING, _TOGGLE_ROW_PADDING, + _TOGGLE_ROW_PADDING, _TOGGLE_ROW_PADDING, + ) + row_lay.setSpacing(4) + + # Both buttons MUST have an icon. Icons make Cursor's pattern legible + # at a glance; an iconless toggle row is unacceptable. + # Use phi() (Phosphor icon font). Verify the icon names exist in + # data/fonts/phosphor-charmap.json before PR 4 — fallbacks listed below. + self._collections_btn = self._make_toggle_btn( + "tree-structure", "Collections (Ctrl+1)" + ) + # Phosphor icon options for the Scripts button — pick whichever reads + # cleanest at 16px on this app's theme: "code", "file-code", + # "code-block", or "brackets-curly". + self._scripts_btn = self._make_toggle_btn( + "code", "Scripts (Ctrl+2)" + ) + row_lay.addWidget(self._collections_btn) + row_lay.addWidget(self._scripts_btn) + row_lay.addStretch(1) + + # --- Content stack -------------------------------------------- + self._stack = QStackedWidget(self) + self._stack.addWidget(collections_widget) # index 0 + self._stack.addWidget(scripts_widget) # index 1 + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + outer.addWidget(row) + outer.addWidget(self._stack, 1) + + self._collections_widget = collections_widget + self._scripts_widget = scripts_widget + self._active: str = "collections" + + self._collections_btn.clicked.connect( + lambda: self.set_active_panel("collections") + ) + self._scripts_btn.clicked.connect( + lambda: self.set_active_panel("scripts") + ) + + # Restore last active panel. + s = self._qsettings() + restored = str(s.value("ui/left_pane/active", "collections") or "collections") + self.set_active_panel(restored if restored in {"collections", "scripts"} else "collections") + + def _make_toggle_btn(self, icon_name: str, tooltip: str) -> QToolButton: + btn = QToolButton(self) + btn.setObjectName("leftPaneToggleButton") + btn.setCheckable(True) + btn.setAutoRaise(True) + btn.setToolTip(tooltip) + btn.setIcon(phi(icon_name, size=16)) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + btn.setFixedSize(_TOGGLE_BTN_HEIGHT + 4, _TOGGLE_BTN_HEIGHT) + return btn + + def _qsettings(self) -> QSettings: + from ui.styling.theme_manager import _ORG, _APP + return QSettings(_ORG, _APP) + + def set_active_panel(self, name: str) -> None: + """Show the named page (``"collections"`` or ``"scripts"``).""" + if name not in {"collections", "scripts"}: + return + self._active = name + self._stack.setCurrentIndex(0 if name == "collections" else 1) + self._collections_btn.setChecked(name == "collections") + self._scripts_btn.setChecked(name == "scripts") + self._qsettings().setValue("ui/left_pane/active", name) + self.panel_changed.emit(name) + + def active_panel(self) -> str: + return self._active + + def collections_widget(self) -> QWidget: + return self._collections_widget + + def scripts_widget(self) -> QWidget: + return self._scripts_widget +``` + +**Notes on the design:** +- The toggle row is a plain `QHBoxLayout` of `QToolButton`s — same pattern Postmark already uses for `CollectionHeader`'s "New" / "Import" buttons. Cheap, no novel infrastructure. +- The content area is a vanilla `QStackedWidget`, the same pattern used by `_editor_stack` / `_response_stack` in `main_window/window.py`. No `QDockWidget`. +- No `install_in_splitter` shenanigans. `LeftSidebarPane` is just a `QWidget` you drop into the splitter where `collection_widget` used to go. +- No collapse behavior — clicking the active button is a no-op (idempotent). The whole left pane collapses via the existing `_toggle_sidebar` action (which hides this `LeftSidebarPane`). + +### 4.2 — QSS styling + +Add to [src/ui/styling/global_qss.py](../../src/ui/styling/global_qss.py) inside the same f-string block that already styles `sidebarToolButton`: + +```python +QWidget#leftPaneToggleRow {{ + background: {p["bg_alt"]}; + border-bottom: 1px solid {p["border"]}; +}} +QToolButton#leftPaneToggleButton {{ + background: transparent; + border: none; + padding: 2px; + border-radius: 4px; + color: {p["text_muted"]}; +}} +QToolButton#leftPaneToggleButton:hover {{ + background: {p["hover_bg"]}; + color: {p["text"]}; +}} +QToolButton#leftPaneToggleButton:checked {{ + background: {p["selected_bg"]}; + color: {p["accent"]}; +}} +``` + +Use existing palette keys only (`bg_alt`, `border`, `hover_bg`, `selected_bg`, `accent`, `text`, `text_muted`). If `text_muted` doesn't exist, grep the file for the actual key name and substitute. + +### 4.3 — Modify `src/ui/main_window/window.py` + +Currently (per Phase-1 exploration, [window.py:469](../../src/ui/main_window/window.py#L469)): +```python +self._main_splitter.addWidget(self.collection_widget) +``` + +Replace with: +```python +from ui.sidebar.left_pane import LeftSidebarPane +from ui.sidebar.scripts_panel import ScriptsPanel + +self._scripts_panel = ScriptsPanel() +self._scripts_panel.file_open_requested.connect(self._open_script_module_tab) +self._left_sidebar_pane = LeftSidebarPane( + collections_widget=self.collection_widget, + scripts_widget=self._scripts_panel, +) +self._main_splitter.addWidget(self._left_sidebar_pane) +``` + +**Keyboard shortcuts** — add after splitter setup: + +```python +from PySide6.QtGui import QShortcut, QKeySequence +QShortcut(QKeySequence("Ctrl+1"), self, + activated=lambda: self._left_sidebar_pane.set_active_panel("collections")) +QShortcut(QKeySequence("Ctrl+2"), self, + activated=lambda: self._left_sidebar_pane.set_active_panel("scripts")) +``` + +**Existing `_toggle_sidebar`** (around [window.py:547](../../src/ui/main_window/window.py#L547)) — keep as-is: it toggles visibility of the whole `LeftSidebarPane` (which is now what occupies the slot `collection_widget` used to occupy). No code change needed there; just verify the reference points to `self._left_sidebar_pane` instead of `self.collection_widget`. + +**Settings → re-rooting hook**: +```python +def _on_local_modules_dir_changed(self) -> None: + self._scripts_panel.refresh_root() +``` +Connect to `SettingsDialog.local_modules_dir_changed` signal (Section 6.7). + +### 4.4 — Tests `tests/ui/sidebar/test_left_pane.py` + +New file. Cases: +1. `test_starts_on_collections_by_default` — fresh QSettings, default page index = 0. +2. `test_set_active_panel_switches_stack` — `set_active_panel("scripts")` → `stack.currentIndex() == 1`. +3. `test_buttons_reflect_active_panel` — after switch, `scripts_btn.isChecked() is True` and `collections_btn.isChecked() is False`. +4. `test_panel_changed_signal_emits` — switching emits `"scripts"`. +5. `test_active_panel_persisted_via_qsettings` — set, recreate widget, restored. +6. `test_clicking_active_button_is_idempotent` — calling `set_active_panel("collections")` twice doesn't toggle off; the pane never enters a no-active state. +7. `test_invalid_panel_name_ignored` — `set_active_panel("bogus")` is a no-op; active stays the same. + +### 4.5 — Tests `tests/ui/test_main_window.py` (extend) + +Two cases: +1. `test_left_pane_is_left_sidebar_pane` — `main_splitter.widget(0)` is a `LeftSidebarPane`. +2. `test_collection_widget_still_accessible_via_left_pane` — `main_window.collection_widget` resolves to the same instance held by `LeftSidebarPane.collections_widget()`. + +PR 4 ships once tests pass + manual smoke: opens app → see toggle row above collections → click Scripts icon → **Scripts tree** appears → click Collections icon → tree back. + +--- + +## Section 5 — Scripts panel (PR 5) + +### 5.-1 — Tree layout (mirrors the Collections tree pattern) + +The Scripts panel is a **tree** (not a flat list) — same visual pattern as the existing collections sidebar. Folders contain files; folders expand/collapse; selection lands on either a file or a folder. + +**Reuse path**: subclass / parametrize the existing collections-tree machinery so the visual feel matches the rest of the app. + +- `CollectionTree` lives at [src/ui/collections/tree/collection_tree.py](../../src/ui/collections/tree/collection_tree.py). +- The base widget is `DraggableTreeWidget` at [src/ui/collections/tree/draggable_tree_widget.py](../../src/ui/collections/tree/draggable_tree_widget.py) — `QTreeWidget` subclass with custom delegate styling. +- The `CollectionTreeDelegate` provides the row styling used app-wide for sidebar trees. + +**Approach**: extract the styling-only bits (delegate + icon set + indent + spacing) into a reusable base. The Scripts panel instantiates that base and populates it from disk. The existing `CollectionTree`'s drag-drop / persistence logic is **not** reused (it persists to SQL; we're persisting to disk by rename). Only the **visual** layer is shared. + +Concretely, the Scripts panel tree: +- Top-level entries = direct children of the local-modules root (folder OR file). +- Folder entries are expandable; their children come from recursive `iterdir()`. +- File entries display the base name (e.g. `jwt.js`) — extension stays visible so users see the language at a glance. +- Sort: folders first (alphabetical), then files (alphabetical). +- Each row's `UserRole` data carries the resolved absolute `Path`. +- Hidden dirs (`.git`, etc.) and unsupported extensions are filtered out — same rules as `LocalModuleResolver.discover()`. + + + +### 5.0 — Required UI mockup (Collections parity + tree) + +**(Amended)** The Scripts page must **look and behave like the Collections sidebar**: a **named section** inside the panel (not only the icon toggle above), **“+ New”** and **“Refresh”** as **link-style / text+beside-icon** actions matching [`CollectionHeader`](../../src/ui/collections/collection_header.py), a **search field** with placeholder (e.g. `Search scripts`) filtering the tree, then the **folder/file tree**. The left icon row (§4) answers “which sidebar page”; the **Scripts header** answers “what am I editing” — same two-level pattern as Collections (rail vs section). + +``` +┌────────────────────────────────────────────────┐ +│ [📁][] ← toggle row from Section 4 │ +├────────────────────────────────────────────────┤ +│ Scripts + New Refresh │ ← row 1: section label + actions (mirror CollectionHeader) +├────────────────────────────────────────────────┤ +│ 🔍 Search scripts… │ ← row 2: QLineEdit (objectName sidebarSearch) +├────────────────────────────────────────────────┤ +│ [ optional compact row: delete / rename / … ] │ ← optional icon row OR overflow ⋯ menu (see 5.0a) +├────────────────────────────────────────────────┤ +│ 📁 auth │ +│ 📄 oauth.ts │ +│ 📄 google.ts │ +│ 📁 utils │ +│ 📄 jwt.js ← selected │ +│ 📄 shout.py │ +│ 📄 jwt.js │ +│ 📄 validators.py │ +│ │ +│ No modules yet. │ ← empty-state (no “subfolders ignored” copy) +│ Click “+ New” to create one. │ +└────────────────────────────────────────────────┘ + │ + └── Right-click on a file row: + ┌────────────────────────────────┐ + │ New file ▸ │ → same flow as + New (or opens Create New dialog) + │ Copy import specifier │ → clipboard local: + │ Reveal in file manager │ + │ ─────────────────────────── │ + │ Rename… │ + │ Delete │ + └────────────────────────────────┘ +``` + +### 5.0a — Required UI elements (reviewer checklist) **(Amended)** + +None of the following are optional. A prior implementation shipped a bare list with no mutations — reject that. + +1. **Scripts header (Collections-shaped)** — `QLabel` “Scripts” (`sidebarSectionLabel`), **`+ New`** (`sidebarToolButton`, text beside icon, opens **Create New** dialog — §5.0b), **`Refresh`** (`sidebarToolButton` or `linkButton`-style) calling the same rescan as the 5s timer, preserving selection when possible. Model directly on `CollectionHeader`’s first row (without Import unless product wants an analogue such as “Open folder”). +2. **Search row** — full-width `QLineEdit`, `sidebarSearch`, leading magnifier action, emits `search_changed` (or equivalent) to filter visible tree rows **without** hiding folder ancestors of matches (standard tree-filter behaviour). +3. **Tree** — as §5.-1: folders, script files, double-click file → `file_open_requested(Path)`; double-click folder expands/collapses only. +4. **Create New dialog (Postman-style)** — **§5.0b**; do **not** use a bare `QInputDialog` as the only UI for creating a module (that was the gap vs Collections). +5. **Context menu** on tree items — New file submenu, Copy import specifier (relative POSIX path under root), Reveal, Rename, Delete; mirror toolbar when a secondary toolbar exists. +6. **Selection-aware controls** — disable delete/rename when inappropriate; disable mutations when `os.access(root, os.W_OK)` is False (tooltip: “Folder is read-only”). +7. **Empty-state label** when no eligible files: e.g. “No modules yet. Click ‘+ New’ to create one.” **Do not** claim subfolders are ignored (they are supported). + +**Optional secondary chrome:** A row of small icon buttons (delete, rename, open folder) may remain for parity with early mockups; if present, it sits **below** the search row. Primary discovery remains **+ New** + **Refresh** + search like Collections. + +### 5.0b — `NewScriptModulePopup` (mirror `NewItemPopup`) **(Amended)** + +Add a modal dialog alongside [`NewItemPopup`](../../src/ui/collections/new_item_popup.py): same window chrome (`newItemPopup`, `newItemTitle`, tile `objectName`s, fixed size ~380×260, centered “What do you want to create?” style copy adapted for **script modules**). **Tiles**: at minimum **JavaScript**, **TypeScript**, **Python** (icons `file-js` / `file-ts` / `file-py` or Phosphor equivalents). Optional fourth tile: **Folder** (creates empty directory under root or under selected folder). After tile choice, prompt for **name** (second step inside same dialog or follow-up — implementation choice) then create `*.js|*.ts|*.py` or `mkdir`. Emit / callback → `file_open_requested` for new files. This satisfies “similar window to Collections New”. + +--- + +### 5.0 (legacy mockup — superseded) + +The ASCII block and numbered list in the **original** §5.0 (five-icon-only toolbar, no Scripts title, `QInputDialog`-only new file, empty-state “Subfolders are ignored”) is **retired**. It is replaced by **§5.0 + §5.0a + §5.0b** above. The rest of this document (§5.1 code, tests, wiring) still applies but **implementations must follow §5.0a layout**; update the §5.1 sample code accordingly (embed `ScriptsHeader`, wire search, replace `_on_new_file` to open `NewScriptModulePopup`). + +### 5.1 — New package `src/ui/sidebar/scripts_panel/` + +Files: +- `__init__.py` — `from .panel import ScriptsPanel` (re-export). +- `panel.py` — main widget (`ScriptsHeader` + search + tree + optional secondary toolbar per §5.0a). +- `scripts_header.py` — **(Amended)** row 1+2 mirroring [`CollectionHeader`](../../src/ui/collections/collection_header.py) (label, + New, Refresh, search field + signals). +- `new_script_popup.py` — **(Amended)** `NewScriptModulePopup` (§5.0b), same chrome as `NewItemPopup`. +- `actions.py` — context menu handlers (`prompt_new_module`, rename, delete, reveal, copy specifier). + +`panel.py`. **Tree layout** (mirrors the collections tree). Use `QTreeWidget` populated by hand via `Path.iterdir()` (not `QFileSystemModel`, so we keep full control of filtering/icons/sort). Walks subdirectories. + +**(Amended)** The sample `_build_ui` below still uses the **legacy five-icon toolbar** for illustration of tree wiring; the shipped widget must embed **`ScriptsHeader` + search + tree** per §5.0a (toolbar row optional). `_on_new_file` must open **`NewScriptModulePopup`** (§5.0b), not only `QInputDialog`. + +```python +import os +from pathlib import Path +from PySide6.QtCore import Qt, Signal, QTimer +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QToolButton, + QTreeWidget, QTreeWidgetItem, QLabel, +) +from services.scripting.runtime_settings import RuntimeSettings +from ui.styling.icons import phi + + +_ALLOWED_EXTS = (".js", ".ts", ".py") +_ROLE_ABSOLUTE_PATH = Qt.ItemDataRole.UserRole + 1 +_ROLE_IS_DIR = Qt.ItemDataRole.UserRole + 2 + + +class ScriptsPanel(QWidget): + file_open_requested = Signal(Path) + file_renamed = Signal(Path, Path) + file_deleted = Signal(Path) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._root = RuntimeSettings.local_modules_dir() + self._build_ui() + self._refresh() + # Cheap poll for outside-of-app changes (5s). + self._poll = QTimer(self) + self._poll.setInterval(5000) + self._poll.timeout.connect(self._refresh) + self._poll.start() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + toolbar = QHBoxLayout() + toolbar.setContentsMargins(4, 4, 4, 4) + toolbar.setSpacing(4) + self._new_btn = self._mk_btn("plus", "New module") + self._delete_btn = self._mk_btn("trash", "Delete selected") + self._rename_btn = self._mk_btn("pencil-simple", "Rename selected") + self._refresh_btn = self._mk_btn("arrow-clockwise", "Refresh list") + self._reveal_btn = self._mk_btn("folder-open", "Open folder in file manager") + for b in (self._new_btn, self._delete_btn, self._rename_btn, + self._refresh_btn, self._reveal_btn): + toolbar.addWidget(b) + toolbar.addStretch(1) + layout.addLayout(toolbar) + + self._empty_label = QLabel( + 'No modules yet. Click "+ New" to create one.' + ) + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_label.setObjectName("scriptsEmptyState") + self._empty_label.hide() + layout.addWidget(self._empty_label) + + self._tree = QTreeWidget() + self._tree.setHeaderHidden(True) + self._tree.setIndentation(14) + self._tree.setExpandsOnDoubleClick(True) + self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemDoubleClicked.connect(self._on_double_click) + self._tree.itemSelectionChanged.connect(self._sync_button_enabled) + layout.addWidget(self._tree, 1) + + self._new_btn.clicked.connect(self._on_new_file) + self._delete_btn.clicked.connect(self._on_delete_selected) + self._rename_btn.clicked.connect(self._on_rename_selected) + self._refresh_btn.clicked.connect(self._refresh) + self._reveal_btn.clicked.connect(self._on_reveal) + + self._delete_btn.setEnabled(False) + self._rename_btn.setEnabled(False) + + def _mk_btn(self, icon_name: str, tooltip: str) -> QToolButton: + btn = QToolButton(self) + btn.setIcon(phi(icon_name, size=16)) + btn.setToolTip(tooltip) + btn.setAutoRaise(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setFixedSize(26, 26) + return btn + + def _sync_button_enabled(self) -> None: + sel = self._selected_path() + writable = os.access(self._root, os.W_OK) + self._delete_btn.setEnabled(sel is not None and writable) + self._rename_btn.setEnabled(sel is not None and writable) + self._new_btn.setEnabled(writable) + + def refresh_root(self) -> None: + """Called when Settings → Local modules path changes.""" + self._root = RuntimeSettings.local_modules_dir() + self._refresh() + + def _refresh(self) -> None: + # Preserve current selection by absolute path so refresh doesn't jump. + prev_sel = self._selected_path() + self._tree.clear() + if not self._root.is_dir(): + self._empty_label.show() + self._tree.hide() + return + has_any = self._populate_node(self._tree.invisibleRootItem(), self._root) + if not has_any: + self._empty_label.show() + self._tree.hide() + return + self._empty_label.hide() + self._tree.show() + if prev_sel is not None: + self._restore_selection(prev_sel) + + def _populate_node(self, parent: QTreeWidgetItem, dir_path: Path) -> bool: + """Recursively populate *parent* with the children of *dir_path*. + + Returns True if at least one descendant was added (so callers can detect + empty subtrees and prune the empty-state). + """ + # Sort: folders first (alpha), then files (alpha). Hidden + non-script files filtered. + children = sorted( + (p for p in dir_path.iterdir() if not p.name.startswith(".")), + key=lambda p: (not p.is_dir(), p.name.lower()), + ) + added = False + for child in children: + if child.is_dir(): + node = QTreeWidgetItem([child.name]) + node.setIcon(0, phi("folder", size=16)) + node.setData(0, _ROLE_ABSOLUTE_PATH, str(child)) + node.setData(0, _ROLE_IS_DIR, True) + if self._populate_node(node, child): + parent.addChild(node) + added = True + # else: empty subtree — skip the folder entirely + elif child.suffix.lower() in _ALLOWED_EXTS: + node = QTreeWidgetItem([child.name]) + node.setIcon(0, phi(_icon_for_ext(child.suffix), size=16)) + node.setData(0, _ROLE_ABSOLUTE_PATH, str(child)) + node.setData(0, _ROLE_IS_DIR, False) + parent.addChild(node) + added = True + return added + + def _restore_selection(self, target: Path) -> None: + """Re-select the row whose UserRole path matches *target*, if present.""" + target_s = str(target) + it = iter([self._tree.topLevelItem(i) for i in range(self._tree.topLevelItemCount())]) + # Walk all items via a stack. + stack = list(self._tree.topLevelItem(i) for i in range(self._tree.topLevelItemCount())) + while stack: + node = stack.pop() + if node.data(0, _ROLE_ABSOLUTE_PATH) == target_s: + node.setSelected(True) + self._tree.setCurrentItem(node) + return + stack.extend(node.child(i) for i in range(node.childCount())) + + def _selected_path(self) -> Path | None: + items = self._tree.selectedItems() + if not items: + return None + raw = items[0].data(0, _ROLE_ABSOLUTE_PATH) + return Path(raw) if raw else None + + def _selected_is_dir(self) -> bool: + items = self._tree.selectedItems() + return bool(items and items[0].data(0, _ROLE_IS_DIR)) + + def _on_double_click(self, item: QTreeWidgetItem, _col: int) -> None: + if item.data(0, _ROLE_IS_DIR): + return # let Qt handle expand/collapse + raw = item.data(0, _ROLE_ABSOLUTE_PATH) + if raw: + self.file_open_requested.emit(Path(raw)) + + def _on_context_menu(self, pos) -> None: + from .actions import build_context_menu + menu = build_context_menu(self, self._selected_path()) + menu.exec(self._tree.viewport().mapToGlobal(pos)) + + def _on_new_file(self) -> None: + from .actions import prompt_new_module + prompt_new_module(self, self._root, self._selected_path()) + + def _on_delete_selected(self) -> None: + from .actions import _prompt_delete + path = self._selected_path() + if path is not None: + _prompt_delete(self, path) + + def _on_rename_selected(self) -> None: + from .actions import _prompt_rename + path = self._selected_path() + if path is not None: + _prompt_rename(self, path) + + def _on_reveal(self) -> None: + from PySide6.QtCore import QUrl + from PySide6.QtGui import QDesktopServices + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._root))) + + +def _icon_for_ext(ext: str) -> str: + return { + ".js": "file-js", + ".ts": "file-ts", + ".py": "file-py", + }.get(ext.lower(), "file") +``` + +Wire `SettingsDialog.local_modules_dir_changed` → `ScriptsPanel.refresh_root` in `window.py`. + +`actions.py` (extend with `new_script_popup.py` for `NewScriptModulePopup` per §5.0b): + +```python +from pathlib import Path +from PySide6.QtCore import QUrl +from PySide6.QtGui import QAction, QClipboard +from PySide6.QtWidgets import QMenu, QInputDialog, QMessageBox, QApplication +from PySide6.QtGui import QDesktopServices + + +def build_context_menu(panel, path: Path | None) -> QMenu: + menu = QMenu(panel) + # New file submenu. + new_menu = menu.addMenu("New file") + for label, ext in [("JavaScript (.js)", ".js"), + ("TypeScript (.ts)", ".ts"), + ("Python (.py)", ".py")]: + a = new_menu.addAction(label) + a.triggered.connect( + lambda _checked=False, e=ext: prompt_new_module(panel, panel._root, panel._selected_path(), ext=e) + ) + if path and path.is_file(): + menu.addSeparator() + copy_a = menu.addAction("Copy import specifier") + copy_a.triggered.connect(lambda: _copy_import_specifier(panel._root, path)) + reveal_a = menu.addAction("Reveal in file manager") + reveal_a.triggered.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(path.parent)))) + menu.addSeparator() + rename_a = menu.addAction("Rename…") + rename_a.triggered.connect(lambda: _prompt_rename(panel, path)) + del_a = menu.addAction("Delete") + del_a.triggered.connect(lambda: _prompt_delete(panel, path)) + return menu + + +def prompt_new_module(panel, root: Path, selected: Path | None, ext: str = ".js") -> None: + """Open ``NewScriptModulePopup`` (§5.0b); then collect name and create under *root* or *selected* folder.""" + # Full implementation: see §5.0b. End state: ``panel.file_open_requested.emit(target_path)``. + pass + + +def prompt_new_file(panel, root: Path, ext: str = ".js") -> None: + """Legacy helper name — prefer ``prompt_new_module`` + popup. Kept for grep parity in tests.""" + prompt_new_module(panel, root, None, ext=ext) + + +def _copy_import_specifier(modules_root: Path, path: Path) -> None: + """Copy ``local:`` under *modules_root*.""" + rel = path.resolve().relative_to(modules_root.resolve()).as_posix() + spec = f"local:{rel}" + QApplication.clipboard().setText(spec) + + +def _prompt_rename(panel, path: Path) -> None: + new_name, ok = QInputDialog.getText(panel, "Rename", "New name (no extension):", text=path.stem) + if not ok or not new_name.strip(): + return + new_path = path.with_name(f"{new_name.strip()}{path.suffix}") + path.rename(new_path) + panel.file_renamed.emit(path, new_path) + + +def _prompt_delete(panel, path: Path) -> None: + if QMessageBox.question(panel, "Delete", f"Delete {path.name}?") != QMessageBox.StandardButton.Yes: + return + path.unlink() + panel.file_deleted.emit(path) +``` + +### 5.2 — Tests `tests/ui/sidebar/test_scripts_panel.py` + +New file. Cases (all required — sparse coverage was a flagged gap last time): +1. `test_root_matches_settings` — `local_modules_dir` set to tmp dir → panel's `_root` is that dir. +2. `test_filter_hides_unsupported_extensions` — drop `.md` / `.txt` → not in tree. +3. `test_subdirectory_files_visible_under_folder_node` — `utils/jwt.js` → top-level `utils` folder node with `jwt.js` child. +4. `test_deep_subdirectory_visible` — `auth/oauth/google.ts` → 3-level deep node visible. +5. `test_empty_subdirectory_pruned` — folder with no eligible files → folder node not shown (avoids visual clutter). +6. `test_hidden_dirs_filtered` — `.git/foo.js` → not in tree. +7. `test_folder_first_alpha_sort` — folders sorted before files within each level, alphabetical. +8. `test_double_click_on_file_emits_signal_with_path` — signal payload is `Path`, points to the absolute file. +9. `test_double_click_on_folder_does_not_emit_signal` — expanding a folder doesn't trigger `file_open_requested`. +10. `test_copy_import_specifier_top_level` — `Path("foo.js")` → clipboard has `local:foo.js`. +11. `test_copy_import_specifier_nested` — `utils/jwt.js` → clipboard has `local:utils/jwt.js`. +12. `test_copy_import_specifier_python_nested` — `helpers/shout.py` → clipboard has `local:helpers/shout.py`. +13. `test_new_file_creates_with_correct_extension` — mock dialog → file appears in tree, signal emits Path. +14. `test_new_file_in_selected_folder` — folder selected → new file created inside that folder. +15. `test_new_file_collision_shows_warning` — existing file → no overwrite, no signal. +16. `test_rename_emits_signal` — `(old_path, new_path)`. +17. `test_delete_confirmation_no_keeps_file` — user picks No → file still exists, no signal. +18. `test_delete_emits_signal_and_removes_file`. +19. `test_context_menu_on_file_includes_copy_specifier_rename_delete` — menu correctness. +20. `test_context_menu_on_folder_omits_copy_specifier` — folders have no specifier; menu adapts. +21. `test_read_only_root_disables_mutation_buttons` — `os.access` mocked False → New / Rename / Delete disabled. +22. `test_empty_root_shows_empty_state` — no eligible files → empty label visible, tree hidden. +23. `test_refresh_root_preserves_selection_when_possible` — select `utils/jwt.js`, refresh, selection restored. +24. `test_refresh_root_updates_on_settings_change` — change root → tree reflects new contents. +25. **(Amended)** `test_scripts_header_has_new_refresh_search` — widgets present; `Refresh` triggers `_refresh`. +26. **(Amended)** `test_new_module_popup_opens_from_header` — `+ New` shows `NewScriptModulePopup` (or equivalent exec). + +### 5.3 — Wire panel into main window + +In `window.py` (already touched in PR 4): + +```python +self._scripts_panel = ScriptsPanel() +self._scripts_panel.file_open_requested.connect(self._open_script_module_tab) +# (set_scripts_widget called in PR 4) +``` + +`_open_script_module_tab` is implemented in PR 6 — wire the signal even if the handler is a stub here. + +PR 5 ships once `ScriptsPanel` tests pass. Double-click is a no-op until PR 6. + +--- + +## Section 6 — Script-module tab type (PR 6) + +### 6.0 — FULL editor surface reuse (mirror the pre/post scripts pane) + +**Script-module tabs must use the same multi-pane layout, widgets, and toolbar as the existing pre/post-request scripts editor.** Not just the editor widget — the **entire chrome**. Users authoring a request script and authoring a module file must see the same surface. + +**Mandatory layout** (mirrors [scripts_mixin.py:56-249](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L56-L249)): + +``` +┌──────────────────────────────────────────────────────────┐ +│ Toolbar: [🔍 Find][↔ Replace][🎯 Go to line] │ [↶ ↷] │ [💾 Save] │ +├──────────────────────────────────────────────────────────┤ +│ Search/replace bar (toggleable — same widget as scripts) │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ CodeEditorWidget │ +│ (same class, same LSP wiring, same completion popup) │ +│ │ +├──────────────────────────────────────────────────────────┤ +│ Status bar: Ln 5, Col 12 │ Language: JavaScript │ 1.2K│ +├──────────────────────────────────────────────────────────┤ ← QSplitter handle +│ ScriptOutputPanel (same class as scripts editor) │ +│ ┌─[ Output ][ Problems ]──────────────────────────────┐ │ +│ │ Problems tab: LSP diagnostics list (clickable, │ │ +│ │ same ScriptLspProblemsTab class). │ │ +│ │ Output tab: stays empty for modules (no Run). │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +**Concrete reuse list** (all classes/widgets, no rebuilds): + +| Component | Source class | File | +|---|---|---| +| Code editor itself | `CodeEditorWidget` | [src/ui/widgets/code_editor/editor_widget.py](../../src/ui/widgets/code_editor/editor_widget.py) | +| Vertical splitter (editor top / output bottom, ~44/56 ratio) | `QSplitter(Qt.Vertical)` per [scripts_mixin.py:128-139](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L128) | — | +| Output + Problems panel | `ScriptOutputPanel` | [src/ui/request/request_editor/scripts/output_panel.py](../../src/ui/request/request_editor/scripts/output_panel.py) | +| Problems tab (LSP diagnostics) | `ScriptLspProblemsTab` | [src/ui/request/request_editor/scripts/lsp_problems_tab.py](../../src/ui/request/request_editor/scripts/lsp_problems_tab.py) | +| Search/replace bar | `SearchReplaceBar` | (same as scripts editor) | +| Find / Replace / Go-to-line buttons | toolbar built by `_build_script_header` | [scripts_mixin.py:544-709](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L544) | +| Undo / Redo buttons | same toolbar | — | +| Save button + Ctrl+S | same toolbar | — | +| Status bar (Ln/Col, language, char count) | `_build_script_status_bar` | [scripts_mixin.py:711-793](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L711) | +| LSP attach | `CodeEditorWidget.set_language()` auto-wires `attach_lsp()` | — | + +**Wire `ScriptOutputPanel.bind_script_editor(editor)`** so the Problems tab receives `lsp_diagnostics_changed` signals — same as the scripts editor. This is the entire LSP-diagnostics hookup. + +**Omit from module tabs** (different from scripts editor): +- **Run button** — modules have no entry point standalone. Hide the button (don't disable — hide). Output tab stays present for chrome parity but stays empty. +- **Debug button** — same reason. Hide. +- **Run all** — same reason. Hide. +- **Mock response tab** — only meaningful for post-response scripts. Omit from the `ScriptOutputPanel` for module tabs (constructor takes a `script_type` arg; pass a new `"module"` value that skips the Mock tab). +- **RuntimeBanner / InheritedScriptsBanner** — request-context concerns, not relevant for modules. + +**The simplest implementation path**: extract the `_build_pre_request_tab` body of `_ScriptsMixin` into a free function `build_script_editor_surface(*, script_type)` (or a small `ScriptEditorSurface` widget) that takes a `script_type: Literal["pre_request","test","module"]`. Both `_ScriptsMixin` and `ScriptModuleTab` call it. The `"module"` branch hides Run/Debug/Banner/Mock; everything else stays identical. + +**Do not** copy-paste the layout code. Reuse via extraction so a future bug fix in one surface fixes both. + +### 6.1 — New file `src/ui/tabs/script_module_tab.py` + +**Illustrative-only (Amended):** The code block below sketches dirty/save/orphan behaviour with a bare `CodeEditorWidget`. The **shipped** module tab **must** embed the full surface from §6.0 via `build_script_editor_surface(..., script_type="module")` (toolbar, splitter, `ScriptOutputPanel`, Problems, LSP bind). Replace the central widget layout accordingly while keeping the same dirty/save signals contract. + +```python +from pathlib import Path +from PySide6.QtCore import Signal +from PySide6.QtGui import QShortcut, QKeySequence +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +from ui.widgets.code_editor.editor_widget import CodeEditorWidget + + +_EXT_TO_LANG = {".js": "javascript", ".ts": "typescript", ".py": "python"} + + +class ScriptModuleEditorWidget(QWidget): + dirty_changed = Signal(bool) + + def __init__(self, path: Path, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._path = path + self._dirty = False + self._orphan = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self._banner = QLabel("File deleted on disk — Save to restore.") + self._banner.hide() + layout.addWidget(self._banner) + + self._editor = CodeEditorWidget(read_only=False) + language = _EXT_TO_LANG.get(path.suffix, "javascript") + self._editor.set_language(language) # call existing API + if path.exists(): + self._editor.set_text(path.read_text(encoding="utf-8")) + layout.addWidget(self._editor, 1) + + self._editor.textChanged.connect(self._on_text_changed) + + QShortcut(QKeySequence.Save, self, activated=self.save) + + @property + def path(self) -> Path: + return self._path + + @property + def dirty(self) -> bool: + return self._dirty + + def _set_dirty(self, v: bool) -> None: + if self._dirty != v: + self._dirty = v + self.dirty_changed.emit(v) + + def _on_text_changed(self) -> None: + self._set_dirty(True) + + def save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(self._editor.text(), encoding="utf-8") + self._orphan = False + self._banner.hide() + self._set_dirty(False) + + def mark_orphan(self) -> None: + self._orphan = True + self._banner.show() + self._set_dirty(True) +``` + +Note: `set_language(...)` and `.text()` / `.set_text()` must exist on `CodeEditorWidget`. If their names differ, look up actual API at [src/ui/widgets/code_editor/editor_widget.py](../../src/ui/widgets/code_editor/editor_widget.py) and adjust. + +### 6.2 — Modify `src/ui/request/navigation/tab_manager.py` + +This is **high blast radius** — every site that touches `ctx.editor` must be audited. Run this audit BEFORE writing code: + +```bash +grep -rn "ctx\.editor\|\.editor\.\|TabContext" src/ui/ | sort -u +``` + +Find `TabContext.__init__` (around line 64). Add new fields. Keep `editor` typed `RequestEditorWidget | None` so existing code can early-return on `None`: + +```python +class TabContext: + def __init__( + self, + ...existing args..., + tab_type: str = "request", # NEW: "request" | "script_module" + script_module_path: Path | None = None, # NEW + script_module_editor: QWidget | None = None, # NEW + ) -> None: + self.tab_type = tab_type + self.script_module_path = script_module_path + self.script_module_editor = script_module_editor + # Existing: editor / response_viewer / breadcrumb / request_id / is_preview + if tab_type == "request": + self.editor = editor or RequestEditorWidget() + else: + self.editor = None # callers must check tab_type before touching editor + + def is_request(self) -> bool: + return self.tab_type == "request" +``` + +**Touchpoint checklist** — every one of these must early-return or branch on `is_request()`: +- `cleanup_thread()` — no-op for script-module tabs (no send pipeline). +- `start_send()` — no-op (raises if called on a non-request tab — defensive). +- `send_pipeline.py` send-button handlers — verify `ctx.is_request()` before queue ops. +- `_on_tab_changed` / `_refresh_sidebar` in `tab_controller.py` — branch already in §6.3. +- `tab_close` — save dirty script-module before closing; confirm with user if dirty. +- Session restore (lines 459-485 region) — separate persistence format (§6.5). +- Breadcrumb update — skip for script-module tabs. +- `set_request_dirty` / draft persistence — skip. +- Title-bar / window-title update — use file basename for script-module tabs. + +Run the audit grep again after edits to make sure no `ctx.editor.` path is left unguarded. + +### 6.3 — Modify `src/ui/main_window/tab_controller.py` + +Add a new method: + +```python +def _open_script_module_tab(self, path: Path) -> None: + # If a tab for this path is already open, focus it. + for tab_id, ctx in self._tabs.items(): + if ctx.tab_type == "script_module" and ctx.script_module_path == path: + self._tab_bar.setCurrentIndex(self._tab_bar.indexOf(tab_id)) + return + editor = ScriptModuleEditorWidget(path) + ctx = TabContext( + tab_type="script_module", + script_module_path=path, + script_module_editor=editor, + ) + tab_id = self._next_tab_id() # follow existing convention + self._tabs[tab_id] = ctx + self._editor_stack.addWidget(editor) + title = path.name + self._tab_bar.add_tab(tab_id, title) + editor.dirty_changed.connect( + lambda d, t=tab_id: self._tab_bar.set_dirty(t, d) + ) + self._tab_bar.setCurrentIndex(self._tab_bar.indexOf(tab_id)) +``` + +Find `_on_tab_changed` (lines 354-414 region). Branch on `tab_type`: + +```python +def _on_tab_changed(self, idx: int) -> None: + tab_id = self._tab_bar.tab_id_at(idx) + ctx = self._tabs.get(tab_id) + if ctx is None: + return + if ctx.tab_type == "script_module": + self._editor_stack.setCurrentWidget(ctx.script_module_editor) + # Hide the response area, breadcrumb, send button — they don't apply. + self._response_area.setVisible(False) + self._breadcrumb.setVisible(False) + return + # ... existing request-tab branch unchanged. +``` + +### 6.4 — Wire to scripts panel + +In `window.py`, ensure: + +```python +self._scripts_panel.file_open_requested.connect(self._open_script_module_tab) +self._scripts_panel.file_deleted.connect(self._on_local_module_deleted) +``` + +Add handler: + +```python +def _on_local_module_deleted(self, path: Path) -> None: + for ctx in self._tabs.values(): + if ctx.tab_type == "script_module" and ctx.script_module_path == path: + ctx.script_module_editor.mark_orphan() +``` + +### 6.5 — Session persistence + +Find existing tab restore code in `tab_controller.py` (around lines 459-485). Extend the persisted format to record script-module tabs by path: + +```python +{"tab_type": "script_module", "path": str(path)} +``` + +On restore, silently drop entries whose path doesn't exist. + +### 6.6 — Tests `tests/ui/test_script_module_tab.py` + +New file. Cases: +1. `test_open_creates_tab_with_editor` — call `_open_script_module_tab(tmp_path/"foo.js")` → new tab appears, `ScriptModuleEditorWidget` in stack. +2. `test_dirty_indicator_on_edit` — type into editor → tab bar `dirty` flag set True. +3. `test_save_writes_to_disk_and_clears_dirty` — modify, Ctrl+S → disk content matches editor, dirty False. +4. `test_open_twice_focuses_existing_tab` — call open twice with same path → only one tab; index is on it. +5. `test_response_area_hidden_for_script_module_tab` — switch to a script-module tab → response area hidden; switch back to request tab → visible again. +6. `test_external_delete_marks_orphan` — emit `file_deleted` signal → banner shown, dirty True. + +### 6.7 — Settings UI + +**Important**: the existing Settings dialog uses a single monolithic `_do_apply()` method (line 1527) — there is **no** `_apply_callbacks` list. Integration must happen inside the dialog class itself, not via callback registration. + +**New file** `src/ui/dialogs/settings_local_modules.py`: + +```python +"""Builders for the 'Local modules' Settings subpage. + +Returns a built QWidget plus the line-edit so the dialog can wire dirty +tracking and read it from inside ``SettingsDialog._do_apply``. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from PySide6.QtCore import QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, + QLabel, QFileDialog, +) +from services.scripting.runtime_settings import RuntimeSettings + + +@dataclass +class LocalModulesPage: + widget: QWidget + path_edit: QLineEdit + + +def build_local_modules_page(parent_dialog) -> LocalModulesPage: + page = QWidget() + layout = QVBoxLayout(page) + + layout.addWidget(QLabel("Local script modules")) + + help_label = QLabel( + "Files in this folder can be imported from any request script with " + "pm.require(\"local:<name>\"). Supported " + "extensions: .js, .ts, .py. " + "Subfolders are ignored." + ) + help_label.setWordWrap(True) + layout.addWidget(help_label) + + row = QHBoxLayout() + path_edit = QLineEdit(str(RuntimeSettings.local_modules_dir())) + browse_btn = QPushButton("Browse…") + open_btn = QPushButton("Open folder") + row.addWidget(path_edit, 1) + row.addWidget(browse_btn) + row.addWidget(open_btn) + layout.addLayout(row) + layout.addStretch() + + def on_browse() -> None: + d = QFileDialog.getExistingDirectory( + page, "Local modules folder", path_edit.text() + ) + if d: + path_edit.setText(d) + + def on_open() -> None: + QDesktopServices.openUrl(QUrl.fromLocalFile(path_edit.text())) + + browse_btn.clicked.connect(on_browse) + open_btn.clicked.connect(on_open) + # Dialog wires dirty tracking + apply itself (see settings_dialog.py changes). + path_edit.textChanged.connect(parent_dialog._mark_dirty) + return LocalModulesPage(widget=page, path_edit=path_edit) +``` + +Modify [src/ui/dialogs/settings_dialog.py](../../src/ui/dialogs/settings_dialog.py): +1. **Add a child node** under the existing "Scripting" parent in the category tree (look for the section that builds the tree near line 543; pattern mirrors `_build_private_packages_pages` at line 555). +2. **In `__init__`** (or wherever existing Scripting page builders are called, near line 149): call `self._local_modules_page = build_local_modules_page(self)`, then register `self._local_modules_page.widget` in the `QStackedWidget` under key `"local_modules"` (follow the existing private-packages-pages registration pattern verbatim). +3. **In `_do_apply`** (line 1527): add a line near the other persistence calls: + ```python + RuntimeSettings.set_local_modules_dir(Path(self._local_modules_page.path_edit.text())) + ``` +4. **Emit a signal or call back into the main window** so `ScriptsPanel._refresh_root()` runs when the path changes (add a `local_modules_dir_changed` signal on `SettingsDialog`, emit from `_do_apply` when the value differs from the original; connect in the dialog opener inside `MainWindow`). + +Tests `tests/ui/dialogs/test_settings_local_modules.py`: +1. `test_default_path_shown_when_unset` — `local_modules_dir` unset → text field shows default per-OS path. +2. `test_browse_sets_path_via_dialog` — mock `getExistingDirectory` → text field updated, dirty flag set. +3. `test_apply_persists_path` — set text, simulate Apply → `RuntimeSettings.local_modules_dir()` returns new value. + +PR 6 ships once these tests pass and end-to-end manual smoke (below) works. + +--- + +## Section 7 — Docs + +Modify [docs/scripting/external-packages.md](../scripting/external-packages.md). Add a top section titled "Local script modules" with: +- Brief: what they are, where to put files (default `/postmark/scripts/`), configurable via Settings → Scripting → Local script modules. +- Specifier shape table: + - JS: `pm.require("local:auth.js")` / `pm.require("local:types.ts")` + - Python: `pm.require("local:utils.py")` + - **Extension is mandatory.** +- Walkthrough: open Scripts pane via top-of-left-pane icon (or `Ctrl+2`) → click "New module" → file opens in tab → save → call from a request script. +- Composition example: `local:auth.js` imports `npm:jose@5.2.0` (works via union scan). +- Cross-language note: not supported (`pm.require("local:helper.py")` from a JS script errors). +- Pyodide-only note for Python local modules (RestrictedPython subprocess rejects them with a clear error). +- One-line link to "Private packages" section that follows. + +Modify [src/AGENTS.md](../../src/AGENTS.md): under scripting, add a bullet pointing to `LocalModuleResolver` and the `local:` specifier syntax (extension-mandatory). + +Modify [AGENTS.md](../../AGENTS.md) directory map: list new `src/ui/sidebar/left_pane.py`, `src/ui/sidebar/scripts_panel/`, `src/ui/tabs/script_module_tab.py`, `src/ui/dialogs/settings_local_modules.py`, `src/services/scripting/local_modules.py`, `data/scripts/pm_local_loader.py`. + +--- + +## Order of work + +Each PR must keep `poetry run pytest tests/` green. + +| PR | Scope | Depends on | User-visible? | +|---|---|---|---| +| 1 | Resolver + settings row (Local script modules path) | — | New section in Settings → Scripting (unused yet) | +| 2 | JS runtime `local:foo.js`/`.ts` support | PR 1 | Devs can drop a file into the folder + call `pm.require("local:foo.js")` from a request script | +| 3 | Python runtime `local:foo.py` (Pyodide) + RestrictedPython error path | PR 1 | Same as PR 2 but for Python | +| 4 | `LeftSidebarPane` shell — toggle row at top, stacked content, Collections page wired (Scripts page = empty stub) | — | **YES — toggle row appears at top of left pane; Collections still default.** | +| 5 | `ScriptsPanel` (toolbar + list + context menu) — wires into stacked page 1 | PR 1, PR 4 | Scripts icon now switches to a working file panel; double-click stub | +| 6 | `ScriptModuleTab` + Settings dialog wiring + `local_modules_dir_changed` signal | PR 2, PR 3, PR 5 | End-to-end feature live | + +Backend (PRs 1-3) and UI (PRs 4-6) can stack in parallel reviews. **No feature flag for PR 4** — the toggle row is a small, recoverable change. If something goes wrong, revert that PR; don't ship an env-var hack. + +--- + +## Pre-implementation verification + +Before starting PR 1, confirm the following one-time facts (they should be true based on exploration but worth a 30-second check): +- `src/services/scripting/runtime_settings.py` exports a `_get_settings()` helper at module scope. **Use it; never call `QSettings()` directly.** UI code uses `QSettings(_ORG, _APP)` (org/app constants from [src/ui/styling/theme_manager.py](../../src/ui/styling/theme_manager.py) lines 20-22); the `LeftSidebarPane._qsettings()` helper in §4.1 follows this. Bare `QSettings()` calls go to a different namespace and break test isolation. +- `src/services/scripting/deno_manager.py` `runtime_dir()` builds a per-OS data dir. Plan to refactor into a shared `_postmark_user_data_dir()` (Section 1.2) so this lives in one place. +- `src/ui/styling/global_qss.py` builds QSS via Python f-strings against a palette dict `p`. **Qt does not support CSS `var(--…)`.** Use `{p["accent"]}` interpolation. +- `src/ui/dialogs/settings_dialog.py` `_do_apply` (line 1527) is monolithic — there is **no** callback registration list. Plan integrates by adding a line directly inside `_do_apply` (Section 6.7). +- `CodeEditorWidget` API names — check actual signatures at [src/ui/widgets/code_editor/editor_widget.py:122-180](../../src/ui/widgets/code_editor/editor_widget.py#L122) for `setPlainText` vs `set_text`, `toPlainText` vs `text`, `set_language(...)` vs another spelling. Adjust §6.1 method calls to match. +- Scripting directory file-count limit (per [src/AGENTS.md](../../src/AGENTS.md)): if `src/services/scripting/` is near the convention cap, plan to split `local_modules.py` into a subpackage. Check before PR 1. + +## Risks / sharp edges + +- **Traversal/symlink escape** — resolver uses `resolve(strict=True)` + `relative_to(root)` where root is already canonical. Test with `../escape.js`, `link.js → /etc/passwd`. +- **No silent-ambiguity class** — extension-mandatory specifier means `foo.js` and `foo.ts` can coexist; each has its own specifier. No "ambiguous stem" error possible from the call site. +- **Cycles** — A requires B, B requires A → resolver raises with chain message. +- **Per-execution scan** — bounded by `MAX_LOCAL_MODULES = 500`. Glob is cheap; source read lazily for reached modules. +- **mtime races** — snapshot source at run start; in-flight execs use their snapshot. +- **Read-only root** — Scripts panel detects via `os.access(root, os.W_OK)`; disables mutation buttons + context-menu entries. +- **Tab open when file deleted** — emit `file_deleted` signal; tab marks orphan with banner. Save creates the file again. +- **Cross-language pm.require** — JS user requiring `local:foo.py` not detected by JS regex (silently no-op at scan; bundle then errors at runtime in `pm_bootstrap.js`). Python user requiring `local:helper.js` not detected by Python regex. +- **Pyodide entry script location** — `data/scripts/pyodide_run.mjs` (verified). Section 3.5 spec. +- **CodeEditorWidget API names** — `set_text`/`set_language`/`text()` may differ; check actual names at [src/ui/widgets/code_editor/editor_widget.py](../../src/ui/widgets/code_editor/editor_widget.py) and align in §6.1 before writing code. +- **`sys.modules` shadowing** — loader uses only `__pm_local_`. A file called `json.py` does NOT replace stdlib `json`. `pm.require("local:json.py")` works via the namespaced lookup; `import json` in user code still resolves to stdlib. +- **LSP on module-tab editors** — out of scope for MVP. Note in docs. +- **Toggle row icons** — confirm `phi("tree-structure")` and `phi("code")` exist in `data/fonts/phosphor-charmap.json` before PR 4 (or pick alternates like `phi("list")`, `phi("file-code")`). +- **Settings path change re-rooting** — `local_modules_dir_changed` signal: + 1. Compute `new_root = RuntimeSettings.local_modules_dir().resolve()`. + 2. For each open script-module tab, compute `tab_path.resolve()`. + 3. If `tab_path` is not under `new_root`, call `mark_orphan()` (same banner as external delete). + 4. `ScriptsPanel.refresh_root()` reloads the list. +- **`ctx.editor` audit** — grep `ctx\.editor\|\.editor\.\|TabContext` is a starting list. Run full test suite after; fix any `AttributeError: 'NoneType' object has no attribute 'editor'` traces. +- **No `QDockWidget` for Scripts** — explicit anti-pattern (see Context). A prior implementation made this mistake. Reviewer should reject any PR that uses `QDockWidget` for the Scripts panel. +- **No vertical activity rail** — explicit anti-pattern. Toggle row is horizontal, inside the left pane. + +--- + +## Verification + +After each PR, run the listed unit/UI tests for that PR plus a full sweep: + +``` +poetry run pytest tests/unit/services/test_local_modules_resolver.py -q # PR 1 +poetry run pytest tests/unit/services/test_runtime_settings.py -q # PR 1 +poetry run pytest tests/unit/services/test_pm_require_local_js.py -q # PR 2 +poetry run pytest tests/unit/services/test_pm_require_local_py.py -q # PR 3 +poetry run pytest tests/unit/services/test_pm_python_parity.py -q # PR 3 +poetry run pytest tests/ui/sidebar/test_left_pane.py -q # PR 4 +poetry run pytest tests/ui/test_main_window.py -q # PR 4 +poetry run pytest tests/ui/sidebar/test_scripts_panel.py -q # PR 5 +poetry run pytest tests/ui/test_script_module_tab.py -q # PR 6 +poetry run pytest tests/ui/dialogs/test_settings_local_modules.py -q # PR 6 +poetry run pytest tests/ -q # always +``` + +End-to-end manual smoke (after PR 6): + +1. Launch app. **Toggle row visible at top of left pane**, Collections icon active by default. +2. Settings → Scripting → Local script modules. Path defaults to per-OS default. Click "Open folder" → OS file manager pops to a freshly-created `/postmark/scripts/` folder. +3. Press `Ctrl+2` (or click Scripts icon). Stacked content swaps to ScriptsPanel; empty state visible. +4. Click "New module" → modal asks for name → `mathx`. `mathx.js` appears in list and opens in a new editor tab. +5. Type `export function add(a,b){ return a+b; }`. Press `Ctrl+S`. Dirty dot disappears. +6. Right-click `mathx.js` → Copy import specifier. Clipboard now has `local:mathx.js` (with extension). +7. Paste into a request's Tests tab: `const m = pm.require("local:mathx.js"); pm.test("adds", () => pm.expect(m.add(1,2)).to.eql(3));`. Send. Test passes. +8. Python: create `helpers.py` via context menu → `def shout(s): return s.upper()`. Python pre-request script: `h = pm.require("local:helpers.py"); pm.environment.set("greet", h.shout("hi"))`. Send. Env var set to "HI". +9. Composition: create `multiplier.js` with `export const mul = (a,b) => a*b;`. Edit `mathx.js` to also export `mul = pm.require("local:multiplier.js").mul;`. Original test still passes; add a test calling `m.mul(2,3)`. +10. Extension mismatch: in a request script, `pm.require("local:mathx.ts")` → error mentions extension mismatch (only `.js` on disk). +11. Path traversal: try to create `../escape.js` via OS file manager outside the modules dir — file appears outside but resolver doesn't list it; `pm.require("local:escape.js")` → "not found" error. +12. Delete `mathx.js` from panel context menu while tab is open → tab shows orphan banner. `Ctrl+S` → file recreated. +13. Right sidebar (variables / snippets / saved responses) still toggles. No regression. +14. Restart app → Toggle row remembers active panel; last-open script-module tabs restored. +15. **No `QDockWidget` exists anywhere in the running UI.** Inspect via `QApplication.allWidgets()` if needed. + +--- + +## Critical files + +- [src/services/scripting/js_runtime.py](../../src/services/scripting/js_runtime.py) — JS specifier scanner, imports block +- [src/services/scripting/py_runtime.py](../../src/services/scripting/py_runtime.py) — Python specifier scanner +- [src/services/scripting/pyodide_runtime.py](../../src/services/scripting/pyodide_runtime.py) — IPC payload +- [src/services/scripting/deno_runtime.py](../../src/services/scripting/deno_runtime.py) — bundle + workdir file writes +- [src/services/scripting/runtime_settings.py](../../src/services/scripting/runtime_settings.py) — new `local_modules_dir` key +- `src/services/scripting/local_modules.py` — NEW resolver (link when file lands on disk) +- [data/scripts/pm_bootstrap.py](../../data/scripts/pm_bootstrap.py) — Python `pm.require` `local:` branch +- [data/scripts/pm_bootstrap.js](../../data/scripts/pm_bootstrap.js) — JS hint for `local:` errors +- `data/scripts/pm_local_loader.py` — NEW Pyodide-side registrar (link when file lands on disk) +- [src/ui/collections/collection_widget.py](../../src/ui/collections/collection_widget.py) — unchanged content; reparented under `LeftSidebarPane` +- [src/ui/collections/collection_header.py](../../src/ui/collections/collection_header.py) — unchanged; sits beneath toggle row +- `src/ui/sidebar/left_pane.py` — NEW (`LeftSidebarPane` — toggle row + stacked content; link when file lands on disk) +- [src/ui/sidebar/scripts_panel/](../../src/ui/sidebar/scripts_panel/) — NEW package (`ScriptsPanel` + `actions.py`) +- `src/ui/tabs/script_module_tab.py` — NEW (link when file lands on disk) +- [src/ui/main_window/window.py](../../src/ui/main_window/window.py) — splitter wiring (replace `collection_widget` with `LeftSidebarPane`) + Ctrl+1/+2 +- [src/ui/main_window/tab_controller.py](../../src/ui/main_window/tab_controller.py) — script-module tab type +- [src/ui/request/navigation/tab_manager.py](../../src/ui/request/navigation/tab_manager.py) — `TabContext.tab_type` / `script_module_panel` / `script_module_path` +- [src/ui/dialogs/settings_dialog.py](../../src/ui/dialogs/settings_dialog.py) — new "Local script modules" row + `local_modules_dir_changed` signal +- `src/ui/dialogs/settings_local_modules.py` — NEW (row builder; link when file lands on disk) +- [src/ui/styling/global_qss.py](../../src/ui/styling/global_qss.py) — `#leftPaneToggleRow` + `#leftPaneToggleButton` rules diff --git a/.cursor/plans/scripts_panel_plan_review_1b186b1d.plan.md b/.cursor/plans/scripts_panel_plan_review_1b186b1d.plan.md new file mode 100644 index 0000000..699362b --- /dev/null +++ b/.cursor/plans/scripts_panel_plan_review_1b186b1d.plan.md @@ -0,0 +1,20 @@ +--- +name: Scripts panel plan review +overview: "Superseded — use the canonical merged plan at [.cursor/plans/local-script-modules-full-plan.md](local-script-modules-full-plan.md) (full multi-PR spec + amendments)." +todos: [] +isProject: false +--- + +# Superseded + +This short review has been **folded into** the adopted Cursor plan: + +**[local-script-modules-full-plan.md](local-script-modules-full-plan.md)** + +That file contains the **entire** original specification (resolver, JS/Python runtimes, left pane, Scripts panel, module tabs, tests, verification) plus inline amendments: + +- Collections-parity Scripts header (+ New, Refresh, search) and folder tree (§5.0–§5.0b). +- `NewScriptModulePopup` mirroring `NewItemPopup` (§5.0b). +- Full pre/post script editor surface reuse for module tabs, including Output, Problems, LSP, undo/redo (§6.0); §6.1 sample marked illustrative-only. + +Use the YAML `todos` in that file’s frontmatter as the PR checklist. diff --git a/.cursor/plans/we-need-a-much-fluffy-glade.plan.md b/.cursor/plans/we-need-a-much-fluffy-glade.plan.md new file mode 100644 index 0000000..379d56f --- /dev/null +++ b/.cursor/plans/we-need-a-much-fluffy-glade.plan.md @@ -0,0 +1,2134 @@ +--- +name: Local script modules full plan +overview: "Cursor plan converted from ~/.claude/plans/we-need-a-much-fluffy-glade.md. Preserves the original multi-PR body as-is, with inline amendments for full pre/post script editor reuse, a Collections-shaped Scripts panel, and a NewScriptModulePopup matching Collections New." +todos: + - id: pr-1-resolver-settings + content: "PR 1 - LocalModuleResolver, local modules setting, resolver/settings tests" + - id: pr-2-js-runtime + content: "PR 2 - JavaScript/TypeScript local: support, union dependency scan, Deno bundle/debug parity, tests" + - id: pr-3-python-runtime + content: "PR 3 - Python local: support for Pyodide, RestrictedPython error path, loader/bootstrap tests" + - id: pr-4-left-pane + content: "PR 4 - LeftSidebarPane toggle row around existing CollectionWidget, QSS, MainWindow wiring, tests" + - id: pr-5-scripts-panel + content: "PR 5 - ScriptsPanel with folder tree, search, + New, Refresh, NewScriptModulePopup, context actions, tests" + - id: pr-6-module-tabs + content: "PR 6 - Script-module tabs using the full pre/post script editor surface, output/problems/LSP, settings signal, tests" +--- + + +# Local script modules (`pm.require("local:…")`) + left-pane toggle row + +--- + +## READ THIS FIRST — Precedence rules for implementers + +This is a long plan (~2 000 lines). It contains old/legacy code samples kept for traceability alongside newer amended requirements. To avoid mistakes, follow these rules strictly: + +1. **Amended sections always win.** Any paragraph labeled **(Amended)** or any subsection numbered **5.0a / 5.0b** overrides conflicting text in the same section. +2. **Do NOT copy code blocks marked "illustrative" or "legacy".** Two code blocks in this plan are explicitly illustrative only: + - The `_build_ui` sample in Section 5.1 (legacy five-icon toolbar). The shipped `ScriptsPanel` must instead use `ScriptsHeader` + search + tree per Section 5.0a. + - The `ScriptModuleEditorWidget` sample in Section 6.1 (bare `CodeEditorWidget` only). The shipped module tab must instead call `build_script_editor_surface(..., script_type="module")` from Section 6.0. +3. **"Top-level files only" is outdated.** The resolver and tree recurse into subdirectories. Ignore any sentence saying otherwise. +4. **Before writing any code**, check the actual API names in the existing codebase (e.g. `set_text` vs `setPlainText`). The plan's guesses may be wrong. +5. **After every PR**, run the full validation suite from `AGENTS.md` — all four commands must pass with zero errors. + +--- + +## Three non-negotiable requirements (hard gate on every PR) + +**Requirement A — Full editor reuse (PR 6).** Module tabs must reuse the **same entire script editor surface** as pre/post-request scripts. This means: `CodeEditorWidget`, toolbar with Find/Replace/Go-to-line, **Undo/Redo**, **Save**, status bar, vertical `QSplitter`, `ScriptOutputPanel` with **Output** and **Problems** tabs, and LSP wiring. Extract `build_script_editor_surface(*, script_type)` from `_ScriptsMixin` so both request scripts and module tabs share the same code. Do NOT copy-paste. Hide Run/Debug/Mock for `"module"` type; everything else stays. See Section 6.0 for the full layout diagram and reuse table. + +**Requirement B — Collections-shaped Scripts panel (PR 5).** The Scripts page must look and behave like the Collections sidebar: +- Header row: `QLabel` "Scripts" + **"+ New"** button + **"Refresh"** button (model on `CollectionHeader`). +- Search row: `QLineEdit` with placeholder "Search scripts..." that filters the tree without hiding folder ancestors of matches. +- Tree: folder/file hierarchy (folders first, alphabetical), double-click file opens tab, double-click folder expands/collapses. +- Context menu: New file, Copy import specifier, Reveal, Rename, Delete. +- Empty state: "No modules yet. Click '+ New' to create one." +- See Sections 5.0, 5.0a, and the ASCII mockup for the exact layout. + +**Requirement C — Collections-style New flow (PR 5).** Clicking **"+ New"** must open `NewScriptModulePopup` — a modal dialog modeled on `NewItemPopup` from the collections panel. Tiles for **JavaScript**, **TypeScript**, **Python** (optional: **Folder**). After tile selection, prompt for name, then create the file and open it. Do NOT use a bare `QInputDialog`. See Section 5.0b. + +--- + +This document is the **complete** implementation plan: it preserves the original multi-PR specification with **inline amendments** merged. Amendments fix internal contradictions and add UI/editor requirements agreed in review. If a paragraph below is labeled **(Amended)** or a subsection **5.0a**, it **supersedes** any conflicting sentence in the same section that was left for traceability. + +## Context + +Postman ships **Package Library** (cloud-only, JS-only, no composition, owner-locked, no Newman, no git, no PyPI). We beat it by making reusable scripts **local files on disk**. + +A user drops a file like `auth-helpers.js` under one workspace folder. From any request pre/post script they call `pm.require("local:auth-helpers.js")` and get the module. Files are git-friendly, diffable, editable with LSP. Local modules can also import each other and import `npm:` / `jsr:` / PyPI packages. + +**UI shape**: the **existing left sidebar pane gets a horizontal icon toggle row added at its top**. Two compact icon buttons sit in that row: **Collections** (active by default — shows the existing tree) and **Scripts** (shows the **folder-grouped module tree** described in §5.-1 — not a flat list). Clicking swaps the content below via a `QStackedWidget`. This is the Cursor IDE pattern (per Cursor community docs: "Cursor has a distinctive design where the row of icons on the primary sidebar are arranged **horizontally** rather than vertically like VS Code"). + +**What is and isn't changing in the left pane slot:** +- The main `QSplitter` keeps the **same slot** for the left sidebar that `collection_widget` occupies today. **Width, position, and behavior unchanged.** +- The slot's content widget becomes a tiny new wrapper (`LeftSidebarPane`) that owns: (a) the horizontal toggle row, and (b) a `QStackedWidget` holding the existing `CollectionWidget` (page 0) and a new `ScriptsPanel` (page 1). +- `CollectionWidget` is **untouched** — same class, same tree, same header buttons. It just becomes page 0 of the stack. + +### UI anti-patterns (a prior implementation attempt failed by violating these) + +- **NO new splitter pane / column / section.** The left sidebar slot count stays the same as today. +- **NO `QDockWidget`** anywhere for the Scripts panel. Scripts is a page inside the same left sidebar pane. +- **NO vertical activity rail** on the far-left edge of the window. The toggle row is **horizontal** and lives **inside** the existing left pane. +- **NO separate top-level menu items / shortcuts** that bypass the toggle row. `Ctrl+1` / `Ctrl+2` call the toggle row's `set_active_panel(name)` method like the icons do. +- **NO modifications to `CollectionWidget`'s internal layout.** It enters the stack as-is. +- **NO read-only ScriptsPanel.** The panel must let users create / rename / delete modules from inside the app (**primary header actions + context menu** — see §5.0a; a compact secondary toolbar remains acceptable for power actions). Without that the feature is unusable; a prior implementation made exactly this mistake. +- **NO iconless toggle row.** Both toggle buttons in §4 must have an icon (Phosphor font via `phi()`). Icon-only or icon+text — pick one, but never label-only. +- **NO new code editor for script-module tabs.** Script-module tabs use the **same entire script editor surface** as pre/post-request scripts (not only `CodeEditorWidget`): toolbar with Find/Replace/Go to line, **Undo/Redo**, Save, status bar, vertical splitter, `ScriptOutputPanel` with **Output + Problems**, LSP wiring — see §6.0. The bare `CodeEditorWidget`-only sample in §6.1 is **illustrative** of persistence hooks; the shipped tab must call the extracted `build_script_editor_surface(..., script_type="module")` from §6.0. + +**Out of scope** (do not implement, even if related): per-collection-scoped modules; "Extract to module" refactor; where-used panel; snippet palette integration for `local:` (defer until shape stabilises); hot reload; TypeScript `.d.ts` autogen; in-app test runner for modules; cross-language imports (JS calling Python or vice versa); **RestrictedPython subprocess support for `local:` Python modules** (Pyodide-only — RestrictedPython path explicitly errors out); **standalone "Run" button for module files** (modules are imported by request scripts; they have no entry point on their own — Output panel exists for chrome parity but stays inactive). LSP **is in scope** — wired via the same auto-attach path the scripts editor uses today. + +**Scope expansion vs prior plan revisions**: **subdirectories are now supported** under the local-modules root. Specifier accepts a relative path (e.g. `pm.require("local:utils/jwt.js")`). The Scripts panel displays a tree grouped by folder, matching the visual pattern of the collections tree. + +--- + +## Composition story (resolver + bundle) + +**Critical**: a local module can call `pm.require("npm:...")`, `pm.require("jsr:...")`, `pm.require("local:other")` (JS) or `pm.require("pkg==X.Y.Z")`, `pm.require("local:other")` (Python). The user script's static scan would miss specifiers that only appear inside reachable local modules. + +**Rule**: registry/PyPI specifier detection runs as a **union scan over the user source PLUS the source of every transitively reachable local module**. + +Order of operations for each runtime path: +1. `LocalModuleResolver.resolve_required(user_source, language=...)` → builds `{name: LocalModule}` map (transitive closure with cycle detection). +2. Collect specifiers via `_detect_pm_require_specs(user_source + "\n" + "\n".join(local_sources))` (JS) or the Python equivalent. +3. Build bundle / IPC payload with the union of specifiers + the resolved local modules. + +This is the only design that makes the composition example in Verification step 7 actually pass. + +## Specifier rules + +Form: **extension is mandatory; relative path is allowed**. Accepted shapes: + +- JS: `pm.require("local:.js")` or `pm.require("local:.ts")` +- Python: `pm.require("local:.py")` + +Where `` is one or more `/`-separated segments. Examples: +``` +pm.require("local:jwt.js") // top-level file +pm.require("local:utils/jwt.js") // one subfolder deep +pm.require("local:auth/oauth/google.ts") // nested +pm.require("local:helpers/shout.py") // Python under helpers/ +``` + +Rules: +- Each segment matches `^[A-Za-z0-9_][\w.-]*$` (JS) / `^[A-Za-z_][A-Za-z0-9_]*$` (Python — segments are Python identifiers so the loader can register dotted names like `utils.jwt`). +- **No `..`, no leading `/`, no `@scope/`, no version, no Windows backslashes.** Reject these at parse time. +- The extension **must match a file on disk** at the resolved path. If only `utils/jwt.ts` exists, `pm.require("local:utils/jwt.js")` is a "not found" error. +- Path **must resolve under the configured local-modules root** — resolver enforces `resolve(strict=True)` + `relative_to(root)` (same traversal guard as before). +- The same file may be referenced by exactly one path; there's no implicit barrel/index resolution. + +Why extension-mandatory: +- Matches Deno / ESM / Python import convention — fewer surprises. +- No silent-ambiguity class possible at the call site. + +Why allow subdirectories: +- Users with many modules want logical grouping (`auth/`, `utils/`, `validators/`) like collections. +- The Scripts panel (§5) renders the tree visually, matching the collections-tree pattern. + +The `local:` prefix cannot collide with `npm:` / `jsr:` / bare PyPI names. + +--- + +## File system layout + +- Root folder, default: `/postmark/scripts/` + - Linux: `~/.local/share/postmark/scripts/` + - macOS: `~/Library/Application Support/postmark/scripts/` + - Windows: `%LOCALAPPDATA%\postmark\scripts\` +- Use the same per-OS resolver as [DenoManager.runtime_dir()](../../src/services/scripting/deno_manager.py) (see lines around 80-90 of that file). +- **(Amended)** The tree and resolver **recurse into subdirectories** under this root (see §1.1 `LocalModuleResolver.discover()`, specifier rules above, and §5.-1). Ignore the older “top-level only” MVP sentence — it contradicted the rest of this document. +- Auto-create the folder when read for the first time (`mkdir(parents=True, exist_ok=True)`). + +--- + +## Section 1 — Resolver and settings (PR 1) + +### 1.1 — New file `src/services/scripting/local_modules.py` + +Create this file. Add the constants, dataclass, and class below verbatim. + +```python +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Iterable, Literal + +MAX_LOCAL_MODULES = 500 +ALLOWED_EXTS = {".js", ".ts", ".py"} +EXT_TO_LANGUAGE: dict[str, Literal["javascript", "typescript", "python"]] = { + ".js": "javascript", + ".ts": "typescript", + ".py": "python", +} + +ScanFn = Callable[[str], Iterable[str]] + + +@dataclass(frozen=True) +class LocalModule: + name: str + language: Literal["javascript", "typescript", "python"] + path: Path + source: str = "" # populated by resolve_required(); empty after discover() + + +class LocalModuleResolver: + """Discovers and validates local script modules under a root. + + Walks subdirectories recursively. Modules are keyed by their relative + POSIX path (e.g. ``"utils/jwt.js"``, not just ``"jwt"``). + """ + + def __init__(self, root: Path | None = None) -> None: + from services.scripting.runtime_settings import RuntimeSettings + self._root = (root or RuntimeSettings.local_modules_dir()).resolve() + self._root.mkdir(parents=True, exist_ok=True) + + @property + def root(self) -> Path: + return self._root + + def discover(self) -> dict[str, LocalModule]: + """Recursive scan. Returns ``{rel_posix_path: LocalModule(source="")}``. + + Keys look like ``"jwt.js"`` for top-level files or + ``"utils/jwt.js"`` for nested ones (forward-slash separator, always). + Raises ValueError on cap exceeded or unsafe paths. + """ + modules: dict[str, LocalModule] = {} + for entry in sorted(self._root.rglob("*")): + if not entry.is_file(): + continue + if entry.suffix not in ALLOWED_EXTS: + continue + if not self._is_safe(entry): + continue + rel = entry.relative_to(self._root) + # Reject hidden dirs / files anywhere in the path (e.g. ``.git/``). + if any(part.startswith(".") for part in rel.parts): + continue + key = rel.as_posix() + modules[key] = LocalModule( + name=key, + language=EXT_TO_LANGUAGE[entry.suffix], + path=entry, + source="", + ) + if len(modules) > MAX_LOCAL_MODULES: + raise ValueError(f"too many local modules (> {MAX_LOCAL_MODULES})") + return modules + + def resolve_required( + self, + user_source: str, + scan_specs: ScanFn, + language: Literal["javascript", "python"], + ) -> dict[str, LocalModule]: + """Transitive closure of ``local:`` requires. + + ``scan_specs(source)`` yields **relative POSIX paths with extension** + (e.g. ``"utils/jwt.js"``). Returns ``{rel_path: LocalModule}`` with + ``source`` populated. Raises ValueError on cycles, missing modules, + unsafe paths, or cross-language imports. + """ + available = self.discover() + same_lang = { + "javascript": {"javascript", "typescript"}, + "python": {"python"}, + }[language] + reachable: dict[str, LocalModule] = {} + + def visit(rel: str, chain: tuple[str, ...]) -> None: + # Cheap path-shape rejection before any disk lookup. + if (".." in rel.split("/")) or rel.startswith("/") or "\\" in rel: + raise ValueError(f"pm.require: unsafe local path {rel!r}") + if rel in chain: + raise ValueError(f"local module cycle: {' -> '.join((*chain, rel))}") + if rel in reachable: + return + mod = available.get(rel) + if mod is None: + raise ValueError( + f"pm.require: local module {rel!r} not found in {self._root}" + ) + if mod.language not in same_lang: + raise ValueError( + f"pm.require: local module {rel!r} is {mod.language}; " + f"cannot be imported from {language}" + ) + src = mod.path.read_text(encoding="utf-8") + reachable[rel] = mod.with_source(src) + for inner in scan_specs(src): + visit(inner, (*chain, rel)) + + for n in scan_specs(user_source): + visit(n, ()) + return reachable + + def _is_safe(self, p: Path) -> bool: + """Rejects anything whose resolved real path escapes root. + + ``self._root`` is already ``resolve()``d in ``__init__`` so both + sides of ``relative_to`` are canonical (no symlink-in-the-root + edge case). ``p.resolve(strict=True)`` follows symlinks; if the + target lives outside the canonical root, ``relative_to`` raises. + """ + try: + resolved = p.resolve(strict=True) + except (FileNotFoundError, RuntimeError, OSError): + return False + try: + resolved.relative_to(self._root) + except ValueError: + return False + return True +``` + +Acceptance criteria: +- `discover()` lists only top-level `.js`/`.ts`/`.py` files. +- Two files `foo.js` + `foo.ts` → raises. +- > 500 files → raises. +- Symlink whose target is outside root → not included. +- File named `../escape.js` (via os call, not panel) → not included. + +### 1.2 — Modify `src/services/scripting/runtime_settings.py` + +Use the module's existing `_get_settings()` helper (line 128) — **do not** call `QSettings()` directly: that would write to a different namespace and break test isolation. + +Find the block of `_KEY_*` constants. Add: + +```python +_KEY_LOCAL_MODULES_DIR = "scripting/local_modules_dir" +``` + +Add these methods to `RuntimeSettings` (style copied from `deno_path()` / `set_deno_path()`): + +```python +@staticmethod +def local_modules_dir() -> Path: + s = _get_settings() + raw = str(s.value(_KEY_LOCAL_MODULES_DIR, "") or "") + p = Path(raw).expanduser() if raw else _default_local_modules_dir() + p.mkdir(parents=True, exist_ok=True) + return p + +@staticmethod +def set_local_modules_dir(p: Path) -> None: + s = _get_settings() + s.setValue(_KEY_LOCAL_MODULES_DIR, str(p)) +``` + +For the default path, **reuse the existing per-OS helper used by `DenoManager.runtime_dir()`** (see [src/services/scripting/deno_manager.py](../../src/services/scripting/deno_manager.py) around lines 80-90). Either: +- Extract that helper into a shared module function (`_user_data_dir() -> Path`) and call it from both, or +- Make `_default_local_modules_dir()` import `DenoManager` and use its base dir. + +Preferred: extract a small `_postmark_user_data_dir() -> Path` shared helper to avoid Windows/Linux drift. Add it in `runtime_settings.py`: + +```python +def _postmark_user_data_dir() -> Path: + """Returns the OS data dir base used across the scripting layer. + Single source of truth; DenoManager.runtime_dir() should delegate here too. + """ + import os, sys + if sys.platform.startswith("linux"): + base = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share").expanduser() + elif sys.platform == "darwin": + base = Path("~/Library/Application Support").expanduser() + elif sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA") or "~/AppData/Local").expanduser() + else: + base = Path("~/.local/share").expanduser() + return base / "postmark" + + +def _default_local_modules_dir() -> Path: + return _postmark_user_data_dir() / "scripts" +``` + +If `DenoManager.runtime_dir()` already inlines this logic, also refactor it to call `_postmark_user_data_dir()` in the same PR — single source of truth. + +Acceptance: +- `RuntimeSettings.local_modules_dir()` returns a `Path` that exists on disk. +- Setting a custom path persists across app restarts (verified by writing, dropping the `_get_settings()` reference, re-reading via a fresh `_get_settings()` instance). + +### 1.3 — Tests `tests/unit/services/test_local_modules_resolver.py` + +New file. Cases: +1. `test_default_dir_created` — `RuntimeSettings.local_modules_dir()` exists after call. +2. `test_discover_returns_js_ts_py_only` — drop `.txt`, `.md` files: not included. +3. `test_discover_skips_subdirs` — file inside a subfolder is not returned. +4. `test_discover_ambiguous_name_raises` — both `foo.js` and `foo.ts` → ValueError. +5. `test_discover_cap_raises` — create 501 files → ValueError. +6. `test_discover_rejects_symlink_outside_root` — symlink `link.js → /etc/passwd` not in result. +7. `test_resolve_required_transitive_js` — file A requires B; user script requires A → both in result. +8. `test_resolve_required_cycle_raises` — A requires B, B requires A → ValueError with cycle message. +9. `test_resolve_required_missing_raises` — user requires `local:missing` → ValueError naming missing. +10. `test_resolve_required_cross_language_raises` — Python user requires JS module → ValueError. + +### 1.4 — Tests `tests/unit/services/test_runtime_settings.py` (extend) + +Add: +- `test_local_modules_dir_default` — unset → returns default per-OS path under `postmark/scripts/`. +- `test_local_modules_dir_roundtrip` — set then get returns the same path. +- `test_local_modules_dir_autocreates` — getter creates the folder. + +PR 1 ships once the resolver tests and settings tests pass. No UI, no runtime change. + +--- + +## Section 2 — JS runtime `local:` support (PR 2) + +### 2.1 — Modify `src/services/scripting/js_runtime.py` + +**Specifier shape (extension mandatory)**: `pm.require("local:.js")` or `pm.require("local:.ts")`. Two regexes — keep registry detection clean: + +```python +_PM_REQUIRE_REGISTRY_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]""" + r"""(?Pnpm|jsr):(?P@?[\w./-]+?)""" + r"""(?:@(?P[^'"]+))?['"]\s*\)""", +) +# Local: accepts one or more segments separated by ``/``. Each segment must +# start with a letter/underscore/digit and contain only word chars / dots / dashes. +# No ``..``, no leading ``/``, no backslashes. +_PM_REQUIRE_LOCAL_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]local:""" + r"""(?P[A-Za-z0-9_][\w.-]*(?:/[A-Za-z0-9_][\w.-]*)*)\.(?Pjs|ts)""" + r"""['"]\s*\)""", +) +_NPM_NAME_RE = re.compile(r"^(@[a-z0-9][\w.-]*/)?[a-z0-9][\w.-]*(/[\w./-]+)?$", re.IGNORECASE) +_EXACT_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+([-+][\w.\-+]+)?$") +``` + +`PmRequireSpec` keeps three fields. For `local:` entries: `name` holds the **relative POSIX path without extension** (e.g. `"utils/jwt"`); `version` holds the suffix (`.js` / `.ts`). For registry entries: same as before (package name + version). + +```python +class PmRequireSpec(NamedTuple): + registry: str # "npm" | "jsr" | "local" + name: str # package (npm/jsr) or stem path "utils/jwt" (local) + version: str # version (npm/jsr) or ".js"/".ts" suffix (local) + + @property + def rel_path(self) -> str: + """Relative POSIX path with extension (``local:`` only).""" + assert self.registry == "local" + return f"{self.name}{self.version}" + + @property + def specifier(self) -> str: + if self.registry == "local": + return f"local:{self.rel_path}" + if self.version: + return f"{self.registry}:{self.name}@{self.version}" + return f"{self.registry}:{self.name}" + + @property + def ident(self) -> str: + """Safe identifier suffix for generated ``__pm_req_*`` symbols. + + Slashes and dots collapse to underscores so ``utils/jwt.js`` becomes + ``utils_jwt_js``. + """ + if self.registry == "local": + raw = f"local_{self.name}_{self.version.lstrip('.')}" + else: + raw = f"{self.registry}_{self.name}_{self.version or 'latest'}" + return re.sub(r"[^A-Za-z0-9_]", "_", raw) +``` + +`_detect_pm_require_specs` runs both regexes: + +```python +def _detect_pm_require_specs(script: str) -> list[PmRequireSpec]: + seen: dict[tuple[str, str, str], PmRequireSpec] = {} + for m in _PM_REQUIRE_REGISTRY_RE.finditer(script): + reg, name, ver = m.group("reg"), m.group("name"), m.group("ver") or "" + if not _NPM_NAME_RE.match(name): + raise ValueError(f"pm.require: invalid {reg} package name {name!r}") + if ver and not _EXACT_VERSION_RE.match(ver): + raise ValueError( + f"pm.require: version must be exact (got {ver!r}). " + "Ranges and tags like '^1.0' or 'latest' are not supported." + ) + seen[(reg, name, ver)] = PmRequireSpec(reg, name, ver) + for m in _PM_REQUIRE_LOCAL_RE.finditer(script): + path, ext = m.group("path"), m.group("ext") + suf = f".{ext}" + seen[("local", path, suf)] = PmRequireSpec("local", path, suf) + return list(seen.values()) + + +def _iter_pm_require_local_paths(source: str) -> Iterable[str]: + """Yield unique local **relative paths with extension** for the resolver. + + e.g. ``"jwt.js"``, ``"utils/jwt.js"``. + """ + seen: set[str] = set() + for m in _PM_REQUIRE_LOCAL_RE.finditer(source): + rel = f"{m.group('path')}.{m.group('ext')}" + if rel not in seen: + seen.add(rel) + yield rel +``` + +**Modify** `_pm_require_imports_block` (currently around line 148). Local file layout in the bundle workdir mirrors the disk tree under a `local/` subfolder so relative imports resolve naturally: + +``` +workdir/ +├── bundle.mjs ← the user-script bundle +├── local/ +│ ├── jwt.js ← copied from /jwt.js +│ ├── utils/ +│ │ └── jwt.js ← copied from /utils/jwt.js +│ └── auth/ +│ └── oauth/ +│ └── google.ts +``` + +So the emitted static import is `from "./local/utils/jwt.js"`. No file-renaming, no name flattening — disk path === bundle path. + +```python +def _pm_require_imports_block( + specs: list[PmRequireSpec], + local_paths: set[str] | None = None, +) -> str: + """Emit static ESM imports plus globalThis.__pm_require_modules registration. + + For ``local:`` specs, the caller MUST have written the source file to + ``/local/`` before invoking Deno (see ``deno_runtime``). + ``local_paths`` is the resolved closure (set of rel POSIX paths with + extension) — used to validate that every emitted import has a backing file. + """ + if not specs: + return "" + lines: list[str] = [] + entries: list[str] = [] + local_paths = local_paths or set() + for s in specs: + var = f"__pm_req_{s.ident}" + if s.registry == "local": + rel = s.rel_path + if rel not in local_paths: + raise ValueError( + f"pm.require: local module {rel!r} is not in the resolved closure" + ) + lines.append(f"import * as {var} from \"./local/{rel}\";") + entries.append(f" {json.dumps(s.specifier)}: {var}.default ?? {var}") + else: + lines.append(f"import * as {var} from {json.dumps(s.specifier)};") + entries.append(f" {json.dumps(s.specifier)}: {var}.default ?? {var}") + bare = f"{s.registry}:{s.name}" + if s.version and bare != s.specifier: + entries.append(f" {json.dumps(bare)}: {var}.default ?? {var}") + lines.append("globalThis.__pm_require_modules = Object.assign(") + lines.append(" globalThis.__pm_require_modules || {}, {") + lines.append(",\n".join(entries)) + lines.append("});") + return "\n".join(lines) + "\n" +``` + +### 2.2 — Modify `src/services/scripting/deno_runtime.py` + +**Critical algorithm change**: registry specifier detection must scan the union of user source + every reachable local module source. Otherwise `local:auth` calling `pm.require("npm:jose@5.2.0")` would never appear in the bundle. + +**Find** `_build_bundle_text` (around line 281) and `build_debug_bundle_text` (around line 321). Replace the specifier-detection step with this two-pass algorithm: + +```python +from services.scripting.local_modules import LocalModuleResolver +from services.scripting.js_runtime import _iter_pm_require_local_paths + +# Step 1: resolve local closure (yields paths with extension). +resolver = LocalModuleResolver() +local_mods = resolver.resolve_required( + user_source, _iter_pm_require_local_paths, language="javascript" +) + +# Step 2: union scan for registry/jsr specifiers (user + all local sources). +union_source = user_source + "\n" + "\n".join(m.source for m in local_mods.values()) +specs = _detect_pm_require_specs(union_source) + +# Split for emission: locals get relative file imports; npm/jsr get static imports. +registry_specs = [s for s in specs if s.registry in ("npm", "jsr")] +local_paths_set = set(local_mods.keys()) +local_specs_for_emit = [] +for rel in local_mods: + # Split rel "utils/jwt.js" → name="utils/jwt", version=".js" + base, _, ext = rel.rpartition(".") + local_specs_for_emit.append(PmRequireSpec("local", base, f".{ext}")) + +imports_block = _pm_require_imports_block( + registry_specs + local_specs_for_emit, local_paths=local_paths_set +) + +# `needs_net` derives from the SAME union-scanned specs so .npmrc + --allow-net +# stay in sync with what the bundle actually imports. +needs_net = any(s.registry in ("npm", "jsr") for s in specs) + +# Canonical 3-tuple return contract. +return bundle_text, local_mods, needs_net +``` + +**Error policy for `_build_bundle_text`.** All failures (invalid specifier, missing local, cycle, cross-language) propagate as `ValueError` from `_build_bundle_text`. The caller (`_run_bundle`) catches and converts to `_error_output(str(exc))`. **Do not** mix `ValueError` raises with `_error_output(...)` returns inside `_build_bundle_text`. + +**Find** `_run_bundle` (around line 471). Unpack the new 3-tuple; convert errors here; mirror the disk layout under `tdir/local/`: + +```python +try: + bundle_text, local_mods, needs_net = _build_bundle_text(...) +except ValueError as exc: + return _error_output(str(exc)) + +with tempfile.TemporaryDirectory(prefix="postmark-deno-") as tdir: + tdir_path = Path(tdir) + local_root = tdir_path / "local" + for rel, mod in local_mods.items(): + # rel is POSIX-style "utils/jwt.js" — preserves the original tree + # so the bundle's `import "./local/utils/jwt.js"` resolves. + dest = local_root / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(mod.source, encoding="utf-8") + argv, env = deno_ipc_argv_and_env(..., needs_net=needs_net, ...) + # ... existing bundle.mjs write, Deno spawn, etc. +``` + +**Verify** the `--allow-read=` argument already covers `tdir`. Look at `deno_ipc_argv_and_env` (around lines 180-225) — it should already include the workdir. If yes, no change. + +**`needs_net` — single source of truth.** Today `deno_ipc_argv_and_env` derives `needs_net` from `script_for_network_scan` (the user script only). Update its signature to accept `needs_net` as an **explicit parameter** instead of recomputing internally. Remove (or gate) any internal call to `_detect_pm_require_specs(script_for_network_scan)` that derives `needs_net` from the user script alone. + +**Every caller of `deno_ipc_argv_and_env` must pass the new `needs_net`** — including: +- `_run_bundle` (this PR) +- `build_debug_bundle_text` path / `deno_debug.py` callers (parity bullet below) +- Any utility helper that wraps spawn — grep with `grep -rn "deno_ipc_argv_and_env" src/` to catch them all. + +**ValueError policy.** Today an invalid specifier from `_detect_pm_require_specs` may force `needs_net = True` as a safe fallback (so the run fails with a clear network-permission error rather than silently dropping the spec). Under the union scan, an invalid specifier in *any* local module must instead **fail the run upfront** via `ValueError → _error_output`. Do **not** widen network access on parse failure — surface the error. + +**Interaction with private `.npmrc`** (shipped earlier): the per-execution `.npmrc` is only emitted when `needs_net` is True. With only `local:` specs, `needs_net=False` → no `.npmrc` written, no `--node-modules-dir` needed. Verify by reading the `.npmrc` emission code in `deno_ipc_argv_and_env` and gating it on the new explicit `needs_net` parameter. + +**Debug bundle parity.** `build_debug_bundle_text` (line 321) and the debug code path in [src/services/scripting/debug/deno_debug.py](../../src/services/scripting/debug/deno_debug.py) must use the **same** union scan + local-file materialization + `needs_net` computation as `_build_bundle_text`. Recommended: extract the shared algorithm into a helper + +```python +def _resolve_locals_and_specs(user_source: str) -> tuple[ + list[PmRequireSpec], # union specs + dict[str, LocalModule], # local closure + bool, # needs_net +]: + ... +``` + +called from both `_build_bundle_text` and `build_debug_bundle_text`. The debug spawn path must also pass `needs_net=...` into `deno_ipc_argv_and_env`. + +Acceptance for PR 2: running the same script through Send (normal) and Debug must produce identical local-module file writes and identical `needs_net` outcomes. Add a test `test_debug_bundle_matches_normal_bundle_for_local_modules`. + +### 2.3 — Tests for the unified API contract + +(Tests for the resolver and union-scan behavior are in §2.4 below; this section is intentionally short — error handling lives entirely in `_run_bundle`.) + +### 2.4 — Tests `tests/unit/services/test_pm_require_local_js.py` + +New file. Cases: +1. `test_regex_accepts_top_level_file` — `_PM_REQUIRE_LOCAL_RE.search('pm.require("local:foo.js")')` matches with `path=foo`, `ext=js`. +2. `test_regex_accepts_subdir_path` — `'pm.require("local:utils/jwt.ts")'` matches with `path=utils/jwt`, `ext=ts`. +3. `test_regex_accepts_deep_path` — `'pm.require("local:auth/oauth/google.ts")'` matches with `path=auth/oauth/google`. +4. `test_regex_rejects_local_without_extension` — `'pm.require("local:foo")'` returns no `local` specs. +5. `test_regex_rejects_local_with_version` — `'pm.require("local:foo.js@1.2.3")'` does not match. +6. `test_regex_rejects_dotdot_in_path` — `'pm.require("local:../escape.js")'` does not match (regex doesn't allow `..`). +7. `test_regex_rejects_leading_slash` — `'pm.require("local:/etc/foo.js")'` does not match. +8. `test_imports_block_emits_relative_import` — `_pm_require_imports_block([PmRequireSpec("local","utils/jwt",".ts")], local_paths={"utils/jwt.ts"})` contains `from "./local/utils/jwt.ts"`. +9. `test_imports_block_rejects_unresolved_local` — passing a spec whose path isn't in `local_paths` raises ValueError. +10. `test_build_bundle_writes_local_files_preserving_tree` — fake `local_modules_dir` with `utils/jwt.ts`. Build bundle. Assert `tdir/local/utils/jwt.ts` exists. +11. `test_transitive_local_require_resolved` — `local:utils/a.js` requires `local:utils/b.js`. User script requires `local:utils/a.js`. Both files appear under `tdir/local/utils/`. +12. `test_union_scan_picks_up_registry_specs_inside_local` — `local:auth/oauth.js` source contains `pm.require("npm:jose@5.2.0")`; user script doesn't. Bundle imports block contains `from "npm:jose@5.2.0"`. +13. `test_missing_local_returns_error_output` — script requires `local:nope.js` → `_error_output` mentions `nope`. +14. `test_extension_mismatch_returns_error_output` — only `foo.ts` on disk; script requires `local:foo.js` → error mentions mismatch. +15. `test_local_only_does_not_set_needs_net` — only `local:` specs → `needs_net` is False. +16. `test_cycle_returns_error_output` — A → B → A → `_error_output` with "cycle". +17. `test_debug_bundle_matches_normal_bundle_for_local_modules` — `_build_bundle_text` and `assemble_debug_bundle_with_meta` agree on `local_mods` + `needs_net` for path-bearing specs. +18. `test_resolver_rejects_symlink_outside_root` — symlink in a subdirectory pointing outside is excluded from `discover()`. + +### 2.5 — Modify `data/scripts/pm_bootstrap.js` + +Find the `pm.require` implementation (around lines 1019-1060). In the existing "module not found" error branch, add a hint for `local:` prefix: + +```js +if (spec.startsWith("local:")) { + throw new Error( + `pm.require(${JSON.stringify(spec)}): local module not found. ` + + `Create a file named ${spec.slice(6)}.js or ${spec.slice(6)}.ts ` + + `in your local modules folder.` + ); +} +// existing error path for npm/jsr ... +``` + +PR 2 ships once `test_pm_require_local_js.py` is green and a manual JS smoke works (see Verification). + +--- + +## Section 3 — Python runtime `local:` support (PR 3) + +### 3.1 — Modify `src/services/scripting/py_runtime.py` + +**Specifier shape**: `pm.require("local:.py")` where `` is one or more `/`-separated **Python-identifier** segments (so `importlib` can register dotted names like `__pm_local_utils.jwt`). + +Add a second regex (the existing `_PM_REQUIRE_PY_RE` matches only bare PyPI names). Insert after the existing regex (line 53): + +```python +_PM_REQUIRE_PY_LOCAL_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]local:""" + r"""(?P[A-Za-z_][A-Za-z0-9_]*(?:/[A-Za-z_][A-Za-z0-9_]*)*)\.py""" + r"""['"]\s*\)""", +) +``` + +**Modify** `_PM_REQUIRE_PY_RE` so it cannot accidentally match `local:foo.py`. Add a negative lookahead to the bare-name regex: + +```python +_PM_REQUIRE_PY_RE = re.compile( + r"""pm\s*\.\s*require\s*\(\s*['"]""" + r"""(?!local:)(?P[a-z0-9][a-z0-9._-]*)""" + r"""(?:==(?P[^'"]+))?['"]\s*\)""", + re.IGNORECASE, +) +``` + +**Extend** `PmPyRequireSpec` with a `kind` field. Put `kind` first so test constructors read naturally (`PmPyRequireSpec("pip", "requests", "2.31.0")` reads better than `PmPyRequireSpec("requests", "2.31.0", "pip")`): + +```python +class PmPyRequireSpec(NamedTuple): + kind: Literal["pip", "local"] + name: str # PyPI package or local stem + version: str # version (pip only — empty for local) + + @property + def pip_spec(self) -> str: + if self.kind == "local": + raise ValueError("local modules have no pip spec") + return f"{self.name}=={self.version}" if self.version else self.name +``` + +**Modify** `detect_pm_require_py_specs`: + +```python +def detect_pm_require_py_specs(source: str) -> list[PmPyRequireSpec]: + seen: dict[tuple[str, str, str], PmPyRequireSpec] = {} + for m in _PM_REQUIRE_PY_LOCAL_RE.finditer(source): + path = m.group("path") # e.g. "utils/jwt" + seen[("local", path, "")] = PmPyRequireSpec("local", path, "") + for m in _PM_REQUIRE_PY_RE.finditer(source): + name = m.group("name").lower() + ver = m.group("ver") or "" + if ver and not _PY_EXACT_VERSION_RE.match(ver): + raise ValueError( + f"pm.require: version must be exact (got {ver!r})." + ) + seen[("pip", name, ver)] = PmPyRequireSpec("pip", name, ver) + return list(seen.values()) + + +def _iter_pm_require_py_local_paths(source: str) -> Iterable[str]: + """Yield unique local **relative paths with extension** for the resolver. + + e.g. ``"jwt.py"``, ``"utils/jwt.py"`` — matches the resolver's key format. + """ + seen: set[str] = set() + for m in _PM_REQUIRE_PY_LOCAL_RE.finditer(source): + rel = f"{m.group('path')}.py" + if rel not in seen: + seen.add(rel) + yield rel +``` + +### 3.2 — Modify `src/services/scripting/pyodide_runtime.py` + +**Find** `PyodideRuntime.execute` (currently lines 192-264). Replace specifier detection with the same two-pass algorithm as JS (resolve locals → union scan for pip specs): + +```python +from services.scripting.py_runtime import ( + detect_pm_require_py_specs, _iter_pm_require_py_local_stems, +) +from services.scripting.local_modules import LocalModuleResolver + +# Step 1: resolve local closure. +resolver = LocalModuleResolver() +try: + local_mods = resolver.resolve_required( + script, _iter_pm_require_py_local_stems, language="python" + ) +except ValueError as exc: + return _err(str(exc)) + +# Step 2: scan pip specs across user + all local sources. +union_source = script + "\n" + "\n".join(m.source for m in local_mods.values()) +try: + all_specs = detect_pm_require_py_specs(union_source) +except ValueError as exc: + return _err(str(exc)) +pip_specs = [s.pip_spec for s in all_specs if s.kind == "pip"] + +local_py_modules_payload = {stem: m.source for stem, m in local_mods.items()} +``` + +Then in the payload (lines 224-230) — call the key `local_py_modules` (parallel to existing `pm_require` / `pypi_index_urls`, narrows to "this is the Python local-module bundle"): + +```python +payload = { + "user_script": script, + "context": dict(context), + "pm_require": pip_specs, # PyPI only (drives micropip.install) + "pypi_index_urls": pypi_index_urls, + "local_py_modules": local_py_modules_payload, # NEW: {stem: source} +} +``` + +`needs_net = bool(pip_specs)` — locals never trigger network. + +**RestrictedPython subprocess path** (in `py_runtime.py`, both `PyRuntime.execute` and `PyRuntime.execute_restricted` non-Pyodide branches): before running, scan user source for `_PM_REQUIRE_PY_LOCAL_RE.search(...)`; if any present, return a `_runtime_error_output` with message: `'Local script modules (pm.require("local:.py")) require the Pyodide Python runtime (Deno + vendor_pyodide). The RestrictedPython sandbox cannot load them.'` Tests must cover both paths (see Section 3.6). + +### 3.3 — New file `data/scripts/pm_local_loader.py` + +Runs **inside Pyodide**. Stores under a namespaced **dotted** name so subdirectory layouts map cleanly to Python's module system. Path `utils/jwt.py` → `__pm_local_utils.jwt`. Never registers under the bare segment alone, so stdlib (`json`, `re`, …) cannot be shadowed. + +```python +r"""Register ``pm.require("local:.py")`` modules under ``__pm_local_.``. + +Loaded by ``pyodide_run.mjs`` before ``pm_bootstrap.py``. Sources arrive as +``{rel_posix_path: source_text}`` from the host. Each is exec'd into a fresh +module and registered under ``sys.modules`` using a dotted name derived from +the relative path (slashes → dots, ``.py`` stripped). The top-level +``__pm_local_`` package node is created on first call so dotted lookups work. +""" +from __future__ import annotations + +import re +import sys +import types + +_PACKAGE_ROOT = "__pm_local_" +_SAFE_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _ensure_package(dotted: str) -> None: + """Create empty parent package modules so dotted import resolves.""" + parts = dotted.split(".") + for i in range(1, len(parts)): + parent = ".".join(parts[:i]) + if parent not in sys.modules: + mod = types.ModuleType(parent) + mod.__path__ = [] # mark as package + sys.modules[parent] = mod + + +def register_pm_local_modules(sources: dict[str, str]) -> None: + """Register each *rel_path → source* pair under ``__pm_local_.``. + + Example: ``{"utils/jwt.py": "..."}`` becomes ``sys.modules["__pm_local_.utils.jwt"]``. + Raises ValueError for invalid segments, duplicate registration, or unsafe paths. + """ + # Ensure the root package node exists. + if _PACKAGE_ROOT not in sys.modules: + root = types.ModuleType(_PACKAGE_ROOT) + root.__path__ = [] + sys.modules[_PACKAGE_ROOT] = root + + for rel, src in sources.items(): + if not rel.endswith(".py") or ".." in rel.split("/") or rel.startswith("/"): + raise ValueError(f"invalid local module path {rel!r}") + segments = rel[:-3].split("/") # strip .py + for seg in segments: + if not _SAFE_SEGMENT.match(seg): + raise ValueError(f"invalid segment {seg!r} in {rel!r}") + dotted = f"{_PACKAGE_ROOT}." + ".".join(segments) + if dotted in sys.modules: + raise ValueError(f"local module {dotted!r} is already registered") + _ensure_package(dotted) + module = types.ModuleType(dotted) + module.__file__ = f"" + exec(compile(src, module.__file__, "exec"), module.__dict__) + sys.modules[dotted] = module +``` + +### 3.4 — Modify `data/scripts/pm_bootstrap.py` + +Find `pm.require` (around line 1104). Prepend a `local:` branch **before** the existing pip-import path. Resolves via dotted name derived from the specifier path: `local:utils/jwt.py` → `__pm_local_.utils.jwt`. + +```python +def require(self, spec): + if not isinstance(spec, str): + raise RuntimeError("pm.require: specifier must be a string") + raw = spec.strip() + if raw.startswith("local:"): + body = raw[len("local:"):] + if not body.endswith(".py"): + raise RuntimeError( + 'pm.require: local Python modules must use an explicit ".py" suffix' + ) + if ".." in body.split("/") or body.startswith("/"): + raise RuntimeError(f"pm.require: unsafe local path {raw!r}") + segments = body[:-3].split("/") + if not segments or any(not s for s in segments): + raise RuntimeError("pm.require: empty local module name") + dotted = "__pm_local_." + ".".join(segments) + try: + return importlib.import_module(dotted) + except ModuleNotFoundError as exc: + raise RuntimeError( + f"pm.require({spec!r}): local module not registered " + f"(expected importable {dotted!r})" + ) from exc + # ... existing path: importlib.import_module(spec) etc. +``` + +### 3.5 — Modify `data/scripts/pyodide_run.mjs` + +Locate `main()` (around line 130-200). After `micropip.install(...)` and **before** `pm_bootstrap.py` is loaded: + +```javascript +// After micropip.install(...) of pip specs: +const localMap = + inp.local_py_modules && + typeof inp.local_py_modules === "object" && + !Array.isArray(inp.local_py_modules) + ? inp.local_py_modules + : {}; +if (Object.keys(localMap).length > 0) { + const loaderPath = join(_here, "pm_local_loader.py"); + const loaderSrc = readFileSync(loaderPath, { encoding: "utf-8" }); + await pyodide.runPythonAsync(loaderSrc); + const localsJson = JSON.stringify(localMap); + await pyodide.runPythonAsync( + `register_pm_local_modules(__import__("json").loads(${JSON.stringify( + localsJson, + )}))`, + ); +} +// then existing: load pm_bootstrap.py, then exec user_script +``` + +Update the file's docstring at the top to mention the new payload field: +``` +// Stdin: one JSON line +// { user_script, context, pm_require: string[], pypi_index_urls?: string[], +// local_py_modules?: { [stem: string]: string } } +``` + +### 3.6 — Tests `tests/unit/services/test_pm_require_local_py.py` + +New file. Cases: +1. `test_detect_specs_recognizes_local` — `detect_pm_require_py_specs('pm.require("local:util.py")')` returns one spec `PmPyRequireSpec("local","util","")`. +2. `test_detect_specs_keeps_pip_separate` — mixed `pm.require("requests")` + `pm.require("local:util.py")` returns one of each kind. +3. `test_detect_specs_rejects_local_without_py_suffix` — `pm.require("local:foo")` is not matched as either kind; `_iter_pm_require_py_local_stems` yields nothing for it. +4. `test_pip_regex_does_not_match_local_prefix` — negative lookahead works: `pm.require("local:foo.py")` does not produce a pip spec. +5. `test_payload_includes_local_py_modules` — mock `LocalModuleResolver` + `subprocess.Popen`; parsed payload JSON contains `local_py_modules: {"util": "..."}`. +6. `test_pip_specs_unaffected_by_local` — when both kinds present, `pm_require` field contains only pip specs. +7. `test_payload_union_scans_for_pip` — local module source contains `pm.require("requests==2.31")`; user script does not. Payload `pm_require` field contains `requests==2.31` (union scan). +8. `test_pm_local_loader_registers_under_namespace` — call `register_pm_local_modules({"foo": "x=1"})`; assert `sys.modules["__pm_local_foo"]` has `x == 1`; assert `sys.modules.get("foo") is None`. +9. `test_pm_local_loader_does_not_shadow_stdlib` — `register_pm_local_modules({"json": "POISONED=True"})`; verify `import json` still returns the real stdlib `json`. +10. `test_pm_local_loader_rejects_invalid_stem` — `register_pm_local_modules({"123bad": "x=1"})` raises `ValueError`. +11. `test_pm_local_loader_rejects_duplicate_registration` — calling `register_pm_local_modules({"foo": "x=1"})` twice raises (defensive). +12. `test_pm_bootstrap_local_branch_uses_namespaced_lookup` — register `__pm_local_foo`; call `pm.require("local:foo.py")`; assert identity. +13. `test_pm_bootstrap_local_branch_rejects_missing_py_suffix` — `pm.require("local:foo")` raises with the "explicit '.py' suffix" message. +14. `test_missing_local_returns_error` — user script requires `local:nope.py` with empty modules dir → `PyodideRuntime.execute` returns `{"error": ...}` mentioning `nope`. +15. `test_execute_path_rejects_local` — invoke `PyRuntime.execute` (non-Pyodide branch) with a script containing `pm.require("local:foo.py")`; assert error result includes "Pyodide". +16. `test_execute_restricted_path_rejects_local` — same for `PyRuntime.execute_restricted`. +17. `test_cross_language_python_requires_js_raises` — `local:helper.js` exists, user Python script requires `local:helper.py` → resolver "not found" error (file with that stem+`.py` doesn't exist; the `.js` file is ignored for Python). + +### 3.7 — Modify `tests/unit/services/test_pm_python_parity.py` + +Add cases: +- Both JS regex and Python regex detect their respective `local:` shapes. +- Both regexes reject `local:foo` (no extension). +- Both regexes reject `local:foo.txt` (wrong extension). + +PR 3 ships once Python tests are green and a manual Python smoke works. + +--- + +## Section 4 — Left-pane toggle row (PR 4) + +### 4.0 — Architecture diagram (Cursor primary-sidebar pattern) + +**Before** (today): +``` +┌────────────────┬───────────────┬───────────────┐ +│ collection_ │ request + │ right │ +│ widget │ response │ sidebar │ +│ │ │ │ +└────────────────┴───────────────┴───────────────┘ + splitter slot 0 slot 1 slot 2 +``` + +**After** (this PR — slot 0 only changes its contents): +``` +┌────────────────┬───────────────┬───────────────┐ +│ LeftSidebarPane│ request + │ right │ +│ ┌────────────┐ │ response │ sidebar │ +│ │[📁][] │ │ (unchanged) │ (unchanged) │ +│ ├────────────┤ │ │ │ +│ │ Stack page0│ │ │ │ +│ │ Collection │ │ │ │ +│ │ Widget │ │ │ │ +│ │ (verbatim) │ │ │ │ +│ │ OR │ │ │ │ +│ │ Stack page1│ │ │ │ +│ │ Scripts │ │ │ │ +│ │ Panel │ │ │ │ +│ └────────────┘ │ │ │ +└────────────────┴───────────────┴───────────────┘ + slot 0 slot 1 slot 2 +``` + +`main_splitter` keeps **exactly the same number of slots** (3, as today). Slot 0's child widget changes from `CollectionWidget` directly → `LeftSidebarPane` wrapper. Inside the wrapper, the existing `CollectionWidget` is page 0 of a `QStackedWidget`; the new `ScriptsPanel` is page 1. The toggle row sits above the stack. + +This is the Cursor primary-sidebar pattern: icon row at top of the pane, content below. + +### 4.1 — New file `src/ui/sidebar/left_pane.py` + +Single widget — `LeftSidebarPane(QWidget)`. Owns the toggle row + a `QStackedWidget` for content. Takes the existing `CollectionWidget` instance and a new `ScriptsPanel` instance via constructor (no re-parent gymnastics). + +```python +from __future__ import annotations + +from PySide6.QtCore import QSettings, Qt, Signal +from PySide6.QtWidgets import ( + QHBoxLayout, QSizePolicy, QStackedWidget, QToolButton, QVBoxLayout, QWidget, +) + +from ui.styling.icons import phi + + +_TOGGLE_BTN_HEIGHT = 24 +_TOGGLE_ROW_PADDING = 4 + + +class LeftSidebarPane(QWidget): + """Horizontal toggle row at the top + stacked content below. + + Pages: 0 = Collections (existing CollectionWidget), 1 = Scripts (ScriptsPanel). + """ + + panel_changed = Signal(str) # emits "collections" or "scripts" + + def __init__( + self, + collections_widget: QWidget, + scripts_widget: QWidget, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setObjectName("leftSidebarPane") + + # --- Toggle row ----------------------------------------------- + row = QWidget(self) + row.setObjectName("leftPaneToggleRow") + row.setFixedHeight(_TOGGLE_BTN_HEIGHT + _TOGGLE_ROW_PADDING * 2) + row_lay = QHBoxLayout(row) + row_lay.setContentsMargins( + _TOGGLE_ROW_PADDING, _TOGGLE_ROW_PADDING, + _TOGGLE_ROW_PADDING, _TOGGLE_ROW_PADDING, + ) + row_lay.setSpacing(4) + + # Both buttons MUST have an icon. Icons make Cursor's pattern legible + # at a glance; an iconless toggle row is unacceptable. + # Use phi() (Phosphor icon font). Verify the icon names exist in + # data/fonts/phosphor-charmap.json before PR 4 — fallbacks listed below. + self._collections_btn = self._make_toggle_btn( + "tree-structure", "Collections (Ctrl+1)" + ) + # Phosphor icon options for the Scripts button — pick whichever reads + # cleanest at 16px on this app's theme: "code", "file-code", + # "code-block", or "brackets-curly". + self._scripts_btn = self._make_toggle_btn( + "code", "Scripts (Ctrl+2)" + ) + row_lay.addWidget(self._collections_btn) + row_lay.addWidget(self._scripts_btn) + row_lay.addStretch(1) + + # --- Content stack -------------------------------------------- + self._stack = QStackedWidget(self) + self._stack.addWidget(collections_widget) # index 0 + self._stack.addWidget(scripts_widget) # index 1 + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + outer.addWidget(row) + outer.addWidget(self._stack, 1) + + self._collections_widget = collections_widget + self._scripts_widget = scripts_widget + self._active: str = "collections" + + self._collections_btn.clicked.connect( + lambda: self.set_active_panel("collections") + ) + self._scripts_btn.clicked.connect( + lambda: self.set_active_panel("scripts") + ) + + # Restore last active panel. + s = self._qsettings() + restored = str(s.value("ui/left_pane/active", "collections") or "collections") + self.set_active_panel(restored if restored in {"collections", "scripts"} else "collections") + + def _make_toggle_btn(self, icon_name: str, tooltip: str) -> QToolButton: + btn = QToolButton(self) + btn.setObjectName("leftPaneToggleButton") + btn.setCheckable(True) + btn.setAutoRaise(True) + btn.setToolTip(tooltip) + btn.setIcon(phi(icon_name, size=16)) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + btn.setFixedSize(_TOGGLE_BTN_HEIGHT + 4, _TOGGLE_BTN_HEIGHT) + return btn + + def _qsettings(self) -> QSettings: + from ui.styling.theme_manager import _ORG, _APP + return QSettings(_ORG, _APP) + + def set_active_panel(self, name: str) -> None: + """Show the named page (``"collections"`` or ``"scripts"``).""" + if name not in {"collections", "scripts"}: + return + self._active = name + self._stack.setCurrentIndex(0 if name == "collections" else 1) + self._collections_btn.setChecked(name == "collections") + self._scripts_btn.setChecked(name == "scripts") + self._qsettings().setValue("ui/left_pane/active", name) + self.panel_changed.emit(name) + + def active_panel(self) -> str: + return self._active + + def collections_widget(self) -> QWidget: + return self._collections_widget + + def scripts_widget(self) -> QWidget: + return self._scripts_widget +``` + +**Notes on the design:** +- The toggle row is a plain `QHBoxLayout` of `QToolButton`s — same pattern Postmark already uses for `CollectionHeader`'s "New" / "Import" buttons. Cheap, no novel infrastructure. +- The content area is a vanilla `QStackedWidget`, the same pattern used by `_editor_stack` / `_response_stack` in `main_window/window.py`. No `QDockWidget`. +- No `install_in_splitter` shenanigans. `LeftSidebarPane` is just a `QWidget` you drop into the splitter where `collection_widget` used to go. +- No collapse behavior — clicking the active button is a no-op (idempotent). The whole left pane collapses via the existing `_toggle_sidebar` action (which hides this `LeftSidebarPane`). + +### 4.2 — QSS styling + +Add to [src/ui/styling/global_qss.py](../../src/ui/styling/global_qss.py) inside the same f-string block that already styles `sidebarToolButton`: + +```python +QWidget#leftPaneToggleRow {{ + background: {p["bg_alt"]}; + border-bottom: 1px solid {p["border"]}; +}} +QToolButton#leftPaneToggleButton {{ + background: transparent; + border: none; + padding: 2px; + border-radius: 4px; + color: {p["text_muted"]}; +}} +QToolButton#leftPaneToggleButton:hover {{ + background: {p["hover_bg"]}; + color: {p["text"]}; +}} +QToolButton#leftPaneToggleButton:checked {{ + background: {p["selected_bg"]}; + color: {p["accent"]}; +}} +``` + +Use existing palette keys only (`bg_alt`, `border`, `hover_bg`, `selected_bg`, `accent`, `text`, `text_muted`). If `text_muted` doesn't exist, grep the file for the actual key name and substitute. + +### 4.3 — Modify `src/ui/main_window/window.py` + +Currently (per Phase-1 exploration, [window.py:469](../../src/ui/main_window/window.py#L469)): +```python +self._main_splitter.addWidget(self.collection_widget) +``` + +Replace with: +```python +from ui.sidebar.left_pane import LeftSidebarPane +from ui.sidebar.scripts_panel import ScriptsPanel + +self._scripts_panel = ScriptsPanel() +self._scripts_panel.file_open_requested.connect(self._open_script_module_tab) +self._left_sidebar_pane = LeftSidebarPane( + collections_widget=self.collection_widget, + scripts_widget=self._scripts_panel, +) +self._main_splitter.addWidget(self._left_sidebar_pane) +``` + +**Keyboard shortcuts** — add after splitter setup: + +```python +from PySide6.QtGui import QShortcut, QKeySequence +QShortcut(QKeySequence("Ctrl+1"), self, + activated=lambda: self._left_sidebar_pane.set_active_panel("collections")) +QShortcut(QKeySequence("Ctrl+2"), self, + activated=lambda: self._left_sidebar_pane.set_active_panel("scripts")) +``` + +**Existing `_toggle_sidebar`** (around [window.py:547](../../src/ui/main_window/window.py#L547)) — keep as-is: it toggles visibility of the whole `LeftSidebarPane` (which is now what occupies the slot `collection_widget` used to occupy). No code change needed there; just verify the reference points to `self._left_sidebar_pane` instead of `self.collection_widget`. + +**Settings → re-rooting hook**: +```python +def _on_local_modules_dir_changed(self) -> None: + self._scripts_panel.refresh_root() +``` +Connect to `SettingsDialog.local_modules_dir_changed` signal (Section 6.7). + +### 4.4 — Tests `tests/ui/sidebar/test_left_pane.py` + +New file. Cases: +1. `test_starts_on_collections_by_default` — fresh QSettings, default page index = 0. +2. `test_set_active_panel_switches_stack` — `set_active_panel("scripts")` → `stack.currentIndex() == 1`. +3. `test_buttons_reflect_active_panel` — after switch, `scripts_btn.isChecked() is True` and `collections_btn.isChecked() is False`. +4. `test_panel_changed_signal_emits` — switching emits `"scripts"`. +5. `test_active_panel_persisted_via_qsettings` — set, recreate widget, restored. +6. `test_clicking_active_button_is_idempotent` — calling `set_active_panel("collections")` twice doesn't toggle off; the pane never enters a no-active state. +7. `test_invalid_panel_name_ignored` — `set_active_panel("bogus")` is a no-op; active stays the same. + +### 4.5 — Tests `tests/ui/test_main_window.py` (extend) + +Two cases: +1. `test_left_pane_is_left_sidebar_pane` — `main_splitter.widget(0)` is a `LeftSidebarPane`. +2. `test_collection_widget_still_accessible_via_left_pane` — `main_window.collection_widget` resolves to the same instance held by `LeftSidebarPane.collections_widget()`. + +PR 4 ships once tests pass + manual smoke: opens app → see toggle row above collections → click Scripts icon → **Scripts tree** appears → click Collections icon → tree back. + +--- + +## Section 5 — Scripts panel (PR 5) + +### 5.-1 — Tree layout (mirrors the Collections tree pattern) + +The Scripts panel is a **tree** (not a flat list) — same visual pattern as the existing collections sidebar. Folders contain files; folders expand/collapse; selection lands on either a file or a folder. + +**Reuse path**: subclass / parametrize the existing collections-tree machinery so the visual feel matches the rest of the app. + +- `CollectionTree` lives at [src/ui/collections/tree/collection_tree.py](../../src/ui/collections/tree/collection_tree.py). +- The base widget is `DraggableTreeWidget` at [src/ui/collections/tree/draggable_tree_widget.py](../../src/ui/collections/tree/draggable_tree_widget.py) — `QTreeWidget` subclass with custom delegate styling. +- The `CollectionTreeDelegate` provides the row styling used app-wide for sidebar trees. + +**Approach**: extract the styling-only bits (delegate + icon set + indent + spacing) into a reusable base. The Scripts panel instantiates that base and populates it from disk. The existing `CollectionTree`'s drag-drop / persistence logic is **not** reused (it persists to SQL; we're persisting to disk by rename). Only the **visual** layer is shared. + +Concretely, the Scripts panel tree: +- Top-level entries = direct children of the local-modules root (folder OR file). +- Folder entries are expandable; their children come from recursive `iterdir()`. +- File entries display the base name (e.g. `jwt.js`) — extension stays visible so users see the language at a glance. +- Sort: folders first (alphabetical), then files (alphabetical). +- Each row's `UserRole` data carries the resolved absolute `Path`. +- Hidden dirs (`.git`, etc.) and unsupported extensions are filtered out — same rules as `LocalModuleResolver.discover()`. + + + +### 5.0 — Required UI mockup (Collections parity + tree) + +**(Amended)** The Scripts page must **look and behave like the Collections sidebar**: a **named section** inside the panel (not only the icon toggle above), **“+ New”** and **“Refresh”** as **link-style / text+beside-icon** actions matching [`CollectionHeader`](../../src/ui/collections/collection_header.py), a **search field** with placeholder (e.g. `Search scripts`) filtering the tree, then the **folder/file tree**. The left icon row (§4) answers “which sidebar page”; the **Scripts header** answers “what am I editing” — same two-level pattern as Collections (rail vs section). + +``` +┌────────────────────────────────────────────────┐ +│ [📁][] ← toggle row from Section 4 │ +├────────────────────────────────────────────────┤ +│ Scripts + New Refresh │ ← row 1: section label + actions (mirror CollectionHeader) +├────────────────────────────────────────────────┤ +│ 🔍 Search scripts… │ ← row 2: QLineEdit (objectName sidebarSearch) +├────────────────────────────────────────────────┤ +│ [ optional compact row: delete / rename / … ] │ ← optional icon row OR overflow ⋯ menu (see 5.0a) +├────────────────────────────────────────────────┤ +│ 📁 auth │ +│ 📄 oauth.ts │ +│ 📄 google.ts │ +│ 📁 utils │ +│ 📄 jwt.js ← selected │ +│ 📄 shout.py │ +│ 📄 jwt.js │ +│ 📄 validators.py │ +│ │ +│ No modules yet. │ ← empty-state (no “subfolders ignored” copy) +│ Click “+ New” to create one. │ +└────────────────────────────────────────────────┘ + │ + └── Right-click on a file row: + ┌────────────────────────────────┐ + │ New file ▸ │ → same flow as + New (or opens Create New dialog) + │ Copy import specifier │ → clipboard local: + │ Reveal in file manager │ + │ ─────────────────────────── │ + │ Rename… │ + │ Delete │ + └────────────────────────────────┘ +``` + +### 5.0a — Required UI elements (reviewer checklist) **(Amended)** + +None of the following are optional. A prior implementation shipped a bare list with no mutations — reject that. + +1. **Scripts header (Collections-shaped)** — `QLabel` “Scripts” (`sidebarSectionLabel`), **`+ New`** (`sidebarToolButton`, text beside icon, opens **Create New** dialog — §5.0b), **`Refresh`** (`sidebarToolButton` or `linkButton`-style) calling the same rescan as the 5s timer, preserving selection when possible. Model directly on `CollectionHeader`’s first row (without Import unless product wants an analogue such as “Open folder”). +2. **Search row** — full-width `QLineEdit`, `sidebarSearch`, leading magnifier action, emits `search_changed` (or equivalent) to filter visible tree rows **without** hiding folder ancestors of matches (standard tree-filter behaviour). +3. **Tree** — as §5.-1: folders, script files, double-click file → `file_open_requested(Path)`; double-click folder expands/collapses only. +4. **Create New dialog (Postman-style)** — **§5.0b**; do **not** use a bare `QInputDialog` as the only UI for creating a module (that was the gap vs Collections). +5. **Context menu** on tree items — New file submenu, Copy import specifier (relative POSIX path under root), Reveal, Rename, Delete; mirror toolbar when a secondary toolbar exists. +6. **Selection-aware controls** — disable delete/rename when inappropriate; disable mutations when `os.access(root, os.W_OK)` is False (tooltip: “Folder is read-only”). +7. **Empty-state label** when no eligible files: e.g. “No modules yet. Click ‘+ New’ to create one.” **Do not** claim subfolders are ignored (they are supported). + +**Optional secondary chrome:** A row of small icon buttons (delete, rename, open folder) may remain for parity with early mockups; if present, it sits **below** the search row. Primary discovery remains **+ New** + **Refresh** + search like Collections. + +### 5.0b — `NewScriptModulePopup` (mirror `NewItemPopup`) **(Amended)** + +Add a modal dialog alongside [`NewItemPopup`](../../src/ui/collections/new_item_popup.py): same window chrome (`newItemPopup`, `newItemTitle`, tile `objectName`s, fixed size ~380×260, centered “What do you want to create?” style copy adapted for **script modules**). **Tiles**: at minimum **JavaScript**, **TypeScript**, **Python** (icons `file-js` / `file-ts` / `file-py` or Phosphor equivalents). Optional fourth tile: **Folder** (creates empty directory under root or under selected folder). After tile choice, prompt for **name** (second step inside same dialog or follow-up — implementation choice) then create `*.js|*.ts|*.py` or `mkdir`. Emit / callback → `file_open_requested` for new files. This satisfies “similar window to Collections New”. + +--- + +### 5.0 (legacy mockup — superseded) + +The ASCII block and numbered list in the **original** §5.0 (five-icon-only toolbar, no Scripts title, `QInputDialog`-only new file, empty-state “Subfolders are ignored”) is **retired**. It is replaced by **§5.0 + §5.0a + §5.0b** above. The rest of this document (§5.1 code, tests, wiring) still applies but **implementations must follow §5.0a layout**; update the §5.1 sample code accordingly (embed `ScriptsHeader`, wire search, replace `_on_new_file` to open `NewScriptModulePopup`). + +### 5.1 — New package `src/ui/sidebar/scripts_panel/` + +Files: +- `__init__.py` — `from .panel import ScriptsPanel` (re-export). +- `panel.py` — main widget (`ScriptsHeader` + search + tree + optional secondary toolbar per §5.0a). +- `scripts_header.py` — **(Amended)** row 1+2 mirroring [`CollectionHeader`](../../src/ui/collections/collection_header.py) (label, + New, Refresh, search field + signals). +- `new_script_popup.py` — **(Amended)** `NewScriptModulePopup` (§5.0b), same chrome as `NewItemPopup`. +- `actions.py` — context menu handlers (`prompt_new_module`, rename, delete, reveal, copy specifier). + +`panel.py`. **Tree layout** (mirrors the collections tree). Use `QTreeWidget` populated by hand via `Path.iterdir()` (not `QFileSystemModel`, so we keep full control of filtering/icons/sort). Walks subdirectories. + +**(Amended)** The sample `_build_ui` below still uses the **legacy five-icon toolbar** for illustration of tree wiring; the shipped widget must embed **`ScriptsHeader` + search + tree** per §5.0a (toolbar row optional). `_on_new_file` must open **`NewScriptModulePopup`** (§5.0b), not only `QInputDialog`. + +> **DO NOT COPY THIS CODE BLOCK.** It is illustrative only — kept for +> traceability of tree-wiring patterns. The shipped `ScriptsPanel` must +> instead follow the layout in Sections 5.0 / 5.0a / 5.0b (ScriptsHeader +> with "+ New", "Refresh", search row, folder tree, NewScriptModulePopup). + +```python +import os +from pathlib import Path +from PySide6.QtCore import Qt, Signal, QTimer +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QToolButton, + QTreeWidget, QTreeWidgetItem, QLabel, +) +from services.scripting.runtime_settings import RuntimeSettings +from ui.styling.icons import phi + + +_ALLOWED_EXTS = (".js", ".ts", ".py") +_ROLE_ABSOLUTE_PATH = Qt.ItemDataRole.UserRole + 1 +_ROLE_IS_DIR = Qt.ItemDataRole.UserRole + 2 + + +class ScriptsPanel(QWidget): + file_open_requested = Signal(Path) + file_renamed = Signal(Path, Path) + file_deleted = Signal(Path) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._root = RuntimeSettings.local_modules_dir() + self._build_ui() + self._refresh() + # Cheap poll for outside-of-app changes (5s). + self._poll = QTimer(self) + self._poll.setInterval(5000) + self._poll.timeout.connect(self._refresh) + self._poll.start() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + toolbar = QHBoxLayout() + toolbar.setContentsMargins(4, 4, 4, 4) + toolbar.setSpacing(4) + self._new_btn = self._mk_btn("plus", "New module") + self._delete_btn = self._mk_btn("trash", "Delete selected") + self._rename_btn = self._mk_btn("pencil-simple", "Rename selected") + self._refresh_btn = self._mk_btn("arrow-clockwise", "Refresh list") + self._reveal_btn = self._mk_btn("folder-open", "Open folder in file manager") + for b in (self._new_btn, self._delete_btn, self._rename_btn, + self._refresh_btn, self._reveal_btn): + toolbar.addWidget(b) + toolbar.addStretch(1) + layout.addLayout(toolbar) + + self._empty_label = QLabel( + 'No modules yet. Click "+ New" to create one.' + ) + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_label.setObjectName("scriptsEmptyState") + self._empty_label.hide() + layout.addWidget(self._empty_label) + + self._tree = QTreeWidget() + self._tree.setHeaderHidden(True) + self._tree.setIndentation(14) + self._tree.setExpandsOnDoubleClick(True) + self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemDoubleClicked.connect(self._on_double_click) + self._tree.itemSelectionChanged.connect(self._sync_button_enabled) + layout.addWidget(self._tree, 1) + + self._new_btn.clicked.connect(self._on_new_file) + self._delete_btn.clicked.connect(self._on_delete_selected) + self._rename_btn.clicked.connect(self._on_rename_selected) + self._refresh_btn.clicked.connect(self._refresh) + self._reveal_btn.clicked.connect(self._on_reveal) + + self._delete_btn.setEnabled(False) + self._rename_btn.setEnabled(False) + + def _mk_btn(self, icon_name: str, tooltip: str) -> QToolButton: + btn = QToolButton(self) + btn.setIcon(phi(icon_name, size=16)) + btn.setToolTip(tooltip) + btn.setAutoRaise(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setFixedSize(26, 26) + return btn + + def _sync_button_enabled(self) -> None: + sel = self._selected_path() + writable = os.access(self._root, os.W_OK) + self._delete_btn.setEnabled(sel is not None and writable) + self._rename_btn.setEnabled(sel is not None and writable) + self._new_btn.setEnabled(writable) + + def refresh_root(self) -> None: + """Called when Settings → Local modules path changes.""" + self._root = RuntimeSettings.local_modules_dir() + self._refresh() + + def _refresh(self) -> None: + # Preserve current selection by absolute path so refresh doesn't jump. + prev_sel = self._selected_path() + self._tree.clear() + if not self._root.is_dir(): + self._empty_label.show() + self._tree.hide() + return + has_any = self._populate_node(self._tree.invisibleRootItem(), self._root) + if not has_any: + self._empty_label.show() + self._tree.hide() + return + self._empty_label.hide() + self._tree.show() + if prev_sel is not None: + self._restore_selection(prev_sel) + + def _populate_node(self, parent: QTreeWidgetItem, dir_path: Path) -> bool: + """Recursively populate *parent* with the children of *dir_path*. + + Returns True if at least one descendant was added (so callers can detect + empty subtrees and prune the empty-state). + """ + # Sort: folders first (alpha), then files (alpha). Hidden + non-script files filtered. + children = sorted( + (p for p in dir_path.iterdir() if not p.name.startswith(".")), + key=lambda p: (not p.is_dir(), p.name.lower()), + ) + added = False + for child in children: + if child.is_dir(): + node = QTreeWidgetItem([child.name]) + node.setIcon(0, phi("folder", size=16)) + node.setData(0, _ROLE_ABSOLUTE_PATH, str(child)) + node.setData(0, _ROLE_IS_DIR, True) + if self._populate_node(node, child): + parent.addChild(node) + added = True + # else: empty subtree — skip the folder entirely + elif child.suffix.lower() in _ALLOWED_EXTS: + node = QTreeWidgetItem([child.name]) + node.setIcon(0, phi(_icon_for_ext(child.suffix), size=16)) + node.setData(0, _ROLE_ABSOLUTE_PATH, str(child)) + node.setData(0, _ROLE_IS_DIR, False) + parent.addChild(node) + added = True + return added + + def _restore_selection(self, target: Path) -> None: + """Re-select the row whose UserRole path matches *target*, if present.""" + target_s = str(target) + it = iter([self._tree.topLevelItem(i) for i in range(self._tree.topLevelItemCount())]) + # Walk all items via a stack. + stack = list(self._tree.topLevelItem(i) for i in range(self._tree.topLevelItemCount())) + while stack: + node = stack.pop() + if node.data(0, _ROLE_ABSOLUTE_PATH) == target_s: + node.setSelected(True) + self._tree.setCurrentItem(node) + return + stack.extend(node.child(i) for i in range(node.childCount())) + + def _selected_path(self) -> Path | None: + items = self._tree.selectedItems() + if not items: + return None + raw = items[0].data(0, _ROLE_ABSOLUTE_PATH) + return Path(raw) if raw else None + + def _selected_is_dir(self) -> bool: + items = self._tree.selectedItems() + return bool(items and items[0].data(0, _ROLE_IS_DIR)) + + def _on_double_click(self, item: QTreeWidgetItem, _col: int) -> None: + if item.data(0, _ROLE_IS_DIR): + return # let Qt handle expand/collapse + raw = item.data(0, _ROLE_ABSOLUTE_PATH) + if raw: + self.file_open_requested.emit(Path(raw)) + + def _on_context_menu(self, pos) -> None: + from .actions import build_context_menu + menu = build_context_menu(self, self._selected_path()) + menu.exec(self._tree.viewport().mapToGlobal(pos)) + + def _on_new_file(self) -> None: + from .actions import prompt_new_module + prompt_new_module(self, self._root, self._selected_path()) + + def _on_delete_selected(self) -> None: + from .actions import _prompt_delete + path = self._selected_path() + if path is not None: + _prompt_delete(self, path) + + def _on_rename_selected(self) -> None: + from .actions import _prompt_rename + path = self._selected_path() + if path is not None: + _prompt_rename(self, path) + + def _on_reveal(self) -> None: + from PySide6.QtCore import QUrl + from PySide6.QtGui import QDesktopServices + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._root))) + + +def _icon_for_ext(ext: str) -> str: + return { + ".js": "file-js", + ".ts": "file-ts", + ".py": "file-py", + }.get(ext.lower(), "file") +``` + +Wire `SettingsDialog.local_modules_dir_changed` → `ScriptsPanel.refresh_root` in `window.py`. + +`actions.py` (extend with `new_script_popup.py` for `NewScriptModulePopup` per §5.0b): + +```python +from pathlib import Path +from PySide6.QtCore import QUrl +from PySide6.QtGui import QAction, QClipboard +from PySide6.QtWidgets import QMenu, QInputDialog, QMessageBox, QApplication +from PySide6.QtGui import QDesktopServices + + +def build_context_menu(panel, path: Path | None) -> QMenu: + menu = QMenu(panel) + # New file submenu. + new_menu = menu.addMenu("New file") + for label, ext in [("JavaScript (.js)", ".js"), + ("TypeScript (.ts)", ".ts"), + ("Python (.py)", ".py")]: + a = new_menu.addAction(label) + a.triggered.connect( + lambda _checked=False, e=ext: prompt_new_module(panel, panel._root, panel._selected_path(), ext=e) + ) + if path and path.is_file(): + menu.addSeparator() + copy_a = menu.addAction("Copy import specifier") + copy_a.triggered.connect(lambda: _copy_import_specifier(panel._root, path)) + reveal_a = menu.addAction("Reveal in file manager") + reveal_a.triggered.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(path.parent)))) + menu.addSeparator() + rename_a = menu.addAction("Rename…") + rename_a.triggered.connect(lambda: _prompt_rename(panel, path)) + del_a = menu.addAction("Delete") + del_a.triggered.connect(lambda: _prompt_delete(panel, path)) + return menu + + +def prompt_new_module(panel, root: Path, selected: Path | None, ext: str = ".js") -> None: + """Open ``NewScriptModulePopup`` (§5.0b); then collect name and create under *root* or *selected* folder.""" + # Full implementation: see §5.0b. End state: ``panel.file_open_requested.emit(target_path)``. + pass + + +def prompt_new_file(panel, root: Path, ext: str = ".js") -> None: + """Legacy helper name — prefer ``prompt_new_module`` + popup. Kept for grep parity in tests.""" + prompt_new_module(panel, root, None, ext=ext) + + +def _copy_import_specifier(modules_root: Path, path: Path) -> None: + """Copy ``local:`` under *modules_root*.""" + rel = path.resolve().relative_to(modules_root.resolve()).as_posix() + spec = f"local:{rel}" + QApplication.clipboard().setText(spec) + + +def _prompt_rename(panel, path: Path) -> None: + new_name, ok = QInputDialog.getText(panel, "Rename", "New name (no extension):", text=path.stem) + if not ok or not new_name.strip(): + return + new_path = path.with_name(f"{new_name.strip()}{path.suffix}") + path.rename(new_path) + panel.file_renamed.emit(path, new_path) + + +def _prompt_delete(panel, path: Path) -> None: + if QMessageBox.question(panel, "Delete", f"Delete {path.name}?") != QMessageBox.StandardButton.Yes: + return + path.unlink() + panel.file_deleted.emit(path) +``` + +### 5.2 — Tests `tests/ui/sidebar/test_scripts_panel.py` + +New file. Cases (all required — sparse coverage was a flagged gap last time): +1. `test_root_matches_settings` — `local_modules_dir` set to tmp dir → panel's `_root` is that dir. +2. `test_filter_hides_unsupported_extensions` — drop `.md` / `.txt` → not in tree. +3. `test_subdirectory_files_visible_under_folder_node` — `utils/jwt.js` → top-level `utils` folder node with `jwt.js` child. +4. `test_deep_subdirectory_visible` — `auth/oauth/google.ts` → 3-level deep node visible. +5. `test_empty_subdirectory_pruned` — folder with no eligible files → folder node not shown (avoids visual clutter). +6. `test_hidden_dirs_filtered` — `.git/foo.js` → not in tree. +7. `test_folder_first_alpha_sort` — folders sorted before files within each level, alphabetical. +8. `test_double_click_on_file_emits_signal_with_path` — signal payload is `Path`, points to the absolute file. +9. `test_double_click_on_folder_does_not_emit_signal` — expanding a folder doesn't trigger `file_open_requested`. +10. `test_copy_import_specifier_top_level` — `Path("foo.js")` → clipboard has `local:foo.js`. +11. `test_copy_import_specifier_nested` — `utils/jwt.js` → clipboard has `local:utils/jwt.js`. +12. `test_copy_import_specifier_python_nested` — `helpers/shout.py` → clipboard has `local:helpers/shout.py`. +13. `test_new_file_creates_with_correct_extension` — mock dialog → file appears in tree, signal emits Path. +14. `test_new_file_in_selected_folder` — folder selected → new file created inside that folder. +15. `test_new_file_collision_shows_warning` — existing file → no overwrite, no signal. +16. `test_rename_emits_signal` — `(old_path, new_path)`. +17. `test_delete_confirmation_no_keeps_file` — user picks No → file still exists, no signal. +18. `test_delete_emits_signal_and_removes_file`. +19. `test_context_menu_on_file_includes_copy_specifier_rename_delete` — menu correctness. +20. `test_context_menu_on_folder_omits_copy_specifier` — folders have no specifier; menu adapts. +21. `test_read_only_root_disables_mutation_buttons` — `os.access` mocked False → New / Rename / Delete disabled. +22. `test_empty_root_shows_empty_state` — no eligible files → empty label visible, tree hidden. +23. `test_refresh_root_preserves_selection_when_possible` — select `utils/jwt.js`, refresh, selection restored. +24. `test_refresh_root_updates_on_settings_change` — change root → tree reflects new contents. +25. **(Amended)** `test_scripts_header_has_new_refresh_search` — widgets present; `Refresh` triggers `_refresh`. +26. **(Amended)** `test_new_module_popup_opens_from_header` — `+ New` shows `NewScriptModulePopup` (or equivalent exec). + +### 5.3 — Wire panel into main window + +In `window.py` (already touched in PR 4): + +```python +self._scripts_panel = ScriptsPanel() +self._scripts_panel.file_open_requested.connect(self._open_script_module_tab) +# (set_scripts_widget called in PR 4) +``` + +`_open_script_module_tab` is implemented in PR 6 — wire the signal even if the handler is a stub here. + +PR 5 ships once `ScriptsPanel` tests pass. Double-click is a no-op until PR 6. + +--- + +## Section 6 — Script-module tab type (PR 6) + +### 6.0 — FULL editor surface reuse (mirror the pre/post scripts pane) + +**Script-module tabs must use the same multi-pane layout, widgets, and toolbar as the existing pre/post-request scripts editor.** Not just the editor widget — the **entire chrome**. Users authoring a request script and authoring a module file must see the same surface. + +**Mandatory layout** (mirrors [scripts_mixin.py:56-249](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L56-L249)): + +``` +┌──────────────────────────────────────────────────────────┐ +│ Toolbar: [🔍 Find][↔ Replace][🎯 Go to line] │ [↶ ↷] │ [💾 Save] │ +├──────────────────────────────────────────────────────────┤ +│ Search/replace bar (toggleable — same widget as scripts) │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ CodeEditorWidget │ +│ (same class, same LSP wiring, same completion popup) │ +│ │ +├──────────────────────────────────────────────────────────┤ +│ Status bar: Ln 5, Col 12 │ Language: JavaScript │ 1.2K│ +├──────────────────────────────────────────────────────────┤ ← QSplitter handle +│ ScriptOutputPanel (same class as scripts editor) │ +│ ┌─[ Output ][ Problems ]──────────────────────────────┐ │ +│ │ Problems tab: LSP diagnostics list (clickable, │ │ +│ │ same ScriptLspProblemsTab class). │ │ +│ │ Output tab: stays empty for modules (no Run). │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +**Concrete reuse list** (all classes/widgets, no rebuilds): + +| Component | Source class | File | +|---|---|---| +| Code editor itself | `CodeEditorWidget` | [src/ui/widgets/code_editor/editor_widget.py](../../src/ui/widgets/code_editor/editor_widget.py) | +| Vertical splitter (editor top / output bottom, ~44/56 ratio) | `QSplitter(Qt.Vertical)` per [scripts_mixin.py:128-139](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L128) | — | +| Output + Problems panel | `ScriptOutputPanel` | [src/ui/request/request_editor/scripts/output_panel.py](../../src/ui/request/request_editor/scripts/output_panel.py) | +| Problems tab (LSP diagnostics) | `ScriptLspProblemsTab` | [src/ui/request/request_editor/scripts/lsp_problems_tab.py](../../src/ui/request/request_editor/scripts/lsp_problems_tab.py) | +| Search/replace bar | `SearchReplaceBar` | (same as scripts editor) | +| Find / Replace / Go-to-line buttons | toolbar built by `_build_script_header` | [scripts_mixin.py:544-709](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L544) | +| Undo / Redo buttons | same toolbar | — | +| Save button + Ctrl+S | same toolbar | — | +| Status bar (Ln/Col, language, char count) | `_build_script_status_bar` | [scripts_mixin.py:711-793](../../src/ui/request/request_editor/scripts/scripts_mixin.py#L711) | +| LSP attach | `CodeEditorWidget.set_language()` auto-wires `attach_lsp()` | — | + +**Wire `ScriptOutputPanel.bind_script_editor(editor)`** so the Problems tab receives `lsp_diagnostics_changed` signals — same as the scripts editor. This is the entire LSP-diagnostics hookup. + +**Omit from module tabs** (different from scripts editor): +- **Run button** — modules have no entry point standalone. Hide the button (don't disable — hide). Output tab stays present for chrome parity but stays empty. +- **Debug button** — same reason. Hide. +- **Run all** — same reason. Hide. +- **Mock response tab** — only meaningful for post-response scripts. Omit from the `ScriptOutputPanel` for module tabs (constructor takes a `script_type` arg; pass a new `"module"` value that skips the Mock tab). +- **RuntimeBanner / InheritedScriptsBanner** — request-context concerns, not relevant for modules. + +**The simplest implementation path**: extract the `_build_pre_request_tab` body of `_ScriptsMixin` into a free function `build_script_editor_surface(*, script_type)` (or a small `ScriptEditorSurface` widget) that takes a `script_type: Literal["pre_request","test","module"]`. Both `_ScriptsMixin` and `ScriptModuleTab` call it. The `"module"` branch hides Run/Debug/Banner/Mock; everything else stays identical. + +**Do not** copy-paste the layout code. Reuse via extraction so a future bug fix in one surface fixes both. + +### 6.1 — New file `src/ui/tabs/script_module_tab.py` + +**Illustrative-only (Amended):** The code block below sketches dirty/save/orphan behaviour with a bare `CodeEditorWidget`. The **shipped** module tab **must** embed the full surface from §6.0 via `build_script_editor_surface(..., script_type="module")` (toolbar, splitter, `ScriptOutputPanel`, Problems, LSP bind). Replace the central widget layout accordingly while keeping the same dirty/save signals contract. + +> **DO NOT COPY THIS CODE BLOCK.** It shows only dirty/save/orphan logic +> with a bare editor. The shipped `ScriptModuleEditorWidget` must instead +> call `build_script_editor_surface(*, script_type="module")` from +> Section 6.0 to get the full toolbar, Undo/Redo, Output panel, Problems +> tab, and LSP wiring. Keep the dirty/save signals contract below, but +> replace the layout with the extracted surface. + +```python +from pathlib import Path +from PySide6.QtCore import Signal +from PySide6.QtGui import QShortcut, QKeySequence +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel +from ui.widgets.code_editor.editor_widget import CodeEditorWidget + + +_EXT_TO_LANG = {".js": "javascript", ".ts": "typescript", ".py": "python"} + + +class ScriptModuleEditorWidget(QWidget): + dirty_changed = Signal(bool) + + def __init__(self, path: Path, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._path = path + self._dirty = False + self._orphan = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self._banner = QLabel("File deleted on disk — Save to restore.") + self._banner.hide() + layout.addWidget(self._banner) + + self._editor = CodeEditorWidget(read_only=False) + language = _EXT_TO_LANG.get(path.suffix, "javascript") + self._editor.set_language(language) # call existing API + if path.exists(): + self._editor.set_text(path.read_text(encoding="utf-8")) + layout.addWidget(self._editor, 1) + + self._editor.textChanged.connect(self._on_text_changed) + + QShortcut(QKeySequence.Save, self, activated=self.save) + + @property + def path(self) -> Path: + return self._path + + @property + def dirty(self) -> bool: + return self._dirty + + def _set_dirty(self, v: bool) -> None: + if self._dirty != v: + self._dirty = v + self.dirty_changed.emit(v) + + def _on_text_changed(self) -> None: + self._set_dirty(True) + + def save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(self._editor.text(), encoding="utf-8") + self._orphan = False + self._banner.hide() + self._set_dirty(False) + + def mark_orphan(self) -> None: + self._orphan = True + self._banner.show() + self._set_dirty(True) +``` + +Note: `set_language(...)` and `.text()` / `.set_text()` must exist on `CodeEditorWidget`. If their names differ, look up actual API at [src/ui/widgets/code_editor/editor_widget.py](../../src/ui/widgets/code_editor/editor_widget.py) and adjust. + +### 6.2 — Modify `src/ui/request/navigation/tab_manager.py` + +This is **high blast radius** — every site that touches `ctx.editor` must be audited. Run this audit BEFORE writing code: + +```bash +grep -rn "ctx\.editor\|\.editor\.\|TabContext" src/ui/ | sort -u +``` + +Find `TabContext.__init__` (around line 64). Add new fields. Keep `editor` typed `RequestEditorWidget | None` so existing code can early-return on `None`: + +```python +class TabContext: + def __init__( + self, + ...existing args..., + tab_type: str = "request", # NEW: "request" | "script_module" + script_module_path: Path | None = None, # NEW + script_module_editor: QWidget | None = None, # NEW + ) -> None: + self.tab_type = tab_type + self.script_module_path = script_module_path + self.script_module_editor = script_module_editor + # Existing: editor / response_viewer / breadcrumb / request_id / is_preview + if tab_type == "request": + self.editor = editor or RequestEditorWidget() + else: + self.editor = None # callers must check tab_type before touching editor + + def is_request(self) -> bool: + return self.tab_type == "request" +``` + +**Touchpoint checklist** — every one of these must early-return or branch on `is_request()`: +- `cleanup_thread()` — no-op for script-module tabs (no send pipeline). +- `start_send()` — no-op (raises if called on a non-request tab — defensive). +- `send_pipeline.py` send-button handlers — verify `ctx.is_request()` before queue ops. +- `_on_tab_changed` / `_refresh_sidebar` in `tab_controller.py` — branch already in §6.3. +- `tab_close` — save dirty script-module before closing; confirm with user if dirty. +- Session restore (lines 459-485 region) — separate persistence format (§6.5). +- Breadcrumb update — skip for script-module tabs. +- `set_request_dirty` / draft persistence — skip. +- Title-bar / window-title update — use file basename for script-module tabs. + +Run the audit grep again after edits to make sure no `ctx.editor.` path is left unguarded. + +### 6.3 — Modify `src/ui/main_window/tab_controller.py` + +Add a new method: + +```python +def _open_script_module_tab(self, path: Path) -> None: + # If a tab for this path is already open, focus it. + for tab_id, ctx in self._tabs.items(): + if ctx.tab_type == "script_module" and ctx.script_module_path == path: + self._tab_bar.setCurrentIndex(self._tab_bar.indexOf(tab_id)) + return + editor = ScriptModuleEditorWidget(path) + ctx = TabContext( + tab_type="script_module", + script_module_path=path, + script_module_editor=editor, + ) + tab_id = self._next_tab_id() # follow existing convention + self._tabs[tab_id] = ctx + self._editor_stack.addWidget(editor) + title = path.name + self._tab_bar.add_tab(tab_id, title) + editor.dirty_changed.connect( + lambda d, t=tab_id: self._tab_bar.set_dirty(t, d) + ) + self._tab_bar.setCurrentIndex(self._tab_bar.indexOf(tab_id)) +``` + +Find `_on_tab_changed` (lines 354-414 region). Branch on `tab_type`: + +```python +def _on_tab_changed(self, idx: int) -> None: + tab_id = self._tab_bar.tab_id_at(idx) + ctx = self._tabs.get(tab_id) + if ctx is None: + return + if ctx.tab_type == "script_module": + self._editor_stack.setCurrentWidget(ctx.script_module_editor) + # Hide the response area, breadcrumb, send button — they don't apply. + self._response_area.setVisible(False) + self._breadcrumb.setVisible(False) + return + # ... existing request-tab branch unchanged. +``` + +### 6.4 — Wire to scripts panel + +In `window.py`, ensure: + +```python +self._scripts_panel.file_open_requested.connect(self._open_script_module_tab) +self._scripts_panel.file_deleted.connect(self._on_local_module_deleted) +``` + +Add handler: + +```python +def _on_local_module_deleted(self, path: Path) -> None: + for ctx in self._tabs.values(): + if ctx.tab_type == "script_module" and ctx.script_module_path == path: + ctx.script_module_editor.mark_orphan() +``` + +### 6.5 — Session persistence + +Find existing tab restore code in `tab_controller.py` (around lines 459-485). Extend the persisted format to record script-module tabs by path: + +```python +{"tab_type": "script_module", "path": str(path)} +``` + +On restore, silently drop entries whose path doesn't exist. + +### 6.6 — Tests `tests/ui/test_script_module_tab.py` + +New file. Cases: +1. `test_open_creates_tab_with_editor` — call `_open_script_module_tab(tmp_path/"foo.js")` → new tab appears, `ScriptModuleEditorWidget` in stack. +2. `test_dirty_indicator_on_edit` — type into editor → tab bar `dirty` flag set True. +3. `test_save_writes_to_disk_and_clears_dirty` — modify, Ctrl+S → disk content matches editor, dirty False. +4. `test_open_twice_focuses_existing_tab` — call open twice with same path → only one tab; index is on it. +5. `test_response_area_hidden_for_script_module_tab` — switch to a script-module tab → response area hidden; switch back to request tab → visible again. +6. `test_external_delete_marks_orphan` — emit `file_deleted` signal → banner shown, dirty True. + +### 6.7 — Settings UI + +**Important**: the existing Settings dialog uses a single monolithic `_do_apply()` method (line 1527) — there is **no** `_apply_callbacks` list. Integration must happen inside the dialog class itself, not via callback registration. + +**New file** `src/ui/dialogs/settings_local_modules.py`: + +```python +"""Builders for the 'Local modules' Settings subpage. + +Returns a built QWidget plus the line-edit so the dialog can wire dirty +tracking and read it from inside ``SettingsDialog._do_apply``. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from PySide6.QtCore import QUrl +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, + QLabel, QFileDialog, +) +from services.scripting.runtime_settings import RuntimeSettings + + +@dataclass +class LocalModulesPage: + widget: QWidget + path_edit: QLineEdit + + +def build_local_modules_page(parent_dialog) -> LocalModulesPage: + page = QWidget() + layout = QVBoxLayout(page) + + layout.addWidget(QLabel("Local script modules")) + + help_label = QLabel( + "Files in this folder can be imported from any request script with " + "pm.require(\"local:<name>\"). Supported " + "extensions: .js, .ts, .py. " + "Subfolders are ignored." + ) + help_label.setWordWrap(True) + layout.addWidget(help_label) + + row = QHBoxLayout() + path_edit = QLineEdit(str(RuntimeSettings.local_modules_dir())) + browse_btn = QPushButton("Browse…") + open_btn = QPushButton("Open folder") + row.addWidget(path_edit, 1) + row.addWidget(browse_btn) + row.addWidget(open_btn) + layout.addLayout(row) + layout.addStretch() + + def on_browse() -> None: + d = QFileDialog.getExistingDirectory( + page, "Local modules folder", path_edit.text() + ) + if d: + path_edit.setText(d) + + def on_open() -> None: + QDesktopServices.openUrl(QUrl.fromLocalFile(path_edit.text())) + + browse_btn.clicked.connect(on_browse) + open_btn.clicked.connect(on_open) + # Dialog wires dirty tracking + apply itself (see settings_dialog.py changes). + path_edit.textChanged.connect(parent_dialog._mark_dirty) + return LocalModulesPage(widget=page, path_edit=path_edit) +``` + +Modify [src/ui/dialogs/settings_dialog.py](../../src/ui/dialogs/settings_dialog.py): +1. **Add a child node** under the existing "Scripting" parent in the category tree (look for the section that builds the tree near line 543; pattern mirrors `_build_private_packages_pages` at line 555). +2. **In `__init__`** (or wherever existing Scripting page builders are called, near line 149): call `self._local_modules_page = build_local_modules_page(self)`, then register `self._local_modules_page.widget` in the `QStackedWidget` under key `"local_modules"` (follow the existing private-packages-pages registration pattern verbatim). +3. **In `_do_apply`** (line 1527): add a line near the other persistence calls: + ```python + RuntimeSettings.set_local_modules_dir(Path(self._local_modules_page.path_edit.text())) + ``` +4. **Emit a signal or call back into the main window** so `ScriptsPanel._refresh_root()` runs when the path changes (add a `local_modules_dir_changed` signal on `SettingsDialog`, emit from `_do_apply` when the value differs from the original; connect in the dialog opener inside `MainWindow`). + +Tests `tests/ui/dialogs/test_settings_local_modules.py`: +1. `test_default_path_shown_when_unset` — `local_modules_dir` unset → text field shows default per-OS path. +2. `test_browse_sets_path_via_dialog` — mock `getExistingDirectory` → text field updated, dirty flag set. +3. `test_apply_persists_path` — set text, simulate Apply → `RuntimeSettings.local_modules_dir()` returns new value. + +PR 6 ships once these tests pass and end-to-end manual smoke (below) works. + +--- + +## Section 7 — Docs + +Modify [docs/scripting/external-packages.md](../scripting/external-packages.md). Add a top section titled "Local script modules" with: +- Brief: what they are, where to put files (default `/postmark/scripts/`), configurable via Settings → Scripting → Local script modules. +- Specifier shape table: + - JS: `pm.require("local:auth.js")` / `pm.require("local:types.ts")` + - Python: `pm.require("local:utils.py")` + - **Extension is mandatory.** +- Walkthrough: open Scripts pane via top-of-left-pane icon (or `Ctrl+2`) → click "New module" → file opens in tab → save → call from a request script. +- Composition example: `local:auth.js` imports `npm:jose@5.2.0` (works via union scan). +- Cross-language note: not supported (`pm.require("local:helper.py")` from a JS script errors). +- Pyodide-only note for Python local modules (RestrictedPython subprocess rejects them with a clear error). +- One-line link to "Private packages" section that follows. + +Modify [src/AGENTS.md](../../src/AGENTS.md): under scripting, add a bullet pointing to `LocalModuleResolver` and the `local:` specifier syntax (extension-mandatory). + +Modify [AGENTS.md](../../AGENTS.md) directory map: list new `src/ui/sidebar/left_pane.py`, `src/ui/sidebar/scripts_panel/`, `src/ui/tabs/script_module_tab.py`, `src/ui/dialogs/settings_local_modules.py`, `src/services/scripting/local_modules.py`, `data/scripts/pm_local_loader.py`. + +--- + +## Order of work + +Each PR must keep `poetry run pytest tests/` green. + +| PR | Scope | Depends on | User-visible? | +|---|---|---|---| +| 1 | Resolver + settings row (Local script modules path) | — | New section in Settings → Scripting (unused yet) | +| 2 | JS runtime `local:foo.js`/`.ts` support | PR 1 | Devs can drop a file into the folder + call `pm.require("local:foo.js")` from a request script | +| 3 | Python runtime `local:foo.py` (Pyodide) + RestrictedPython error path | PR 1 | Same as PR 2 but for Python | +| 4 | `LeftSidebarPane` shell — toggle row at top, stacked content, Collections page wired (Scripts page = empty stub) | — | **YES — toggle row appears at top of left pane; Collections still default.** | +| 5 | `ScriptsPanel` (toolbar + list + context menu) — wires into stacked page 1 | PR 1, PR 4 | Scripts icon now switches to a working file panel; double-click stub | +| 6 | `ScriptModuleTab` + Settings dialog wiring + `local_modules_dir_changed` signal | PR 2, PR 3, PR 5 | End-to-end feature live | + +Backend (PRs 1-3) and UI (PRs 4-6) can stack in parallel reviews. **No feature flag for PR 4** — the toggle row is a small, recoverable change. If something goes wrong, revert that PR; don't ship an env-var hack. + +--- + +## Pre-implementation verification + +Before starting PR 1, confirm the following one-time facts (they should be true based on exploration but worth a 30-second check): +- `src/services/scripting/runtime_settings.py` exports a `_get_settings()` helper at module scope. **Use it; never call `QSettings()` directly.** UI code uses `QSettings(_ORG, _APP)` (org/app constants from [src/ui/styling/theme_manager.py](../../src/ui/styling/theme_manager.py) lines 20-22); the `LeftSidebarPane._qsettings()` helper in §4.1 follows this. Bare `QSettings()` calls go to a different namespace and break test isolation. +- `src/services/scripting/deno_manager.py` `runtime_dir()` builds a per-OS data dir. Plan to refactor into a shared `_postmark_user_data_dir()` (Section 1.2) so this lives in one place. +- `src/ui/styling/global_qss.py` builds QSS via Python f-strings against a palette dict `p`. **Qt does not support CSS `var(--…)`.** Use `{p["accent"]}` interpolation. +- `src/ui/dialogs/settings_dialog.py` `_do_apply` (line 1527) is monolithic — there is **no** callback registration list. Plan integrates by adding a line directly inside `_do_apply` (Section 6.7). +- `CodeEditorWidget` API names — check actual signatures at [src/ui/widgets/code_editor/editor_widget.py:122-180](../../src/ui/widgets/code_editor/editor_widget.py#L122) for `setPlainText` vs `set_text`, `toPlainText` vs `text`, `set_language(...)` vs another spelling. Adjust §6.1 method calls to match. +- Scripting directory file-count limit (per [src/AGENTS.md](../../src/AGENTS.md)): if `src/services/scripting/` is near the convention cap, plan to split `local_modules.py` into a subpackage. Check before PR 1. + +## Risks / sharp edges + +- **Traversal/symlink escape** — resolver uses `resolve(strict=True)` + `relative_to(root)` where root is already canonical. Test with `../escape.js`, `link.js → /etc/passwd`. +- **No silent-ambiguity class** — extension-mandatory specifier means `foo.js` and `foo.ts` can coexist; each has its own specifier. No "ambiguous stem" error possible from the call site. +- **Cycles** — A requires B, B requires A → resolver raises with chain message. +- **Per-execution scan** — bounded by `MAX_LOCAL_MODULES = 500`. Glob is cheap; source read lazily for reached modules. +- **mtime races** — snapshot source at run start; in-flight execs use their snapshot. +- **Read-only root** — Scripts panel detects via `os.access(root, os.W_OK)`; disables mutation buttons + context-menu entries. +- **Tab open when file deleted** — emit `file_deleted` signal; tab marks orphan with banner. Save creates the file again. +- **Cross-language pm.require** — JS user requiring `local:foo.py` not detected by JS regex (silently no-op at scan; bundle then errors at runtime in `pm_bootstrap.js`). Python user requiring `local:helper.js` not detected by Python regex. +- **Pyodide entry script location** — `data/scripts/pyodide_run.mjs` (verified). Section 3.5 spec. +- **CodeEditorWidget API names** — `set_text`/`set_language`/`text()` may differ; check actual names at [src/ui/widgets/code_editor/editor_widget.py](../../src/ui/widgets/code_editor/editor_widget.py) and align in §6.1 before writing code. +- **`sys.modules` shadowing** — loader uses only `__pm_local_`. A file called `json.py` does NOT replace stdlib `json`. `pm.require("local:json.py")` works via the namespaced lookup; `import json` in user code still resolves to stdlib. +- **LSP on module-tab editors** — out of scope for MVP. Note in docs. +- **Toggle row icons** — confirm `phi("tree-structure")` and `phi("code")` exist in `data/fonts/phosphor-charmap.json` before PR 4 (or pick alternates like `phi("list")`, `phi("file-code")`). +- **Settings path change re-rooting** — `local_modules_dir_changed` signal: + 1. Compute `new_root = RuntimeSettings.local_modules_dir().resolve()`. + 2. For each open script-module tab, compute `tab_path.resolve()`. + 3. If `tab_path` is not under `new_root`, call `mark_orphan()` (same banner as external delete). + 4. `ScriptsPanel.refresh_root()` reloads the list. +- **`ctx.editor` audit** — grep `ctx\.editor\|\.editor\.\|TabContext` is a starting list. Run full test suite after; fix any `AttributeError: 'NoneType' object has no attribute 'editor'` traces. +- **No `QDockWidget` for Scripts** — explicit anti-pattern (see Context). A prior implementation made this mistake. Reviewer should reject any PR that uses `QDockWidget` for the Scripts panel. +- **No vertical activity rail** — explicit anti-pattern. Toggle row is horizontal, inside the left pane. + +--- + +## Verification + +After each PR, run the listed unit/UI tests for that PR plus a full sweep: + +``` +poetry run pytest tests/unit/services/test_local_modules_resolver.py -q # PR 1 +poetry run pytest tests/unit/services/test_runtime_settings.py -q # PR 1 +poetry run pytest tests/unit/services/test_pm_require_local_js.py -q # PR 2 +poetry run pytest tests/unit/services/test_pm_require_local_py.py -q # PR 3 +poetry run pytest tests/unit/services/test_pm_python_parity.py -q # PR 3 +poetry run pytest tests/ui/sidebar/test_left_pane.py -q # PR 4 +poetry run pytest tests/ui/test_main_window.py -q # PR 4 +poetry run pytest tests/ui/sidebar/test_scripts_panel.py -q # PR 5 +poetry run pytest tests/ui/test_script_module_tab.py -q # PR 6 +poetry run pytest tests/ui/dialogs/test_settings_local_modules.py -q # PR 6 +poetry run pytest tests/ -q # always +``` + +End-to-end manual smoke (after PR 6): + +1. Launch app. **Toggle row visible at top of left pane**, Collections icon active by default. +2. Settings → Scripting → Local script modules. Path defaults to per-OS default. Click "Open folder" → OS file manager pops to a freshly-created `/postmark/scripts/` folder. +3. Press `Ctrl+2` (or click Scripts icon). Stacked content swaps to ScriptsPanel; empty state visible. +4. Click "New module" → modal asks for name → `mathx`. `mathx.js` appears in list and opens in a new editor tab. +5. Type `export function add(a,b){ return a+b; }`. Press `Ctrl+S`. Dirty dot disappears. +6. Right-click `mathx.js` → Copy import specifier. Clipboard now has `local:mathx.js` (with extension). +7. Paste into a request's Tests tab: `const m = pm.require("local:mathx.js"); pm.test("adds", () => pm.expect(m.add(1,2)).to.eql(3));`. Send. Test passes. +8. Python: create `helpers.py` via context menu → `def shout(s): return s.upper()`. Python pre-request script: `h = pm.require("local:helpers.py"); pm.environment.set("greet", h.shout("hi"))`. Send. Env var set to "HI". +9. Composition: create `multiplier.js` with `export const mul = (a,b) => a*b;`. Edit `mathx.js` to also export `mul = pm.require("local:multiplier.js").mul;`. Original test still passes; add a test calling `m.mul(2,3)`. +10. Extension mismatch: in a request script, `pm.require("local:mathx.ts")` → error mentions extension mismatch (only `.js` on disk). +11. Path traversal: try to create `../escape.js` via OS file manager outside the modules dir — file appears outside but resolver doesn't list it; `pm.require("local:escape.js")` → "not found" error. +12. Delete `mathx.js` from panel context menu while tab is open → tab shows orphan banner. `Ctrl+S` → file recreated. +13. Right sidebar (variables / snippets / saved responses) still toggles. No regression. +14. Restart app → Toggle row remembers active panel; last-open script-module tabs restored. +15. **No `QDockWidget` exists anywhere in the running UI.** Inspect via `QApplication.allWidgets()` if needed. + +--- + +## Critical files + +- [src/services/scripting/js_runtime.py](../../src/services/scripting/js_runtime.py) — JS specifier scanner, imports block +- [src/services/scripting/py_runtime.py](../../src/services/scripting/py_runtime.py) — Python specifier scanner +- [src/services/scripting/pyodide_runtime.py](../../src/services/scripting/pyodide_runtime.py) — IPC payload +- [src/services/scripting/deno_runtime.py](../../src/services/scripting/deno_runtime.py) — bundle + workdir file writes +- [src/services/scripting/runtime_settings.py](../../src/services/scripting/runtime_settings.py) — new `local_modules_dir` key +- `src/services/scripting/local_modules.py` — NEW resolver (link when file lands on disk) +- [data/scripts/pm_bootstrap.py](../../data/scripts/pm_bootstrap.py) — Python `pm.require` `local:` branch +- [data/scripts/pm_bootstrap.js](../../data/scripts/pm_bootstrap.js) — JS hint for `local:` errors +- `data/scripts/pm_local_loader.py` — NEW Pyodide-side registrar (link when file lands on disk) +- [src/ui/collections/collection_widget.py](../../src/ui/collections/collection_widget.py) — unchanged content; reparented under `LeftSidebarPane` +- [src/ui/collections/collection_header.py](../../src/ui/collections/collection_header.py) — unchanged; sits beneath toggle row +- `src/ui/sidebar/left_pane.py` — NEW (`LeftSidebarPane` — toggle row + stacked content; link when file lands on disk) +- [src/ui/sidebar/scripts_panel/](../../src/ui/sidebar/scripts_panel/) — NEW package (`ScriptsPanel` + `actions.py`) +- `src/ui/tabs/script_module_tab.py` — NEW (link when file lands on disk) +- [src/ui/main_window/window.py](../../src/ui/main_window/window.py) — splitter wiring (replace `collection_widget` with `LeftSidebarPane`) + Ctrl+1/+2 +- [src/ui/main_window/tab_controller.py](../../src/ui/main_window/tab_controller.py) — script-module tab type +- [src/ui/request/navigation/tab_manager.py](../../src/ui/request/navigation/tab_manager.py) — `TabContext.tab_type` / `script_module_panel` / `script_module_path` +- [src/ui/dialogs/settings_dialog.py](../../src/ui/dialogs/settings_dialog.py) — new "Local script modules" row + `local_modules_dir_changed` signal +- `src/ui/dialogs/settings_local_modules.py` — NEW (row builder; link when file lands on disk) +- [src/ui/styling/global_qss.py](../../src/ui/styling/global_qss.py) — `#leftPaneToggleRow` + `#leftPaneToggleButton` rules + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1070783..1896038 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,423 +1 @@ -# Postmark — Copilot Instructions - -## CRITICAL — Keeping instructions in sync - -> **MANDATORY — EVERY code change MUST be followed by an instruction audit.** -> After modifying, adding, or deleting ANY source file, test file, signal, -> TypedDict, service method, QSS objectName, or architectural pattern, you -> MUST review ALL instruction files listed below and update them to reflect -> the change. **Stale or incomplete instructions are treated as bugs.** -> -> Checklist — run through each step after every code change: -> -> 1. **Update the architecture tree** in this file to match `src/` and -> `tests/`. Add new files, remove deleted files. -> 2. **Update `architecture.instructions.md`** with any new or changed -> signals, data flows, TypedDicts, implicit contracts, or service methods. -> 3. **Update `pyside6.instructions.md`** with any new `objectName` values -> used in global QSS. -> 4. **Update `testing.instructions.md`** with any new test files or -> directories. -> 5. **Update `sqlalchemy.instructions.md`** with any new models, -> relationships, or repository functions. -> 6. **Update relevant skills** (under `.github/skills/`) when adding or -> changing signals, service/repository methods, TypedDicts, widgets, or -> parsers. See the Skills table below. -> 7. **Search every instruction file and skill** for stale references to -> renamed, moved, or deleted code. Remove or correct them. -> 8. **Update `docs/` pages** when adding, changing, or removing public API, -> signals, TypedDicts, widgets, parsers, or architectural patterns. -> See `docs/contributing/updating-docs.md` for the full checklist. - -This file and the scoped instruction files below form a single source of -truth. - -- **Check all instruction files for overlap** before editing any of them. -- **Never duplicate rules** across files — reference the canonical location. -- **Place rules in the most specific file** that applies. Only add rules here - if they are truly project-wide. -- **Prefer creating new scoped instruction files** (under - `.github/instructions/` with an `applyTo` glob) over adding to this file. - -Scoped instruction files (auto-applied by path): - -| File | Applies to | -|------|------------| -| [pyside6.instructions.md](./instructions/pyside6.instructions.md) | `src/ui/**/*.py` | -| [sqlalchemy.instructions.md](./instructions/sqlalchemy.instructions.md) | `src/database/**/*.py` | -| [architecture.instructions.md](./instructions/architecture.instructions.md) | `src/**/*.py` | -| [testing.instructions.md](./instructions/testing.instructions.md) | `tests/**/*.py` | -| [documentation.instructions.md](./instructions/documentation.instructions.md) | `docs/**/*.md` | - -On-demand skills (loaded when the task matches the description): - -| Skill | Description | -|-------|-------------| -| [signal-flow](./skills/signal-flow/SKILL.md) | Complete signal flow diagrams, signal declaration tables, MainWindow wiring summary | -| [service-repository-reference](./skills/service-repository-reference/SKILL.md) | Repository function catalogues, service method tables, TypedDict schemas | -| [widget-patterns](./skills/widget-patterns/SKILL.md) | Tree badge rendering, data roles, InfoPopup, VariablePopup, theme module, new widget checklist | -| [test-writing](./skills/test-writing/SKILL.md) | Test patterns for all layers — repository, service, UI widget, MainWindow | -| [import-parser](./skills/import-parser/SKILL.md) | How to add a new import format parser to the import system | -| [customization-guide](./skills/customization-guide/SKILL.md) | How to create, update, or debug Copilot instruction files, skills, applyTo patterns, and YAML frontmatter | - -> **Instructions vs Skills:** Instructions are always loaded when editing -> matching files — keep them lean with core rules. Skills are loaded -> on-demand when the task description matches — use them for heavyweight -> reference material, step-by-step guides, and catalogues. - -### Quick-reference — creating new skills or instructions - -If you need to **add a new skill** or **instruction file**, follow these -minimal rules (full guide in the `customization-guide` skill): - -**Skill** — `.github/skills//SKILL.md`: -```yaml ---- -name: "" # kebab-case, matches folder name -description: "One sentence ..." # VS Code matches this to user prompts ---- -# -(content) -``` - -**Instruction** — `.github/instructions/<name>.instructions.md`: -```yaml ---- -name: "<Display Name>" -description: "One sentence ..." -applyTo: "src/path/**/*.py" # glob — auto-loaded for matching files ---- -# <Title> -(content) -``` - -After creating either, **update this file**: add the new entry to the -scoped-instructions or skills table above, and update the sync checklist -if needed. - -## Project overview - -**Postmark** — native desktop API client built with **PySide6**, **SQLAlchemy 2.0**, **Python 3.12+**, managed by **Poetry**. - -```bash -poetry install --with dev # pytest, ruff, mypy -poetry run python src/main.py -poetry run ruff check src/ && poetry run ruff format src/ -poetry run mypy src/ -poetry run pytest -``` - -`src/` is the source root for all tools (`pythonpath`, `mypy_path`, -`extraPaths` in `pyproject.toml`). Imports use bare module names: -`from database.database import init_db`. - -## LLM Navigation Quick-Start - -Fastest paths to understand and navigate the codebase: - -- **All services at a glance:** Read `src/services/__init__.py` — re-exports - `CollectionService`, `EnvironmentService`, `ImportService`, and key - TypedDicts (`RequestLoadDict`, `VariableDetail`, `LocalOverride`). -- **HTTP subsystem:** Read `src/services/http/__init__.py` — re-exports - `HttpService`, `GraphQLSchemaService`, `SnippetGenerator`, - `SnippetOptions`, `HttpResponseDict`, `parse_header_dict`. - Auth header injection lives in `src/services/http/auth_handler.py`. - OAuth 2.0 token exchange lives in `src/services/http/oauth2_service.py`. -- **All DB models:** Read `src/database/database.py` — re-exports all four - ORM models (`CollectionModel`, `RequestModel`, `SavedResponseModel`, - `EnvironmentModel`). -- **Collection CRUD vs queries:** Mutations live in - `collection_repository.py`; read-only tree/breadcrumb/ancestor queries - live in `collection_query_repository.py`. -- **Signal flow:** Load the `signal-flow` skill for complete wiring diagrams. -- **TypedDicts:** Cross-module dict schemas live in the service that owns - them (e.g. `RequestLoadDict` in `collection_service.py`, - `HttpResponseDict` in `http_service.py`). -- **Test fixtures:** `make_collection_with_request` (root `conftest.py`) and - `make_request_dict` (`tests/ui/request/conftest.py`) reduce setup - boilerplate. - -## Architecture - -``` -docs/ # Project documentation (see docs/README.md) -├── README.md # Landing page + full table of contents -├── getting-started/ # Installation, running, overview -├── architecture/ # Layered design, data flow, directory tree -├── api-reference/ # Function signatures, TypedDicts, signals -│ ├── database/ # ORM models, repository functions -│ └── services/ # Service methods, HTTP, auth, parsers -├── ui-reference/ # Widget classes, styling, navigation -├── guides/ # How-to guides (import parser, auth, widget, tests, signals) -└── contributing/ # Coding conventions, testing, updating docs -src/ -├── main.py # Entry point — QApplication + init_db() -├── database/ # Engine, models, repository -│ ├── database.py # init_db(), get_session(), migration -│ └── models/ -│ ├── base.py # DeclarativeBase -│ ├── collections/ -│ │ ├── collection_repository.py # CRUD for collections + requests -│ │ ├── collection_query_repository.py # Read-only tree/breadcrumb/ancestor queries -│ │ ├── import_repository.py # Atomic bulk-import of parsed data -│ │ └── model/ -│ │ ├── collection_model.py # CollectionModel (folders) -│ │ ├── request_model.py # RequestModel (HTTP requests) -│ │ └── saved_response_model.py -│ └── environments/ -│ ├── environment_repository.py # CRUD for environments -│ └── model/ -│ └── environment_model.py # EnvironmentModel (key-value sets) -├── services/ # Service layer (UI ↔ DB bridge) -│ ├── collection_service.py # CollectionService (static methods) -│ ├── environment_service.py # EnvironmentService (variable substitution + TypedDicts) -│ ├── import_service.py # ImportService (parse + persist) -│ ├── http/ # HTTP request/response handling -│ │ ├── http_service.py # HttpService (httpx) + response TypedDicts -│ │ ├── graphql_schema_service.py # GraphQL introspection + schema parsing -│ │ ├── auth_handler.py # Shared auth header injection (all 12 auth types) -│ │ ├── oauth2_service.py # OAuth 2.0 token exchange (4 grant types) -│ │ ├── snippet_generator/ # Code snippet generation sub-package (23 languages) -│ │ │ ├── generator.py # SnippetGenerator, SnippetOptions, LanguageEntry, registry -│ │ │ ├── shell_snippets.py # cURL, HTTP raw, wget, HTTPie, PowerShell -│ │ │ ├── dynamic_snippets.py # Python, JS, Node, Ruby, PHP, Dart -│ │ │ └── compiled_snippets.py # Go, Rust, C, Swift, Java, Kotlin, C# -│ │ └── header_utils.py # Shared header parsing utility -│ └── import_parser/ # Parser sub-package -│ ├── models.py # TypedDict schemas for parsed data -│ ├── postman_parser.py # Postman collection/environment parser -│ ├── curl_parser.py # cURL command parser -│ └── url_parser.py # URL/raw-text auto-detect parser -└── ui/ # PySide6 widgets - ├── main_window/ # Top-level MainWindow sub-package - │ ├── window.py # MainWindow widget + signal wiring - │ ├── send_pipeline.py # _SendPipelineMixin — HTTP send/response flow - │ ├── draft_controller.py # _DraftControllerMixin — draft tab open/save - │ ├── tab_controller.py # _TabControllerMixin — tab open/close/switch - │ └── variable_controller.py # _VariableControllerMixin — env variable + sidebar management - ├── loading_screen.py # Loading screen overlay widget - ├── sidebar/ # Right sidebar sub-package - │ ├── sidebar_widget.py # RightSidebar (icon rail) + _FlyoutPanel - │ ├── variables_panel.py # VariablesPanel — read-only variable display - │ ├── snippet_panel.py # SnippetPanel — inline code snippet generator - │ └── saved_responses/ # Saved responses sub-package - │ ├── panel.py # SavedResponsesPanel — saved example list/detail flyout - │ ├── search_filter.py # _PanelSearchFilterMixin — body search/filter - │ ├── helpers.py # Formatting helpers (body size, language detect, etc.) - │ └── delegate.py # Custom delegate for saved response list items - ├── styling/ # Visual theming and icons - │ ├── theme.py # Palettes, colours, badge geometry, method_color(), status_color() - │ ├── theme_manager.py # ThemeManager — QPalette + QSettings - │ ├── tab_settings_manager.py # TabSettingsManager — request-tab QSettings bridge (preview, limits, activate-on-close, wrap mode) - │ ├── global_qss.py # build_global_qss() — global stylesheet builder - │ └── icons.py # Phosphor font-glyph icon provider (phi()) - ├── widgets/ # Reusable shared components - │ ├── code_editor/ # CodeEditorWidget sub-package - │ │ ├── editor_widget.py # CodeEditorWidget — main editor class - │ │ ├── highlighter.py # Syntax highlighting engine - │ │ ├── folding.py # Code folding logic - │ │ ├── gutter.py # Line-number gutter - │ │ └── painting.py # Custom painting helpers - │ ├── info_popup.py # InfoPopup (QFrame) base + ClickableLabel - │ ├── key_value_table.py # Reusable key-value editor widget - │ ├── variable_line_edit.py # VariableLineEdit — QLineEdit with {{var}} highlighting + hover popup - │ └── variable_popup.py # VariablePopup — singleton hover popup for variable details - ├── collections/ # Collection sidebar - │ ├── collection_header.py - │ ├── collection_widget.py - │ ├── new_item_popup.py # NewItemPopup — Postman-style icon grid popup - │ └── tree/ # Tree widget sub-package - │ ├── constants.py - │ ├── draggable_tree_widget.py - │ ├── collection_tree.py # CollectionTree widget - │ ├── tree_actions.py # _TreeActionsMixin — context menus, rename, delete - │ └── collection_tree_delegate.py # Custom delegate for method badges - ├── dialogs/ # Modal dialogs - │ ├── collection_runner.py - │ ├── import_dialog.py - │ ├── save_request_dialog.py # Save draft request to collection - │ └── settings_dialog.py # Settings (theme + request-tab behaviour) - ├── environments/ # Environment management widgets - │ ├── environment_editor.py - │ └── environment_selector.py - ├── panels/ # Bottom / side panels - │ ├── console_panel.py - │ └── history_panel.py - └── request/ # Request/response editing - ├── folder_editor.py # Folder/collection detail editor - ├── http_worker.py # HttpSendWorker + SchemaFetchWorker (QThread) - ├── auth/ # Shared auth sub-package (14 auth types) - │ ├── auth_field_specs.py # Per-type FieldSpec definitions (AUTH_FIELD_SPECS) - │ ├── auth_mixin.py # _AuthMixin — shared by both editors - │ ├── auth_pages.py # FieldSpec dataclass, page builders, auth constants - │ ├── auth_serializer.py # Generic load/save for all auth types - │ └── oauth2_page.py # OAuth 2.0 custom page (grant-type switching) - ├── request_editor/ # RequestEditor sub-package - │ ├── editor_widget.py # RequestEditor — main request editing widget - │ ├── auth.py # Re-export of _AuthMixin from auth sub-package - │ ├── body_search.py # _BodySearchMixin — search/replace in body - │ └── graphql.py # _GraphQLMixin — GraphQL mode + schema - ├── response_viewer/ # ResponseViewer sub-package - │ ├── viewer_widget.py # ResponseViewer — response display widget - │ └── search_filter.py # _SearchFilterMixin — response search/filter - ├── navigation/ # Tab switching and path navigation - │ ├── breadcrumb_bar.py - │ ├── request_tab_bar.py # Compatibility wrapper re-exporting the wrapped deck - │ ├── request_tabs/ # Wrapped multi-row request tab deck sub-package - │ │ ├── __init__.py - │ │ ├── bar.py # RequestTabBar custom wrapped-row deck - │ │ ├── labels.py # TabLabel / FolderTabLabel chip content widgets - │ │ └── tab_button.py # TabButton chip with close + reorder interactions - │ └── tab_manager.py # TabManager + TabContext (with local_overrides, draft_name) - └── popups/ # Response metadata popups - ├── status_popup.py # HTTP status code explanation - ├── timing_popup.py # Request timing breakdown - ├── size_popup.py # Response/request size breakdown - └── network_popup.py # Network/TLS connection details -tests/ -├── conftest.py # Autouse fresh-DB fixture + qapp fixture + tab-settings reset -├── unit/ # Repository & service layer tests -│ ├── database/ # Repository tests -│ │ ├── test_repository.py -│ │ └── test_environment_repository.py -│ └── services/ # Service layer tests -│ ├── test_service.py -│ ├── test_environment_service.py -│ ├── test_import_parser.py -│ ├── test_import_service.py -│ └── http/ # HTTP service tests -│ ├── test_http_service.py -│ ├── test_graphql_schema_service.py -│ ├── test_snippet_generator.py -│ ├── test_snippet_shell.py -│ ├── test_snippet_dynamic.py -│ ├── test_snippet_compiled.py -│ ├── test_auth_handler.py -│ └── test_oauth2_service.py -└── ui/ # End-to-end PySide6 widget tests - ├── conftest.py # _no_fetch (autouse) + helpers - ├── test_main_window.py - ├── test_main_window_tabs_navigation.py # Wrapped tab deck shortcuts + search tests - ├── test_main_window_save.py # SaveButton + RequestSaveEndToEnd tests - ├── test_main_window_draft.py # Draft tab open/save lifecycle tests - ├── test_main_window_session.py # Tab session persistence (save/restore) tests - ├── styling/ # Theme and icon tests - │ ├── test_theme_manager.py - │ └── test_icons.py - ├── sidebar/ # Sidebar widget tests - │ ├── test_sidebar.py - │ ├── test_variables_panel.py - │ ├── test_snippet_panel.py - │ └── test_saved_responses_panel.py - ├── widgets/ # Shared component tests - │ ├── test_code_editor.py - │ ├── test_code_editor_folding.py - │ ├── test_code_editor_painting.py - │ ├── test_code_editor_memory.py - │ ├── test_info_popup.py - │ ├── test_key_value_table.py - │ ├── test_variable_line_edit.py - │ ├── test_variable_popup.py - │ └── test_variable_popup_local.py - ├── collections/ # Collection sidebar tests - │ ├── test_collection_header.py - │ ├── test_collection_tree.py - │ ├── test_collection_tree_actions.py - │ ├── test_collection_tree_delegate.py - │ ├── test_collection_widget.py - │ └── test_new_item_popup.py - ├── dialogs/ # Dialog tests - │ ├── test_import_dialog.py - │ ├── test_save_request_dialog.py - │ └── test_settings_dialog.py - ├── environments/ # Environment widget tests - │ ├── test_environment_editor.py - │ └── test_environment_selector.py - ├── panels/ # Panel tests - │ ├── test_console_panel.py - │ └── test_history_panel.py - └── request/ # Request/response editing tests - ├── conftest.py # make_request_dict fixture factory - ├── test_folder_editor.py - ├── test_http_worker.py - ├── test_request_editor.py - ├── test_request_editor_auth.py - ├── test_request_editor_binary.py - ├── test_request_editor_graphql.py - ├── test_request_editor_search.py - ├── test_response_viewer.py - ├── test_response_viewer_search.py - ├── navigation/ # Tab and breadcrumb tests - │ ├── test_breadcrumb_bar.py - │ ├── test_request_tab_bar.py - │ └── test_tab_manager.py - └── popups/ # Response popup tests - ├── test_status_popup.py - ├── test_timing_popup.py - ├── test_size_popup.py - └── test_network_popup.py -``` - -**Layering:** UI → signals → Service → Repository → `get_session()`. -UI must never import from `database/`. - -## CRITICAL — Verify after every change - -After **any** code change, run the **full** validation suite and confirm -**zero failures** before considering the task complete: - -```bash -poetry run pytest # all tests must pass -poetry run ruff check src/ tests/ # linter clean -poetry run ruff format --check src/ tests/ # formatter clean -poetry run mypy src/ tests/ # type checker clean -``` - -> **ZERO tolerance for errors — including pre-existing ones.** -> Every command above must exit with **zero** errors, warnings, or -> suggestions. If you find a pre-existing error (lint, type, format, -> test failure) while working on an unrelated task, **fix it immediately** -> in the same change. "It was already broken" is never an acceptable -> excuse — fix it anyway. All four commands passing clean is a hard gate -> on every change. No exceptions. - -**NEVER use `--fix` or auto-format as a substitute for the checks above.** -Always run the check-only commands first. If they fail, fix the code -manually (or with `--fix`), then **re-run the check-only commands** and -confirm they pass. The goal is to surface every issue visibly — a silent -auto-fix that is never re-verified can leave the working tree clean while -the staged/committed version is still broken. - -After **any** documentation change (`.md` files, instruction files, README), -run the markdown link checker and confirm **zero broken links**: - -```bash -python scripts/check_md_links.py -``` - -Never skip a layer — repository, service, UI, and MainWindow tests all -must stay green. See `testing.instructions.md` for detailed conventions. - -## Coding conventions - -- `from __future__ import annotations` in **every** module. -- `X | None`, not `Optional[X]`. -- Ruff is the linter **and** formatter (config in `pyproject.toml`). - First-party packages for isort: `database`, `ui`, `services`. -- Named constants over magic numbers. -- `init_db()` must be called before any DB access (app startup and test fixture). -- Every module, class, and public function must have a docstring. -- All hex colour values belong in `src/ui/styling/theme.py` -- never inline. -- Use `TypedDict` for dict schemas that cross module boundaries. -- No emoji in code comments -- use plain numbered steps (e.g. `# 1.`). -- **Directory file limit:** No directory may contain more than 5 `.py` files - (excluding `__init__.py`). When a directory reaches this limit, group - related files into a sub-package before adding more. Test directories - mirror the source tree; test file count may exceed 5 when multiple test - files cover a single source module. -- **File line limit:** No single `.py` file may exceed **600 lines** - (including docstrings and comments). When a file approaches this limit, - extract cohesive groups of methods, helper classes, or setup logic into - a sub-package. Re-export public symbols from the package's `__init__.py` - so external imports remain stable. Test files follow the same limit — - split by test class into separate files mirroring the sub-package. +See [AGENTS.md](../AGENTS.md). diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md deleted file mode 100644 index d092c1e..0000000 --- a/.github/instructions/architecture.instructions.md +++ /dev/null @@ -1,345 +0,0 @@ ---- -name: "Architecture & Data Flow" -description: "Signal wiring, data schemas, implicit contracts, and known limitations" -applyTo: "src/**/*.py" ---- - -# Architecture and data flow - -This file documents how data moves between layers, how signals are wired, -and what implicit contracts exist. - -## Quick rules — read these first - -1. **UI must never import from `database/`.** Go through the service layer. -2. **Call `init_db()` before creating `MainWindow`** — the constructor - immediately starts a background DB query. -3. **Create `ThemeManager(app)` before creating `MainWindow`** — it applies - the global stylesheet, QPalette, and widget style on construction. - Import from `ui.styling.theme_manager`. -4. **Every repository function is its own transaction.** You cannot batch - multiple calls into one commit. -5. **Always wrap programmatic tree-item edits in `blockSignals(True/False)`** - — see `pyside6.instructions.md`. -6. **The data interchange format is a nested `dict[str, Any]`**, not ORM - objects. See the schema below. -7. **`_safe_svc_call` swallows all exceptions.** Errors are logged but never - shown to the user. -8. **`CollectionService` methods are all `@staticmethod`.** Do not add - instance state. -9. **Never call `setStyleSheet()` for static widget styling** — use - `setObjectName()` and global QSS. See `pyside6.instructions.md`. -10. **Never use `# type: ignore` to assign `None` to a non-optional - attribute.** This silences mypy but Pylance still widens the inferred - type to `X | None`, propagating false errors everywhere the attribute - is read. If a field truly needs to become `None`, declare it as - `X | None` from the start and add proper guards at usage sites. - If you only need to drop the reference for GC, `del` the owning - object instead. - -## Layering recap - -``` -UI widgets ──signals──► CollectionWidget ──calls──► CollectionService - │ - (static methods) - │ - Repository functions - │ - get_session() context mgr - │ - SQLite file - -ThemeManager ──QPalette + global QSS──► QApplication - ──theme_changed signal──► widgets (refresh dynamic styles) - ──QSettings──► persistent user preferences - -TabSettingsManager ──QSettings──► persistent request-tab preferences - ──settings_changed──► MainWindow / RequestTabBar - -RequestEditorWidget ──send_requested──► MainWindow - MainWindow → HttpSendWorker (QThread) → HttpService.send_request() - → HttpSendWorker.finished(HttpResponseDict) → ResponseViewerWidget.load_response() - -RequestEditorWidget ──_on_fetch_schema──► SchemaFetchWorker (QThread) - → GraphQLSchemaService.fetch_schema() → SchemaFetchWorker.finished() -``` - -- **DO NOT** import from `database/` in any UI file. The service layer is - the only bridge between UI and repository. -- `ThemeManager` (`ui.styling.theme_manager`) is created once in `main.py` - and passed to `MainWindow`. It owns the app-wide stylesheet, QPalette, - and QSettings persistence for theme preferences. See - `pyside6.instructions.md` for widget styling rules. -- `TabSettingsManager` (`ui.styling.tab_settings_manager`) is created once - in `main.py` and passed to `MainWindow`. It persists request-tab - behaviour (preview enablement, compact labels, duplicate-name path - disambiguation, wrap mode, tab limit, and close-activation policy) - via QSettings. It also stores the open-tab session (tab list + active - index) for restore-on-launch via `save_open_tabs()` / - `load_open_tabs()` / `clear_open_tabs()`. Session data is a JSON - string under QSettings key `tabs/session`. -- `CollectionService` is instantiated as `self._svc = CollectionService()` in - `CollectionWidget.__init__`, but **every method is `@staticmethod`**. - Do not add instance state without updating every call site. -- `EnvironmentService`, `HttpService`, `GraphQLSchemaService`, and - `SnippetGenerator` follow the same `@staticmethod` pattern. - -## The dict interchange schema - -`fetch_all_collections()` in the repository converts ORM objects to a nested -dict **inside the open session** (required because relationships are loaded -lazily per-query). This dict is the canonical data format that flows from -DB through the service layer, across the thread boundary, and into -`CollectionTree.set_collections()`. - -```python -# Top-level: str(collection.id) -> collection dict -{ - "42": { - "id": 42, # int — database PK - "name": "My Folder", # str - "type": "folder", # literal "folder" - "children": { # str(child_id) -> child dict - "99": { # request child - "type": "request", - "id": 99, - "name": "Get Users", - "method": "GET", - }, - "43": { # nested folder child - "type": "folder", - "id": 43, - "name": "Subfolder", - "children": { ... }, - }, - }, - }, -} -``` - -`CollectionDict` (a `TypedDict` in `collection_widget.py`) describes a single -node. When constructing dicts for `add_collection()` or `add_request()`, -follow this schema exactly. - -**Key rules for the dict schema:** -- Top-level keys are `str(collection.id)` — always strings, never ints. -- `"type"` is always `"folder"` or `"request"` — use these exact strings. -- Requests have a `"method"` key (e.g. `"GET"`); folders do not. -- Folders have a `"children"` dict; requests do not. - -### Known issue — ID namespace collision - -Collections and requests share the same `children` dict, both keyed by -`str(id)`. A collection with `id=5` and a request with `id=5` would -collide because they are in different DB tables but the same dict. Unlikely -with SQLite auto-increment, but be aware of it. - -## Signal flow - -> **Full signal flow diagrams, signal declaration tables, and MainWindow -> wiring summary are in the `signal-flow` skill.** -> Reference it when wiring new signals or debugging connections. - -Key signals to know (always-on summary): - -- `CollectionWidget.item_action_triggered(str, int, str)` → opens - requests/folders in MainWindow. -- `CollectionWidget.draft_request_requested()` → opens a new draft - (unsaved) request tab in MainWindow. -- `NewItemPopup.new_request_clicked()` / `new_collection_clicked()` → - emitted by the icon grid popup when tiles are clicked. -- `RequestEditorWidget.send_requested()` → triggers HTTP send flow. -- `ResponseViewerWidget.save_response_requested(dict)` → saves the current live response. -- `ResponseViewerWidget.save_availability_changed(bool)` → refreshes right-sidebar saved-response affordances. -- `SavedResponsesPanel` emits `save_current_requested`, - `rename_requested`, `duplicate_requested`, and `delete_requested` — all - handled in `MainWindow` through `CollectionService`. -- `ThemeManager.theme_changed()` → widgets refresh dynamic styles, including - the wrapped request-tab deck chip styling. -- `TabSettingsManager.settings_changed()` → `MainWindow` / `RequestTabBar` - refresh tab behaviour and label presentation, including switching - between single-row and wrapped-row layouts. -- `MainWindow` View menu exposes `Search Tabs…` (`Ctrl+P`), `Next Tab` - (`Ctrl+Tab`, `Ctrl+PgDown`), and `Previous Tab` - (`Ctrl+Shift+Tab`, `Ctrl+PgUp`) so the wrapped deck keeps editor-style - keyboard navigation even though it is no longer a native `QTabBar`. -- `VariablePopup` uses **class-level callbacks**, not signals — wired once - in `MainWindow.__init__`. - -## Unconnected signals - -| Signal / Feature | Location | Status | -|---|---|---| -| `MainWindow.run_action` | `main_window/window.py` | QAction created, not connected | - -## Implicit contracts - -### 1. `init_db()` must precede `MainWindow()` - -`MainWindow` creates `CollectionWidget`, whose constructor immediately spawns -a background thread that queries the DB. If `init_db()` has not been called, -`get_session()` raises `RuntimeError`. - -### 2. Session-per-function isolation - -Every repository function opens and closes **its own session** via -`get_session()`. There is no way to batch multiple operations in a single -transaction from the service or UI layer. Each call auto-commits -independently. - -**Exception:** `import_repository.import_collection_tree()` uses a **single -session** for the entire collection tree so import is atomic — if any part -fails, the whole import rolls back. - -### 3. ORM objects and detached access - -`get_session()` uses `expire_on_commit=False`, so scalar attributes on -returned ORM objects survive session close. However, **navigating -un-loaded relationships on a detached object raises -`DetachedInstanceError`**. Both `children` and `requests` use -`lazy="selectin"` to eagerly load one level, but for deeper trees the -repository converts to dicts inside the session (see dict schema above). - -### 4. Exception swallowing in `_safe_svc_call` - -`CollectionWidget._safe_svc_call` catches **all** exceptions and only logs -them. Service validation errors (empty names, missing parents) are silently -discarded. - -**If you add a new service method**, its errors will be invisible unless you -also add explicit UI feedback (e.g. a `QMessageBox`). For user-visible -errors, pair the service call with `QMessageBox.warning()` or emit a status -signal instead of relying on `_safe_svc_call`. - -### 5. Sort ordering - -`set_collections()` sorts **root** collections alphabetically by name. -Children within a folder are **not sorted** — they appear in dict iteration -order (insertion order in Python 3.7+). - -### 6. Auth inheritance convention - -`auth = None` in the database means "inherit from parent" — the request -or folder walks up its ancestor chain until it finds a folder with an -explicit `auth` dict. `{"type": "noauth"}` means "no authentication" and -**stops** the inheritance chain. The UI maps `None` to -`"Inherit auth from parent"` in the auth type combo. - -- `_get_auth_data()` returns `None` for inherit, `{"type": "noauth"}` for - explicit no-auth. -- `_load_auth(None)` / `_load_auth({})` → selects "Inherit auth from parent". -- `get_request_inherited_auth(request_id)` / `get_collection_inherited_auth(collection_id)` - resolve the effective auth by walking ancestors. - -### 7. Saved responses are now split across two UI surfaces - -- **Saving** a response remains a response-viewer action. The live response - viewer emits `save_response_requested(dict)` only when it has a live - `HttpResponseDict` loaded. -- **Browsing/managing** saved responses now lives in the right sidebar's - `SavedResponsesPanel`, alongside Variables and Snippets. -- The panel is fully self-contained: selecting a saved response shows its - details (headers, body, metadata) inline, with built-in search and filter. -- The old plain-text Saved tab in `ResponseViewerWidget` has been removed. - -### 8. Saved response data contract - -`CollectionService` now normalizes saved responses into `SavedResponseDict`: - -```python -class SavedResponseDict(TypedDict): - id: int - request_id: int - name: str - status: str | None - code: int | None - headers: list[dict[str, Any]] | None - body: str | None - preview_language: str | None - original_request: dict[str, Any] | None - created_at: str | None - body_size: int -``` - -`get_saved_responses_for_request()` orders rows newest-first by -`created_at DESC, id DESC`, and `CollectionService` formats `created_at` -into `%Y-%m-%d %H:%M` strings for the UI. - -## Repository and service reference - -> **Full repository function catalogues, service method tables, TypedDict -> schemas, and response viewer docs are in the `service-repository-reference` -> skill.** Reference it when adding or modifying repository/service methods. - -## Known limitations - -1. **No cycle detection for collection moves** — `move_collection` only - prevents direct self-reference (`id == new_parent_id`). Moving a parent - into its own descendant would create an infinite loop. -2. ~~**DELETE method has no colour**~~ — Fixed: `COLOR_DELETE` (`#e67e22`) - added to `METHOD_COLORS` in `theme.py`. -3. **`request_parameters` and `headers` are `String` columns** — unlike - `scripts`, `settings`, and `events` (which are JSON columns), these store - serialised strings. Consuming code must handle string-to-dict conversion. -4. ~~**Send not implemented**~~ — Fixed: `RequestEditorWidget.send_requested` - is wired to `MainWindow._on_send_request` which uses `HttpSendWorker` + - `HttpService.send_request()`. -5. **Navigation history is in-memory only** — back/forward stack in - `MainWindow` is lost on restart. -6. **`TabContext.local_overrides` are in-memory only** — - `TabContext` (in `tab_manager.py`) stores per-request variable overrides - in `local_overrides: dict[str, LocalOverride]`. These do **not** persist - to the database. When the user edits a variable value in - `VariablePopup` and dismisses the popup without saving, the changed - value goes into `local_overrides`. They are merged on top of the - combined variable map in `MainWindow._refresh_variable_map()` and - tagged with `is_local=True` in `VariableDetail` so the popup can show - Update/Reset buttons. -7. **`TabContext.draft_name` tracks the display name of unsaved tabs** — - Set to `"Untitled Request"` when a draft tab is opened. Updated when - the user renames via the breadcrumb bar. Used as fallback label in the - save-to-collection dialog. `None` for persisted request tabs. -8. **Request-tab behaviour is settings-driven** — preview tabs, compact - labels, duplicate-name path suffixes, tab insertion position, wrap - mode, tab limit, and close-activation policy are read from - `TabSettingsManager`. - `RequestTabBar` is a custom wrapped multi-row widget, not a native - `QTabBar`; it keeps a small compatibility API (`currentIndex()`, - `setCurrentIndex()`, `count()`, `tabRect()`, `tabButton()`, - `tabToolTip()`, `select_next_tab()`, `select_previous_tab()`, - `tab_search_text()`, `tab_request_info()`) so `MainWindow` and tests - do not depend on Qt tab-bar internals. `MainWindow` enforces the - limit/promotion policies when opening and closing tabs. - **Session persistence:** `_TabControllerMixin._persist_open_tabs()` saves - the current tab list (type + DB id + method + name for requests) and - active index after every tab open/close/reorder and in `closeEvent`. - **Deferred tab materialisation:** `_restore_tabs()` restores tabs - lazily after `CollectionWidget.load_finished` fires. Request tabs - with `method` and `name` in the session data are created as - lightweight tab-bar chips stored in `_deferred_tabs`; the editor and - viewer widgets are built on first selection via - `_materialise_deferred_tab()`. Old-format entries (without - `method`/`name`) fall back to eager `_open_request()` for backward - compatibility. Deleted requests/collections are silently skipped. - Draft (unsaved) tabs are serialized with `type: "draft"` and an inline - snapshot of the editor state (`get_request_data()` + `draft_name`). - On restore, `_restore_draft()` calls `_open_draft_request()` and - replays the saved state into the editor. -9. **Manual tab reorder changes close-unchanged priority** — when the user - drags tabs into a new visible order, `_TabControllerMixin._on_tab_reordered` - rewrites `TabContext.opened_order` to match that order. The - `close_unchanged` limit policy then evicts the leftmost eligible, - unchanged tab instead of an older pre-drag ordering. -10. **VariablePopup uses class-level callbacks, not Qt signals** — - `VariablePopup` is a **singleton** `QFrame`. Its callbacks - (`set_save_callback`, `set_local_override_callback`, - `set_reset_local_override_callback`, `set_add_variable_callback`, - `set_has_environment`) are classmethods that store callables on the - **class itself**, not on an instance. They are wired once in - `MainWindow.__init__` and survive popup hide/show cycles. -11. **Saved response mutations are MainWindow-owned** — - `SavedResponsesPanel` is a read-only/browser widget. It never imports the - repository or service directly for mutations; it only emits signals to - `MainWindow`, which calls `CollectionService` and then refreshes the - sidebar state. diff --git a/.github/instructions/pyside6.instructions.md b/.github/instructions/pyside6.instructions.md deleted file mode 100644 index b0c67ec..0000000 --- a/.github/instructions/pyside6.instructions.md +++ /dev/null @@ -1,360 +0,0 @@ ---- -name: "PySide6 Conventions" -description: "Qt/PySide6 widget coding rules — enum scoping, layout casts, signal patterns" -applyTo: "src/ui/**/*.py" ---- - -# PySide6 coding conventions - -## Quick rules — read these first - -1. **Every `QPushButton` / `QToolButton` MUST call - `setCursor(Qt.CursorShape.PointingHandCursor)`** — no exceptions. - This applies to icon-only buttons, outline buttons, primary buttons, - link buttons, toolbar buttons, and dialog buttons. Always add the - call immediately after construction. -2. **Always use fully qualified enums:** `Qt.ItemDataRole.UserRole`, not - `Qt.UserRole`. -3. **Wrap programmatic item edits in `blockSignals(True)` / `blockSignals(False)`** - or you will get infinite recursion from `itemChanged`. -4. **Never hardcode hex colours** — import from `ui.styling.theme`. -5. **UI files must not import from `database/`** — use signals + service layer. -6. **Use `exec()`, not `exec_()`** for menus, dialogs, and the app event loop. -7. **Cast to `QBoxLayout`** before calling `insertWidget()` — `QLayout` does - not have it in the type stubs. - -## Enum access must always be fully qualified - -PySide6 requires the scoped enum path. Short-form compiles at runtime but -Pylance / mypy reject it. - -```python -# WRONG -Qt.UserRole -Qt.ItemIsEditable -QSizePolicy.Expanding -QTreeWidget.InternalMove -QMessageBox.Yes - -# CORRECT -Qt.ItemDataRole.UserRole -Qt.ItemFlag.ItemIsEditable -QSizePolicy.Policy.Expanding -QTreeWidget.DragDropMode.InternalMove -QMessageBox.StandardButton.Yes -Qt.ContextMenuPolicy.CustomContextMenu -Qt.TextFormat.RichText -QTreeWidget.ScrollHint.EnsureVisible -QLineEdit.ActionPosition.LeadingPosition -QTreeWidgetItem.ChildIndicatorPolicy.ShowIndicator -``` - -## QLayout does not have insertWidget() - -`QLayout.insertWidget()` does not exist in the type stubs. Cast to `QBoxLayout`: - -```python -from typing import cast -from PySide6.QtWidgets import QBoxLayout - -box = cast(QBoxLayout, widget.layout()) -box.insertWidget(1, new_widget) -``` - -## QLayout.takeAt() / itemAt() and QLayoutItem.widget() may return None - -`QLayout.takeAt()` and `QLayout.itemAt()` return `QLayoutItem | None`. -`QLayoutItem.widget()` also returns `QWidget | None`. PySide6 stub -versions vary — some mark these as non-optional, others as optional. -**Always guard both levels** to stay safe across all environments: - -```python -item = layout.takeAt(0) -if item is not None: - w = item.widget() - if w is not None: - w.deleteLater() - -# One-liner for read access: -layout_item = layout.itemAt(1) -widget = layout_item.widget() if layout_item else None -if widget: - widget.hide() -``` - -## Use exec() not exec_() - -`exec_()` is deprecated. Always use `exec()` for menus, dialogs, and the -application event loop. - -## UI widgets must not import from database/ - -Widgets live in `src/ui/` and must communicate via **Qt signals**. -The service layer (`src/services/`) connects those signals to the repository. - -## Prefer named constants for custom data roles - -Define roles at module level in `ui/collections/tree/constants.py`, not as -inline magic numbers: - -```python -ROLE_ITEM_ID = Qt.ItemDataRole.UserRole # column 0 -ROLE_ITEM_TYPE = Qt.ItemDataRole.UserRole + 1 # column 1 -``` - -Import them where needed: - -```python -from ui.collections.tree import ROLE_ITEM_ID, ROLE_ITEM_TYPE -``` - -## All colours and method_color() live in ui/styling/theme.py - -Never hardcode hex colour values in widget files. Import from `ui.styling.theme`: - -```python -from ui.styling.theme import COLOR_ACCENT, METHOD_COLORS, DEFAULT_METHOD_COLOR, method_color -``` - -## Icons — Phosphor font glyphs via ui/styling/icons.py - -Use the `phi()` helper from `ui.styling.icons` for all button and menu icons. -**Never** use `QIcon.fromTheme()` — it is unreliable across platforms. - -```python -from ui.styling.icons import phi - -button.setIcon(phi("paper-plane-right")) -action.setIcon(phi("trash", color="#e74c3c", size=16)) -``` - -- `phi(name)` returns a cached `QIcon` rendered from the bundled Phosphor - TTF font (`data/fonts/phosphor.ttf`). -- Default colour is `COLOR_TEXT_MUTED`; override with the `color` kwarg. -- Default size is 16 px; override with `size`. -- Icons are cached by `(name, color, size)` — each unique combo is created - once. -- `load_font()` is called once in `main.py` after `QApplication` is created. -- `clear_cache()` is called automatically by `ThemeManager.apply()` on theme - change so icon colours refresh. -- Browse available icon names in `data/fonts/phosphor-charmap.json`. - -## Theme system — ThemeManager + global QSS + QPalette - -The application uses a centralised theme system with three layers: - -1. **ThemeManager** (`ui/styling/theme_manager.py`) — singleton `QObject` - created in `main.py` right after `QApplication`. Reads/writes - `QSettings`, resolves light/dark palette, applies - `QApplication.setStyle()`, `QApplication.setPalette()`, and - `QApplication.setStyleSheet()`. -2. **Global QSS** — a single application-wide stylesheet built by - `ThemeManager._build_global_qss()` using `objectName` selectors. - Widgets do **not** call `setStyleSheet()` for static styling; instead - they set `setObjectName("primaryButton")` etc. -3. **QPalette** — built from a `ThemePalette` dict - (`ui/styling/theme.py`) via `ThemeManager._build_qpalette()`. Two - palettes exist: `LIGHT_PALETTE` and `DARK_PALETTE`. - `set_active_palette()` updates the mutable module-level colour aliases - (`COLOR_ACCENT`, `COLOR_TEXT`, etc.). - -### objectName conventions for styling - -Widgets use `setObjectName()` to opt into global QSS rules. These are the -standard object names: - -| objectName | Widget type | Visual role | -|---|---|---| -| `primaryButton` | `QPushButton` | Accent-coloured action button | -| `dangerButton` | `QPushButton` | Red destructive action | -| `smallPrimaryButton` | `QPushButton` | Compact accent button | -| `outlineButton` | `QPushButton` | Border-only button | -| `iconButton` | `QPushButton` | Icon-only square button (no padding) | -| `iconDangerButton` | `QPushButton` | Icon-only button with danger-red hover | -| `linkButton` | `QPushButton` | Text-only accent link | -| `flatAccentButton` | `QPushButton` | Borderless accent text | -| `flatMutedButton` | `QPushButton` | Borderless muted text | -| `importLinkButton` | `QPushButton` | Underlined import link | -| `dismissButton` | `QPushButton` | Dialog dismiss button | -| `titleLabel` | `QLabel` | Bold 14px heading | -| `sectionLabel` | `QLabel` | 12px section heading | -| `panelTitle` | `QLabel` | Bold 12px panel title with padding | -| `mutedLabel` | `QLabel` | Small muted text | -| `emptyStateLabel` | `QLabel` | Italic muted empty-state message | -| `methodBadge` | `QLabel` | HTTP method badge (tree + tabs) | -| `monoEdit` | `QTextEdit` | Monospace text editor | -| `consoleOutput` | `QTextEdit` | Dark console output area | -| `importTabs` | `QTabWidget` | Box-style tabs in import dialog | -| `codeEditor` | `QPlainTextEdit` | Syntax-highlighted code editor | -| `gqlSplitter` | `QSplitter` | GraphQL query/variables splitter | -| `rowDeleteButton` | `QPushButton` | Row delete button in key-value table | -| `infoPopup` | `QFrame` | Response metadata popup container | -| `infoPopupTitle` | `QLabel` | Popup title heading | -| `infoPopupSeparator` | `QLabel` | Popup horizontal rule | -| `variablePopupBadge` | `QLabel` | Source badge (collection/environment/unresolved/local) | -| `variablePopupName` | `QLabel` | Variable name heading | -| `variablePopupValue` | `QLineEdit` | Editable variable value field | -| `variablePopupUpdateBtn` | `QPushButton` | "Update" button (persist local override) | -| `variablePopupResetBtn` | `QPushButton` | "Reset" button (remove local override) | -| `variablePopupAddSelect` | `QPushButton` | "Add to ▾" select-box toggle | -| `variablePopupAddPanel` | `QFrame` | Expandable target panel for unresolved vars | -| `variablePopupTarget` | `QPushButton` | Collection/environment target button | -| `variablePopupNoEnv` | `QLabel` | "No environment selected" warning | -| `variablePopup` | `QFrame` | Variable popup container | -| `saveButton` | `QPushButton` | Save action button | -| `sidebarSearch` | `QLineEdit` | Collection sidebar search input | -| `sidebarSectionLabel` | `QLabel` | Sidebar section heading | -| `sidebarToolButton` | `QToolButton` | Sidebar toolbar button | -| `newItemPopup` | `QDialog` | Postman-style "Create New" dialog | -| `newItemTile` | `QPushButton` | Tile button inside the new-item dialog | -| `newItemTileLabel` | `QLabel` | Tile label text inside the dialog | -| `newItemTitle` | `QLabel` | Dialog heading ("What do you want to create?") | -| `newItemDescription` | `QLabel` | Description text below tiles | -| `collectionTree` | `QTreeWidget` | Collection tree in SaveRequestDialog | -| `sidebarRail` | `QWidget` | Always-visible icon rail (RightSidebar widget) | -| `sidebarRailButton` | `QToolButton` | Checkable icon button in the rail | -| `sidebarPanelArea` | `QWidget` | Collapsible flyout panel (separate splitter child) | -| `sidebarTitleLabel` | `QLabel` | Bold panel title in flyout header | -| `variableKeyLabel` | `QLabel` | Variable key in sidebar panel | -| `variableValueLabel` | `QLabel` | Variable value in sidebar panel | -| `sidebarSourceDot` | `QLabel` | Colour-coded variable source dot | -| `sidebarSeparator` | `QFrame` | Separator line in sidebar panels | - -### QTabBar overflow scroll buttons - -When a `QTabWidget` has more tabs than fit, Qt shows left/right -`QToolButton` scroll arrows inside the `QTabBar`. These are styled -globally in `global_qss.py` with: - -- `background: input_bg` -- `border: 1px solid border` (sharp corners, `border-radius: 0`) -- `border-color: accent` on hover - -Do **not** override or remove the default platform arrows. Do **not** -add `border-radius`, `bg_alt` hover fills, or `image: none` rules. -The global rule is unscoped — it applies to every `QTabBar` in the app. - -### When inline setStyleSheet() is still acceptable - -Only use `setStyleSheet()` for **dynamic per-instance** styling that -varies at runtime and cannot be expressed with objectName selectors: - -- Method badge background-color (varies by HTTP method) -- Status label colour (varies by HTTP status code) -- History row method colour -- Breadcrumb per-segment colour -- Spinner animation colours -- Drop-zone active hover overlay - -For everything else, use `setObjectName()` and let the global QSS handle it. - -### Adding new styled widgets - -1. Choose an appropriate `objectName` from the table above, or create a new - one if none fits. -2. Call `widget.setObjectName("yourName")` in the widget constructor. -3. Add the corresponding QSS rule in `ThemeManager._build_global_qss()`. -4. Do **not** call `setStyleSheet()` on the widget. - -### Theme module contents - -> **Detailed contents (palette definitions, colour constants, method colour -> mapping, badge system) are in the `widget-patterns` skill.** - -### Tree item badge rendering - -> **Custom delegate details, column semantics, and data role layout are in -> the `widget-patterns` skill.** Key fact: the delegate reads -> `ROLE_METHOD` (column 0) and column 1 display text. No per-row -> `QWidget` is created. - -## QPushButton — icons, cursors, and icon-only buttons - -### Every button must have a pointing-hand cursor - -All `QPushButton` instances must set a hand cursor so users know they are -clickable: - -```python -btn.setCursor(Qt.CursorShape.PointingHandCursor) -``` - -### Icon-only buttons must use `iconButton`, not `outlineButton` - -The `outlineButton` style has `padding: 4px 12px` which leaves no room for -the icon in a compact square button. For icon-only buttons (no text): - -1. Use `setObjectName("iconButton")` — it has `padding: 0px` with hover - and checked states. -2. Use `setFixedSize(28, 28)` (not `setFixedWidth`) so the button is a - proper square. -3. Do **not** set text — icon only. - -```python -# CORRECT — icon-only button -btn = QPushButton() -btn.setIcon(phi("funnel")) -btn.setObjectName("iconButton") -btn.setCursor(Qt.CursorShape.PointingHandCursor) -btn.setFixedSize(28, 28) - -# WRONG — icon invisible due to outlineButton padding -btn = QPushButton() -btn.setIcon(phi("funnel")) -btn.setObjectName("outlineButton") -btn.setFixedWidth(28) -``` - -### Buttons with text + icon use `outlineButton` - -When a button has both text and an icon, use `outlineButton` and let Qt -auto-size the width: - -```python -btn = QPushButton("Wrap") -btn.setIcon(phi("text-align-left")) -btn.setObjectName("outlineButton") -btn.setCursor(Qt.CursorShape.PointingHandCursor) -``` - -## Wrap programmatic item edits in blockSignals - -`QTreeWidget` emits `itemChanged` whenever item text is modified — including -programmatic changes. If a slot connected to `itemChanged` also modifies -items, you get infinite recursion or spurious rename signals. - -**Always** wrap bulk or programmatic updates: - -```python -self._tree.blockSignals(True) -try: - item.setText(0, new_name) - item.setData(1, ROLE_OLD_NAME, new_name) -finally: - self._tree.blockSignals(False) -``` - -Every call to `blockSignals(True)` must have a matching -`blockSignals(False)` — prefer a `try/finally` block. - -## Tree item column semantics differ by type - -> **Full column semantics table, data role layout, and context-menu -> `_current_item` rules are in the `widget-patterns` skill.** -> -> Quick reference: folders use column 0 for display; requests use column 1 -> (delegate paints badge + name from column 0 `ROLE_METHOD` + column 1 -> text). - -## Background workers, InfoPopup, and VariablePopup - -> **QThread worker pattern, InfoPopup base class details, VariablePopup -> singleton rules, and VariableLineEdit painting rules are in the -> `widget-patterns` skill.** -> -> Key rules (always apply): -> - Use `QObject` + `moveToThread()`, not `QThread` subclass. -> - `InfoPopup` uses `QFrame` (not `QWidget`) — `QWidget` breaks QSS -> borders on Linux. -> - `VariablePopup` uses class-level callbacks, **not** Qt signals. -> - `VariableLineEdit.set_variable_map()` takes `dict[str, VariableDetail]`. diff --git a/.github/skills/customization-guide/SKILL.md b/.github/skills/customization-guide/SKILL.md deleted file mode 100644 index 58d6fa2..0000000 --- a/.github/skills/customization-guide/SKILL.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: customization-guide -description: "How to create, update, or debug Copilot instruction files, skills, applyTo patterns, and YAML frontmatter. Use when adding a new instruction file, creating a new skill, updating the customization structure, or troubleshooting why instructions or skills are not loading." ---- - -# Copilot customization guide - -How to create and manage custom instructions (`.instructions.md`) and -agent skills (`SKILL.md`) for the Postmark project. - -## When to use instructions vs skills - -| Feature | Custom instructions | Agent skills | -|---------|-------------------|--------------| -| **Location** | `.github/instructions/` | `.github/skills/<name>/` | -| **Filename** | `*.instructions.md` | `SKILL.md` | -| **Loading** | Always-on for matching `applyTo` glob | On-demand when description matches prompt | -| **Best for** | Core rules, conventions, hard constraints | Reference material, step-by-step guides, catalogues | -| **Context cost** | Loaded into every matching request | Only loaded when relevant | - -**Rule of thumb:** If an LLM needs the information for *every* code change -in a file pattern, it belongs in instructions. If it only needs it for -*specific tasks* (e.g. "add a new widget", "debug signals"), it belongs in -a skill. - -## Creating a new instruction file - -1. Create the file in `.github/instructions/`: - - ``` - .github/instructions/my-topic.instructions.md - ``` - -2. Add YAML frontmatter with `applyTo` glob: - - ```yaml - --- - name: "My Topic" - description: "Brief description of what rules this covers" - applyTo: "src/my-module/**/*.py" - --- - ``` - -3. Write concise, imperative rules. Start with a "Quick rules" section. - -4. Register the new file in `copilot-instructions.md`: - - ```markdown - | [my-topic.instructions.md](./instructions/my-topic.instructions.md) | `src/my-module/**/*.py` | - ``` - -5. Run `python scripts/check_md_links.py` to verify links. - -### Instruction file guidelines - -- Keep instructions lean — they are loaded into *every* request. -- Use imperative tone ("Do X", "Never Y"). -- Start with numbered "Quick rules" for the most critical constraints. -- Include code examples for patterns that are easy to get wrong. -- Never duplicate rules across instruction files. - -## Creating a new skill - -1. Create a directory under `.github/skills/`: - - ``` - .github/skills/my-skill/ - ``` - -2. Create the `SKILL.md` file with YAML frontmatter: - - ```yaml - --- - name: my-skill - description: >- - Detailed description of what this skill does and when Copilot should - use it. Include trigger phrases like "Use when adding new X" or - "Use when debugging Y". - --- - ``` - -3. Write the skill body in Markdown. Include: - - Step-by-step procedures - - Code templates and examples - - Reference tables - - Checklists - -4. Optionally add supplementary files (scripts, examples) in the same - directory. - -### Skill naming conventions - -- Directory name: lowercase, hyphens for spaces (e.g. `signal-flow`) -- `name` in frontmatter: must match directory name -- `description`: be specific about when it should trigger — this is what - Copilot uses to decide whether to load the skill - -### Skill description tips - -The `description` field is critical — it determines when the skill gets -loaded. Include: - -- **What** the skill does -- **When** to use it (trigger phrases) -- **Keywords** a user might mention in their prompt - -**Good:** -```yaml -description: >- - Complete signal flow diagrams and wiring map for the Postmark codebase. - Use when wiring new signals, debugging signal connections, adding new - UI actions, or understanding how data flows between widgets. -``` - -**Bad:** -```yaml -description: Signal documentation -``` - -## Existing structure - -### Instructions (always-on) - -| File | Applies to | Content | -|------|------------|---------| -| `copilot-instructions.md` | All files | Project overview, tree, validation | -| `architecture.instructions.md` | `src/**/*.py` | Core rules, layering, contracts | -| `pyside6.instructions.md` | `src/ui/**/*.py` | Qt conventions, enums, QSS | -| `testing.instructions.md` | `tests/**/*.py` | Fixture patterns, test layers | -| `sqlalchemy.instructions.md` | `src/database/**/*.py` | ORM patterns, sessions | - -### Skills (on-demand) - -| Skill | Trigger | -|-------|---------| -| `signal-flow` | Wiring signals, debugging connections, understanding data flow | -| `service-repository-reference` | Adding service/repo methods, looking up API | -| `widget-patterns` | Creating widgets, delegates, popups, background workers | -| `test-writing` | Writing new tests for any layer | -| `import-parser` | Adding new import format support | -| `customization-guide` | Adding new instructions or skills | - -## Mandatory sync after changes - -After modifying any instruction or skill file, follow the checklist in -`copilot-instructions.md` under "CRITICAL — Keeping instructions in sync". - -After modifying any `.md` file, run: - -```bash -python scripts/check_md_links.py -``` diff --git a/.github/workflows/vendor-audit.yml b/.github/workflows/vendor-audit.yml new file mode 100644 index 0000000..94d126b --- /dev/null +++ b/.github/workflows/vendor-audit.yml @@ -0,0 +1,36 @@ +name: Vendor Security Audit + +on: + workflow_dispatch: # Allow manual trigger from GitHub UI. + pull_request: + branches: [main] + +concurrency: + group: vendor-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install --with dev + + - name: Run vendor audit + run: poetry run python scripts/audit_vendor.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0078491 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,665 @@ +# Postmark — Agent Instructions + +## CRITICAL — Keeping instructions in sync + +> **MANDATORY — EVERY code change MUST be followed by an instruction audit.** +> After modifying, adding, or deleting ANY source file, test file, signal, +> TypedDict, service method, QSS objectName, or architectural pattern, you +> MUST review ALL relevant `AGENTS.md` files and skills listed below and update them to reflect +> the change. **Stale or incomplete instructions are treated as bugs.** +> +> Checklist — run through each step after every code change: +> +> 1. **Update the architecture tree** in this file to match `src/` and +> `tests/`. Add new files, remove deleted files. +> 2. **Update [`src/AGENTS.md`](src/AGENTS.md)** with any new or changed +> signals, data flows, TypedDicts, implicit contracts, or service methods. +> 3. **Update [`src/ui/AGENTS.md`](src/ui/AGENTS.md)** with any new `objectName` values +> used in global QSS. +> 4. **Update [`tests/AGENTS.md`](tests/AGENTS.md)** with any new test files or +> directories. +> 5. **Update [`src/database/AGENTS.md`](src/database/AGENTS.md)** with any new models, +> relationships, or repository functions. +> 6. **Update relevant skills** (under `.agents/skills/`) when adding or +> changing signals, service/repository methods, TypedDicts, widgets, or +> parsers. See the Skills table below. +> 7. **Search every `AGENTS.md` file and skill** for stale references to +> renamed, moved, or deleted code. Remove or correct them. +> 8. **Update `docs/` pages** when adding, changing, or removing public API, +> signals, TypedDicts, widgets, parsers, or architectural patterns. +> Update [`docs/AGENTS.md`](docs/AGENTS.md) when documentation authoring rules change. +> See `docs/contributing/updating-docs.md` for the full checklist. + +This file and the nested `AGENTS.md` files below form a single source of +truth. + +- **Check all agent instruction files for overlap** before editing any of them. +- **Never duplicate rules** across files — reference the canonical location. +- **Place rules in the most specific file** that applies. Only add rules here + if they are truly project-wide. +- **Prefer adding a nested `AGENTS.md`** in the appropriate directory + (`src/`, `src/ui/`, `src/database/`, `tests/`, `docs/`) over growing this file. + +Nested `AGENTS.md` files (merged with this file based on which paths you edit — see each file for layer-specific rules): + +| File | Scope | +|------|-------| +| [src/ui/AGENTS.md](src/ui/AGENTS.md) | PySide6 / UI code under `src/ui/` | +| [src/database/AGENTS.md](src/database/AGENTS.md) | SQLAlchemy / DB under `src/database/` | +| [src/AGENTS.md](src/AGENTS.md) | Architecture & data flow for all of `src/` | +| [tests/AGENTS.md](tests/AGENTS.md) | Testing conventions under `tests/` | +| [docs/AGENTS.md](docs/AGENTS.md) | Documentation authoring under `docs/` | + +On-demand skills — read the relevant `SKILL.md` when the task matches (see `description` in each file’s frontmatter): + +| Skill | Description | +|-------|-------------| +| [signal-flow](.agents/skills/signal-flow/SKILL.md) | Complete signal flow diagrams, signal declaration tables, MainWindow wiring summary | +| [service-repository-reference](.agents/skills/service-repository-reference/SKILL.md) | Repository function catalogues, service method tables, TypedDict schemas | +| [widget-patterns](.agents/skills/widget-patterns/SKILL.md) | Tree badge rendering, data roles, InfoPopup, VariablePopup, theme module, new widget checklist | +| [test-writing](.agents/skills/test-writing/SKILL.md) | Test patterns for all layers — repository, service, UI widget, MainWindow | +| [import-parser](.agents/skills/import-parser/SKILL.md) | How to add a new import format parser to the import system | +| [customization-guide](.agents/skills/customization-guide/SKILL.md) | How to create, update, or debug agent instructions, nested `AGENTS.md`, skills, and project conventions | + +> **Nested AGENTS.md vs Skills:** Nested files apply when you work under their directories — keep them lean with core rules. Skills are optional deep reference — load when the task matches the skill description. + +### Quick-reference — creating new skills or nested instructions + +If you need to **add a new skill** or **nested `AGENTS.md`**, follow these +minimal rules (full guide in the `customization-guide` skill): + +**Skill** — `.agents/skills/<name>/SKILL.md`: +```yaml +--- +name: "<name>" # kebab-case, matches folder name +description: "One sentence ... when to load this skill" +--- +# <Title> +(content) +``` + +**Nested agent instructions** — add `AGENTS.md` in the directory that owns the rules (e.g. `src/ui/AGENTS.md`). No glob metadata required — location defines scope. + +After creating either, **update this file**: add the new entry to the +nested-files or skills table above, and update the sync checklist +if needed. + +## Project overview + +**Postmark** — native desktop API client built with **PySide6**, **SQLAlchemy 2.0**, **Python 3.12+**, managed by **Poetry**. + +```bash +poetry install --with dev # pytest, ruff, mypy +poetry run python src/main.py +poetry run ruff check src/ && poetry run ruff format src/ +poetry run mypy src/ +poetry run pytest +``` + +`src/` is the source root for all tools (`pythonpath`, `mypy_path`, +`extraPaths` in `pyproject.toml`). Imports use bare module names: +`from database.database import init_db`. + +## LLM Navigation Quick-Start + +Fastest paths to understand and navigate the codebase: + +- **All services at a glance:** Read `src/services/__init__.py` — re-exports + `CollectionService`, `EnvironmentService`, `ImportService`, + `RunHistoryService`, and key TypedDicts (`RequestLoadDict`, + `VariableDetail`, `LocalOverride`). +- **HTTP subsystem:** Read `src/services/http/__init__.py` — re-exports + `HttpService`, `GraphQLSchemaService`, `SnippetGenerator`, + `SnippetOptions`, `HttpResponseDict`, `parse_header_dict`. + Auth header injection lives in `src/services/http/auth_handler.py`. + OAuth 2.0 token exchange lives in `src/services/http/oauth2_service.py`. +- **All DB models:** Read `src/database/database.py` — re-exports collection, + environment, run-history, and local-script ORM models (`CollectionModel`, + `RequestModel`, `SavedResponseModel`, `EnvironmentModel`, `RunHistoryModel`, + `RunResultModel`, `LocalScriptFolderModel`, `LocalScriptModel`, + `SnippetModel`). +- **Collection CRUD vs queries:** Mutations live in + `collection_repository.py`; read-only tree/breadcrumb/ancestor queries + live in `collection_query_repository.py`. +- **Signal flow:** Load the `signal-flow` skill for complete wiring diagrams. +- **TypedDicts:** Cross-module dict schemas live in the service that owns + them (e.g. `RequestLoadDict` in `collection_service.py`, + `HttpResponseDict` in `http_service.py`). +- **Test fixtures:** `make_collection_with_request` (root `conftest.py`) and + `make_request_dict` (`tests/ui/request/conftest.py`) reduce setup + boilerplate. + +## Architecture + +``` +docs/ # Project documentation (see docs/README.md) +├── README.md # Landing page + full table of contents +├── getting-started/ # Installation, running, overview +├── architecture/ # Layered design, data flow, directory tree +├── api-reference/ # Function signatures, TypedDicts, signals +│ ├── database/ # ORM models, repository functions +│ └── services/ # Service methods, HTTP, auth, parsers +├── ui-reference/ # Widget classes, styling, navigation +├── guides/ # How-to guides (import parser, auth, widget, tests, signals) +└── contributing/ # Coding conventions, testing, updating docs +data/ +└── snippets/ # Script editor snippet JSON (javascript, python; see README.md) +src/ +├── main.py # Entry point — configure_before_qapplication + QApplication + init_db() +├── qt_app_init.py # Hi-DPI bootstrap (before first QApplication; tests + app) +├── database/ # Engine, models, repository +│ ├── database.py # init_db(), get_session(), migration +│ └── models/ +│ ├── base.py # DeclarativeBase +│ ├── collections/ +│ │ ├── collection_repository.py # CRUD for collections + requests +│ │ ├── collection_query_repository.py # Read-only tree/breadcrumb/ancestor queries +│ │ ├── import_repository.py # Atomic bulk-import of parsed data +│ │ └── model/ +│ │ ├── collection_model.py # CollectionModel (folders) +│ │ ├── request_model.py # RequestModel (HTTP requests) +│ │ └── saved_response_model.py +│ ├── runs/ +│ │ ├── run_history_repository.py # CRUD for run history + results +│ │ └── model/ +│ │ ├── run_history_model.py # RunHistoryModel (collection runs) +│ │ └── run_result_model.py # RunResultModel (per-request results) +│ ├── environments/ +│ │ ├── environment_repository.py # CRUD for environments +│ │ └── model/ +│ │ └── environment_model.py # EnvironmentModel (key-value sets) +│ └── local_scripts/ +│ ├── local_script_repository.py # CRUD + atomic rename/move + ref rewrite +│ ├── local_script_query_repository.py # Read-only script tree query +│ ├── path_policy.py # Path-safe folder/script segment validation +│ ├── virtual_paths.py # script_virtual_extension; .js vs .cjs paths +│ ├── path_index.py # Virtual path list for pm.require local: autocomplete +│ ├── require_refs_rewrite.py # pm.require("local:…") reference rewriter +│ ├── import_refs_rewrite.py # static relative import/export-from rewriter +│ └── model/ +│ ├── local_script_folder_model.py +│ └── local_script_model.py # ``module_format`` (``esm`` | ``commonjs``) +│ └── snippets/ +│ ├── snippet_repository.py # CRUD for user-authored script snippets +│ └── model/ +│ └── snippet_model.py # SnippetModel (context) +│ └── request_assertions/ +│ ├── request_assertion_repository.py # CRUD for declarative assertion rows +│ └── model/ +│ └── request_assertion_model.py # RequestAssertionModel (subject/operator/expected) +├── services/ # Service layer (UI ↔ DB bridge) +│ ├── collection_service.py # CollectionService (static methods) +│ ├── assertion_service.py # AssertionService + AssertionDict — declarative tests CRUD + compile +│ ├── local_script_service.py # LocalScriptService + LocalScriptLoadDict +│ ├── snippet_service.py # SnippetService — user snippet CRUD + loader cache invalidation +│ ├── environment_service.py # EnvironmentService (variable substitution + TypedDicts) +│ ├── import_service.py # ImportService (parse + persist) +│ ├── run_history_service.py # RunHistoryService (run history CRUD bridge) +│ ├── script_service.py # ScriptService (script chain resolution) +│ ├── scripting/ # Script execution sub-package +│ │ ├── local_path_policy.py # Re-export path_policy (UI/service) +│ │ ├── local_virtual_paths.py # Re-export virtual_paths +│ │ ├── local_script_modules.py # pm.require("local:…") resolve + bundle +│ │ ├── local_scripts_project/ # Deno mirror, ESM import graph, local entry run/debug, LSP URI refcount +│ │ │ ├── mirror.py # sync_all (prune orphans), sync_script, sync_closure; mirror_write_lock (RLock) serializes mirror writes +│ │ │ ├── deno_config.py # ensure_ambient_pm, ensure_local_project_config +│ │ │ ├── import_graph.py # regex static import/export-from + pm.require closure; esm_import_string_tail + relative_import_suggestions +│ │ │ ├── runner.py # run_local_entry, debug_local_entry +│ │ │ ├── navigation.py # resolve_esm_import_target_script_id +│ │ │ └── lsp_uri_registry.py +│ │ ├── debug_script_metadata.py # Persisted breakpoints/watches JSON (scripts.debug + local debug_metadata) +│ │ ├── dynamic_variables.py # Postman {{$…}} resolve (send-time + RestrictedPython replaceIn) +│ │ ├── json_schema_mini.py # Subset JSON Schema validator for pm.expect().jsonSchema() +│ │ ├── local_dependency_diagnostics.py # Direct local: dependency lint for host script editors +│ │ ├── local_script_require_refs.py # Re-export require_refs_rewrite +│ │ ├── __init__.py # TypedDicts (ScriptInput/Output, TestResult, etc.) +│ │ ├── engine.py # ScriptEngine + run_debug_chain (re-exports find_pm_tests, find_top_level_statement_lines) +│ │ ├── pm_test_finder.py # find_pm_tests — pm.test discovery for gutter +│ │ ├── pm_api_linter.py # Diagnostic + pm/postman static walk helpers +│ │ ├── script_breakpoint_analyzer.py # find_top_level_statement_lines — debugger gutter +│ │ ├── assertions_compiler.py # compile_to_js/py — declarative rows → pm.test blocks (source_name declarative) +│ │ ├── data_loader.py # parse_data_file — CSV/JSON rows for data-driven runs +│ │ ├── context.py # Context builders + normalize_events() + execute_sub_request() + globals persistence +│ │ ├── deno_manager.py # DenoManager — managed Deno download/cache; managed_deno_path() = cache only +│ │ ├── python_format.py # format_python_source() — Ruff format for script editors (jedi has no formatter) +│ │ ├── runtime_settings.py # RuntimeSettings + RuntimePathStatus + RegistryEntry + PyPIConfig — QSettings Deno/Python paths, LSP toggle, validation, private package registries (npm/JSR scope-mapped, default-npm with auth_kind, PyPI index URLs) +│ │ ├── secret_store.py # SecretStore (Protocol) + KeyringSecretStore / EncryptedFileSecretStore / NoopSecretStore + get_default_store() (keyring self-test fallback) + backend_status() — token storage for private package registries +│ │ ├── deno_runtime.py # DenoRuntime — default JS run via deno run + data/scripts/deno_drain.mjs (sendRequest IPC); _build_npmrc_text() resolves private-registry tokens into a chmod-0600 .npmrc when ``pm.require("npm:…")``/``("jsr:…")`` literals trigger network mode +│ │ ├── esprima_deno.py # Esprima parse via deno run data/scripts/esprima_parse.mjs (linter, gutter) +│ │ ├── js_runtime.py # JSRuntime (DenoRuntime) + bootstrap/vendor + pm.require literal detection / ESM import block for npm:/jsr: +│ │ ├── py_runtime.py # PyRuntime — Pyodide (Deno) when vendor present, else RestrictedPython subprocess +│ │ ├── pyodide_runtime.py # PyodideRuntime — data/scripts/pyodide_run.mjs + vendor_pyodide + micropip / pm.require; _resolve_pypi_index_urls() embeds auth into private PyPI index URLs (micropip.set_index_urls) +│ │ ├── _py_sandbox.py # RestrictedPython subprocess entry (main + _execute_restricted; re-exports for tests) +│ │ ├── _sandbox_safe_globals.py # _SAFE_BUILTINS / _SAFE_STDLIB for RestrictedPython +│ │ ├── _sandbox_runtime.py # Resource limits, console capture, _write_done +│ │ ├── _sandbox_pm_assertions.py # _Expectation chains +│ │ ├── _sandbox_pm_models.py # _PmRequest/_PmResponse/_HeaderList, … +│ │ ├── _sandbox_pm_tests.py # pm.test / pm.test.skip +│ │ ├── _sandbox_pm.py # _Pm root object + variable scopes +│ │ ├── _sandbox_debug.py # settrace debug execution (_execute_debug) +│ │ └── debug/ # Debug sub-package (step-through debugging) +│ │ ├── protocol.py # DebugProtocol state machine + DebugPauseInfo +│ │ ├── js_debug.py # JS: inject_checkpoints, locals readers; debug_execute → deno_debug +│ │ ├── deno_scope.py # CDP scope materialisation; deep expand object bindings across scopes; ``__pm_className__`` for CDP descriptions +│ │ ├── deno_debug.py # Deno --inspect-brk + CDP (Chrome DevTools Protocol) step-through +│ │ └── py_debug.py # Python settrace subprocess debug execution +│ ├── lsp/ # Language Server Protocol (Deno LSP, jedi-language-server) +│ │ ├── transport.py # LspTransport — JSON-RPC Content-Length + QThread reader +│ │ ├── client.py # LspClient — initialize, didOpen/Change/Close, requests +│ │ ├── qt_lsp_offsets.py # QTextDocument position ↔ LSP line/UTF-16 column +│ │ ├── pm_require_resolve.py # npm/jsr registry latest lookup for unversioned pm.require LSP types +│ │ ├── js_lsp_preamble.py # Triple-slash refs prepended to virtual JS buffers for Deno LSP +│ │ ├── npm_types_members.py # @types .d.ts member extraction for npm pm.require completion fallback +│ │ ├── pm_require_types.py # pm_require_index.ts generation + deno cache for npm/jsr specs +│ │ ├── local_script_lsp_prep.py # prepare_local_script_lsp_attach (mirror + index + closure; worker-safe) +│ │ ├── local_script_lsp_prep_worker.py # LocalScriptLspPrepWorker — QThread prep → GUI finalize +│ │ ├── stubs_generator.py # pm.d.ts / pm.pyi from pm_api_schema +│ │ ├── server_registry.py # LspRegistry — per-bucket warm_async; shutdown stops all _clients +│ │ ├── servers/spawn.py # Off-GUI Popen + LspSpawnWorker; prepare_*_spawn metadata +│ │ └── servers/ # make_deno_client, make_jedi_client, workspace seed +│ │ ├── _workspace.py +│ │ ├── deno_client.py +│ │ └── jedi_client.py +│ ├── http/ # HTTP request/response handling +│ │ ├── http_service.py # HttpService (httpx) + response TypedDicts +│ │ ├── graphql_schema_service.py # GraphQL introspection + schema parsing +│ │ ├── auth_handler.py # Shared auth header injection (all 12 auth types) +│ │ ├── oauth2_service.py # OAuth 2.0 token exchange (4 grant types) +│ │ ├── snippet_generator/ # Code snippet generation sub-package (23 languages) +│ │ │ ├── generator.py # SnippetGenerator, SnippetOptions, LanguageEntry, registry +│ │ │ ├── shell_snippets.py # cURL, HTTP raw, wget, HTTPie, PowerShell +│ │ │ ├── dynamic_snippets.py # Python, JS, Node, Ruby, PHP, Dart +│ │ │ └── compiled_snippets.py # Go, Rust, C, Swift, Java, Kotlin, C# +│ │ └── header_utils.py # Shared header parsing utility +│ └── import_parser/ # Parser sub-package +│ ├── models.py # TypedDict schemas for parsed data +│ ├── postman_parser.py # Postman collection/environment parser +│ ├── curl_parser.py # cURL command parser +│ └── url_parser.py # URL/raw-text auto-detect parser +└── ui/ # PySide6 widgets + ├── main_window/ # Top-level MainWindow sub-package + │ ├── window.py # MainWindow widget + signal wiring + │ ├── send_pipeline.py # _SendPipelineMixin — HTTP send (re-exports debug-hover helpers) + │ ├── send_pipeline_debug.py # _merge_debug_hover_values, _debug_hover_root_objects, … + │ ├── send_pipeline_postresponse.py # on_send_finished, run_post_response_script_with_live_response + │ ├── send_pipeline_debug_session.py # on_debug_paused/step/finished, end_debug_ui + │ ├── draft_controller.py # _DraftControllerMixin — draft tab open/save + │ ├── tab_controller.py # _TabControllerMixin — tab open/close/switch + │ ├── tab_nav/ # Tab activation back/forward stacks + │ │ ├── history.py # _TabNavHistoryMixin — Go menu Ctrl+Alt+arrows + │ │ └── __init__.py + │ └── variable_controller.py # _VariableControllerMixin — env variable + sidebar management + ├── local_scripts/ # Centre-pane local script editor + │ ├── local_script_editor_widget.py # LocalScriptEditorWidget — CodeEditorWidget + DB save + │ └── script_filename.py # Basename/extension display helpers for script tree + tabs + ├── loading_screen.py # Loading screen overlay widget + ├── sidebar/ # Sidebar rails + flyout panels + │ ├── sidebar_widget.py # RightSidebar (icon rail) + _FlyoutPanel + │ ├── left_sidebar.py # LeftSidebar — activity rail + stacked nav flyout pages + │ ├── local_scripts_sidebar_panel.py # Legacy empty shell (unused; MainWindow uses CollectionWidget) + │ ├── snippets_sidebar_panel.py # User snippets tree (language → category → leaf); search + section (i) + │ ├── snippets_tree_constants.py # Tree data roles / node kinds for snippets sidebar + │ ├── snippets_tree_display.py # Row layout + context/count labels for snippets tree + │ ├── snippets_tree_delegate.py # Language/snippet row painting (context tag on leaves) + │ ├── snippets_tree_rename.py # In-place snippet/category rename overlays + │ ├── snippets_tree_context.py # Snippets tree right-click menus (category/snippet CRUD) + │ ├── variables_panel.py # VariablesPanel — read-only variable display + │ ├── snippet_panel.py # SnippetPanel — inline code snippet generator + │ ├── debug_inspector_split.py # DebugInspectorSplit — call stack + watches | scopes (horizontal splitter) + │ ├── debug_scopes_panel.py # DebugScopesPanel — debugScopesTree (locals / pm / globals only) + │ ├── debug_panel.py # DebugPanel facade — DebugControls + DebugInspectorSplit + │ ├── debug_call_stack_panel.py # CallStackPanel — frame list + frame_selected + │ ├── debug_watch_in_tree.py # Watches section rows + format_watch_display / rebuild_watch_rows + │ └── saved_responses/ # Saved responses sub-package + │ ├── panel.py # SavedResponsesPanel — saved example list/detail flyout + │ ├── search_filter.py # _PanelSearchFilterMixin — body search/filter + │ ├── helpers.py # Formatting helpers (body size, language detect, etc.) + │ └── delegate.py # Custom delegate for saved response list items + ├── styling/ # Visual theming and icons + │ ├── theme.py # Palettes, colours, status bar / left-rail chrome, badge/tree geometry, left-nav panel margins, method_color(), status_color() + │ ├── language_icons.py # Brand SVG pixmaps for JS / TS / Python tiles + │ ├── theme_manager.py # ThemeManager — QPalette + QSettings + │ ├── tab_settings_manager.py # TabSettingsManager — request-tab QSettings bridge (preview, limits, activate-on-close, wrap mode) + │ ├── global_qss.py # build_global_qss() — global stylesheet builder + │ └── icons.py # Phosphor font-glyph icon provider (phi()) + ├── widgets/ # Reusable shared components + │ ├── code_editor/ # CodeEditorWidget sub-package + │ │ ├── editor_widget.py # CodeEditorWidget — core + __init__ (mixins below) + │ │ ├── editor_formatting.py # _FormattingMixin — prettify, format-on-idle + │ │ ├── editor_snippets.py # _SnippetMixin — save-as-snippet context menu + │ │ ├── editor_test_gutter.py # _TestGutterMixin — pm.test gutter + │ │ ├── editor_variables.py # _VariableMixin — {{var}} + debug hover + │ │ ├── editor_language.py # _LanguageMixin — set_language + │ │ ├── editor_keyboard.py # _KeyboardMixin — keyPressEvent, line comment + │ │ ├── editor_ident.py # _IdentMixin — identifier at position + │ │ ├── editor_breakpoints.py # _BreakpointMixin — breakpoint gutter + │ │ ├── editor_lsp_glue.py # attach_lsp, finalize_local_script_lsp_attach, detach_lsp, signature/hover glue + │ │ ├── lsp_integration.py # EditorLspAdapter — LSP sync + diagnostics; local-script attach accepts prep= to skip redundant mirror/index + │ │ ├── popup_registry.py # Shared singleton Completion/ParameterHint/SymbolDoc/DebugValue popups + │ │ ├── debug_hover_popup.py # DebugValuePopup — expandable hover for paused script locals + │ │ ├── highlighter.py # Syntax highlighting engine + │ │ ├── folding.py # Code folding logic + │ │ ├── gutter.py # Gutter QWidget delegates + minimap (_MinimapArea); column order in painting.resizeEvent + │ │ ├── painting.py # _PaintingMixin shims → paint_* modules + │ │ ├── paint_breakpoints.py + │ │ ├── paint_diagnostics.py + │ │ ├── paint_inline_logs.py + │ │ ├── paint_test_gutter.py + │ │ └── completion/ # Autocomplete sub-package + │ │ ├── schema/ # Schema sub-package + │ │ │ ├── core.py # SchemaNode TypedDict, expectation chain, shared helpers + │ │ │ ├── js.py # JS_SCHEMA (pm, console, CryptoJS, postman) + JS_GLOBALS + │ │ │ └── py.py # PY_SCHEMA + PY_GLOBALS (Python variant) + │ │ ├── engine.py # CompletionEngine — dot-path, variables, resolve_symbol(), find_definition_pos(), resolve_call_signature(), resolve_nearest_call_signature() + │ │ ├── path_completions/ # pm.require('local:…') + ESM relative import path items + │ │ │ └── items.py + │ │ ├── mixin.py # _CompletionMixin — triggers, filtering, parameter hint + Ctrl+hover symbol doc wiring + │ │ ├── parameter_hint.py # ParameterHintPopup — floating call-signature hint + │ │ ├── popup.py # CompletionPopup — floating autocomplete widget + │ │ └── symbol_doc_popup.py # SymbolDocPopup — Ctrl+hover / Ctrl+Q quick-doc tooltip + │ ├── info_popup.py # InfoPopup (QFrame) base + ClickableLabel + │ ├── sidebar_section_info.py # SidebarSectionInfoPopup — (i) help for sidebar sections + │ ├── sidebar_tree_row_info.py # Trailing row (i) paint/hit-test for local-script tree leaves + │ ├── tree_rename_overlay.py # TreeRenameClickAway — app-wide click-away / Escape for tree rename QLineEdit + │ ├── lazy_editor_placeholder.py # LazyEditorPlaceholder — progress + caption until Body/Scripts editors mount + │ ├── key_value_column_widths.py # QSettings JSON persistence for Key/Value widths + │ ├── key_value_table.py # Reusable key-value editor widget + │ ├── key_value_bulk.py # Bulk text serialize/parse for key-value tables + │ ├── query_string.py # URL query parse/build (raw; no encode/decode) + │ ├── key_value_table_delegate.py # Variable {{…}} highlight delegate for key-value cells + │ ├── search_replace_bar.py # SearchReplaceBar — find/replace + go-to-line for CodeEditorWidget + │ ├── deno_download_worker.py # DenoDownloadWorker — QThread background Deno download (banner + settings) + │ ├── debug_value_tree.py # Debug tree helpers (CLASSNAME_KEY, attach_selectable_cell_widgets, debug_tree_cell_text, fill_tree_item, populate_debug_tree, source_dot_icon, make_debug_value_tree) + │ ├── runtime_banner.py # RuntimeBanner — Deno install/configure prompt for JS editors + │ ├── snippets/ # Script snippet palette (loader + SnippetsPopup) + │ │ ├── loader.py # load_snippets — merges data/snippets/*.json + DB user snippets + │ │ ├── popup.py # SnippetsPopup — search + grouped list; read-only insert (accent user rows) + │ │ └── snippet_capture_dialog.py # Create/edit snippets (delete via sidebar context menu); CodeEditorWidget body + │ ├── variable_line_edit.py # VariableLineEdit — QLineEdit with {{var}} highlighting + hover popup + │ └── variable_popup.py # VariablePopup — singleton hover popup for variable details + ├── collections/ # Collection sidebar + │ ├── collection_header.py + │ ├── collection_widget.py + │ ├── new_item_popup.py # NewItemPopup — Postman-style icon grid popup + │ ├── new_local_script_popup.py # NewLocalScriptItemPopup — Script / Folder tiles + │ └── tree/ # Tree widget sub-package + │ ├── constants.py + │ ├── draggable_tree_widget.py + │ ├── collection_tree.py # CollectionTree widget + │ ├── tree_actions.py # _TreeActionsMixin — context menus, rename, delete + │ ├── tree_overlay_rename.py # _TreeOverlayRenameMixin — overlay rename + click-away + │ └── collection_tree_delegate.py # Custom delegate for method badges + ├── dialogs/ # Modal dialogs + │ ├── collection_runner/ + │ │ ├── __init__.py # Re-exports RunnerConfigView, RunnerResultsView, RunnerWorker + │ │ ├── config.py # RunnerConfigView (env selector, request checklist, data file, iterations, delay) + │ │ ├── results.py # RunnerResultsView (summary + results table + detail panel + export) + │ │ └── worker.py # RunnerWorker (QThread), env var substitution, scripts_enabled (imports parse_data_file from services) + │ ├── import_dialog.py + │ ├── save_request_dialog.py # Save draft request to collection + │ └── settings_dialog.py # Settings (theme + request-tab + Scripting: LSP toggle, Deno/Python paths) + ├── environments/ # Environment management widgets + │ ├── environment_editor.py # EnvironmentEditorWidget + EnvironmentEditorDialog + │ ├── environment_selector.py + │ └── environment_sidebar_panel.py + ├── panels/ # Bottom / side panels + │ ├── console_panel.py + │ └── history_panel.py + └── request/ # Request/response editing + ├── folder_editor/ # Folder/collection detail editor sub-package + │ ├── editor_widget.py # FolderEditorWidget — main editor class + │ ├── runner_panel.py # _RunnerPanel — inline collection runner (Runs -> New run) + │ └── runs.py # _RunsMixin + _build_runs_table (run history table) + ├── http_worker.py # HttpSendWorker + SchemaFetchWorker (QThread) + ├── auth/ # Shared auth sub-package (14 auth types) + │ ├── auth_field_specs.py # Per-type FieldSpec definitions (AUTH_FIELD_SPECS) + │ ├── auth_mixin.py # _AuthMixin — shared by both editors + │ ├── auth_pages.py # FieldSpec dataclass, page builders, auth constants + │ ├── auth_serializer.py # Generic load/save for all auth types + │ └── oauth2_page.py # OAuth 2.0 custom page (grant-type switching) + ├── request_editor/ # RequestEditor sub-package + │ ├── editor_widget.py # RequestEditor — main request editing widget + │ ├── auth.py # Re-export of _AuthMixin from auth sub-package + │ ├── body_search.py # _BodySearchMixin — search/replace in body + │ ├── graphql.py # _GraphQLMixin — GraphQL mode + schema + │ ├── assertions/ # Declarative assertions sub-package + │ │ ├── assertions_guide.py # AssertionsHelpDialog + How this works button + │ │ ├── assertions_tab.py # AssertionsTab — subject/operator/expected rows + guide + │ │ └── assertions_mixin.py # _AssertionsMixin — lazy tab + AssertionService persistence + │ ├── data_runner/ # Inline data-driven script runner (D3) + │ │ └── panel.py # DataRunnerPanel — CSV/JSON picker + Run iterations + │ └── scripts/ # Scripts sub-package + │ ├── script_language.py # codes: javascript | typescript | python; detect/heuristics, display, normalise + │ ├── script_editor_pane/ # ScriptEditorPane — reusable toolbar + editor + output stack + │ ├── debug_metadata_persist.py # _DebugMetadataPersistMixin — debounced scripts.debug DB + draft session + │ ├── scripts_mixin.py # _ScriptsMixin — dual pre-request/test script editors (delegates to panes) + │ ├── mock_response_tab.py # ScriptMockResponseTab — mock status + headers table + JSON CodeEditorWidget body (post-response) + │ ├── output_panel.py # ScriptOutputPanel — orchestration + worker slot shims + │ ├── output_panel_build.py # Tab/layout construction + │ ├── output_console_tab.py # Console rows + inline_log_annotations_from_console_logs + │ ├── output_variable_section.py + │ ├── output_test_results_tab.py + │ ├── output_debug_bar.py + │ ├── output_script_runner.py # run_script / debug worker wiring + │ ├── output_iterations_tab.py # ScriptOutputIterationsTab — iteration×test matrix + re-run failed + │ ├── lsp_problems_tab.py # ScriptLspProblemsTab — LSP + ``[local:…]`` dependency rows; click opens local script tab + │ ├── local_dependency_warn.py # Warn-only Send/Run when direct local: dependencies have errors + │ ├── script_run_worker.py # ScriptRunWorker — inline runs; ``iteration_finished`` for data-driven matrix + │ ├── version_history.py # _show_version_history entry point + │ └── version_history/ # Version history dialog sub-package + │ ├── delegate.py # _VersionItemDelegate — two-line list item rendering + │ ├── dialog.py # VersionHistoryDialog — timeline + side-by-side diff + │ ├── diff_viewer.py # _DiffViewer — dual-editor diff with folding + │ ├── helpers.py # Diff formatting, fold ranges, timestamp helpers + │ └── toolbar.py # _DiffToolbar — search, nav, whitespace, copy + ├── response_viewer/ # ResponseViewer sub-package + │ ├── viewer_widget.py # ResponseViewer — response display widget + │ ├── search_filter.py # _SearchFilterMixin — response search/filter + │ ├── test_results_mixin.py # _TestResultsMixin — test results tab + │ └── pre_request_mixin.py # _PreRequestMixin — pre-request script output tab + ├── navigation/ # Tab switching and path navigation + │ ├── breadcrumb_bar.py + │ ├── request_tab_bar.py # Compatibility wrapper re-exporting the wrapped deck + │ ├── request_tabs/ # Wrapped multi-row request tab deck sub-package + │ │ ├── __init__.py + │ │ ├── bar.py # RequestTabBar custom wrapped-row deck + │ │ ├── labels.py # TabLabel / FolderTabLabel chip content widgets + │ │ └── tab_button.py # TabButton chip with close + reorder interactions + │ └── tab_manager.py # TabManager + TabContext (nav_token, is_debugging, local_overrides, draft_name) + └── popups/ # Response metadata popups + ├── status_popup.py # HTTP status code explanation + ├── timing_popup.py # Request timing breakdown + ├── size_popup.py # Response/request size breakdown + └── network_popup.py # Network/TLS connection details +tests/ +├── conftest.py # Autouse fresh-DB fixture + qapp fixture + tab-settings reset +├── unit/ # Repository & service layer tests +│ ├── database/ # Repository tests +│ │ ├── test_repository.py +│ │ ├── test_local_script_repository.py +│ │ ├── test_local_script_path_policy.py +│ │ ├── test_local_script_require_refs.py +│ │ ├── test_request_assertion_repository.py +│ │ ├── test_script_version_local_script.py +│ │ ├── test_environment_repository.py +│ │ └── test_run_history_repository.py +│ └── services/ # Service layer tests +│ ├── test_service.py +│ ├── test_environment_service.py +│ ├── test_import_parser.py +│ ├── test_import_service.py +│ ├── test_script_bridge_globals.py +│ ├── test_script_debug.py +│ ├── test_script_debug_cdp.py +│ ├── test_script_engine.py +│ ├── test_pm_api_schema_drift.py +│ ├── test_script_linter.py +│ ├── test_script_sandbox.py +│ ├── test_script_service.py +│ ├── test_script_vendor.py +│ ├── test_script_vendor_libs.py +│ ├── test_data_loader.py +│ ├── test_script_run_worker_iterations.py +│ ├── test_script_version_service.py +│ ├── test_assertions_compiler.py +│ ├── test_deno_manager.py +│ ├── test_runtime_settings.py +│ └── http/ # HTTP service tests +│ ├── test_http_service.py +│ ├── test_graphql_schema_service.py +│ ├── test_snippet_generator.py +│ ├── test_snippet_shell.py +│ ├── test_snippet_dynamic.py +│ ├── test_snippet_compiled.py +│ ├── test_auth_handler.py +│ └── test_oauth2_service.py +└── ui/ # End-to-end PySide6 widget tests + ├── conftest.py # _no_fetch (autouse) + helpers + ├── test_main_window.py + ├── test_main_window_tabs_navigation.py # Wrapped tab deck shortcuts + search tests + ├── test_main_window_tab_nav_history.py # Go menu tab activation back/forward + ├── test_main_window_save.py # SaveButton + RequestSaveEndToEnd tests + ├── test_main_window_draft.py # Draft tab open/save lifecycle tests + ├── test_main_window_session.py # Tab session persistence (save/restore) tests + ├── styling/ # Theme and icon tests + │ ├── test_theme_manager.py + │ └── test_icons.py + ├── sidebar/ # Sidebar widget tests + │ ├── test_sidebar.py + │ ├── test_left_sidebar.py + │ ├── test_variables_panel.py + │ ├── test_snippet_panel.py + │ ├── test_debug_panel.py + │ └── test_saved_responses_panel.py + ├── widgets/ # Shared component tests + │ ├── test_code_editor.py + │ ├── test_code_editor_folding.py + │ ├── test_code_editor_painting.py + │ ├── test_code_editor_memory.py + │ ├── test_code_editor_minimap.py + │ ├── test_code_editor_variables.py + │ ├── test_completion_engine.py + │ ├── test_completion_engine_top_level.py + │ ├── test_completion_engine_local_paths.py + │ ├── test_esm_import_completion_accept.py + │ ├── test_lsp_diagnostic_debounce.py + │ ├── test_no_debug_on_keystroke.py + │ ├── test_completion_popup.py + │ ├── test_info_popup.py + │ ├── test_key_value_table.py + │ ├── test_variable_line_edit.py + │ ├── test_variable_popup.py + │ ├── test_variable_popup_local.py + │ ├── test_search_replace_bar.py + │ └── test_runtime_banner.py + ├── collections/ # Collection sidebar tests + │ ├── test_collection_header.py + │ ├── test_collection_tree.py + │ ├── test_collection_tree_actions.py + │ ├── test_collection_tree_delegate.py + │ ├── test_collection_widget.py + │ ├── test_new_item_popup.py + │ └── test_new_local_script_popup.py + ├── dialogs/ # Dialog tests + │ ├── test_collection_runner.py + │ ├── test_import_dialog.py + │ ├── test_save_request_dialog.py + │ └── test_settings_dialog.py + ├── environments/ # Environment widget tests + │ ├── test_environment_editor.py + │ ├── test_environment_selector.py + │ └── test_environment_sidebar_panel.py + ├── panels/ # Panel tests + │ ├── test_console_panel.py + │ └── test_history_panel.py + └── request/ # Request/response editing tests + ├── conftest.py # make_request_dict fixture factory + ├── test_folder_editor.py + ├── test_folder_editor_scripts.py + ├── test_runner_panel.py + ├── test_http_worker.py + ├── test_request_editor.py + ├── test_request_editor_auth.py + ├── test_request_editor_binary.py + ├── test_request_editor_graphql.py + ├── test_request_editor_search.py + ├── test_response_viewer.py + ├── test_response_viewer_search.py + ├── test_response_viewer_tests.py + ├── test_version_history.py + ├── test_script_output_panel.py + ├── test_script_lsp_problems_tab.py + ├── navigation/ # Tab and breadcrumb tests + │ ├── test_breadcrumb_bar.py + │ ├── test_request_tab_bar.py + │ └── test_tab_manager.py + └── popups/ # Response popup tests + ├── test_status_popup.py + ├── test_timing_popup.py + ├── test_size_popup.py + └── test_network_popup.py +``` + +**Layering:** UI → signals → Service → Repository → `get_session()`. +UI must never import from `database/`. + +## CRITICAL — Verify after every change + +After **any** code change, run the **full** validation suite and confirm +**zero failures** before considering the task complete: + +```bash +poetry run pytest # all tests must pass +poetry run ruff check src/ tests/ # linter clean +poetry run ruff format --check src/ tests/ # formatter clean +poetry run mypy src/ tests/ # type checker clean +``` + +> **ZERO tolerance for errors — including pre-existing ones.** +> Every command above must exit with **zero** errors, warnings, or +> suggestions. If you find a pre-existing error (lint, type, format, +> test failure) while working on an unrelated task, **fix it immediately** +> in the same change. "It was already broken" is never an acceptable +> excuse — fix it anyway. All four commands passing clean is a hard gate +> on every change. No exceptions. + +**NEVER use `--fix` or auto-format as a substitute for the checks above.** +Always run the check-only commands first. If they fail, fix the code +manually (or with `--fix`), then **re-run the check-only commands** and +confirm they pass. The goal is to surface every issue visibly — a silent +auto-fix that is never re-verified can leave the working tree clean while +the staged/committed version is still broken. + +After **any** documentation change (`.md` files, instruction files, README), +run the markdown link checker and confirm **zero broken links**: + +```bash +python scripts/check_md_links.py +``` + +Never skip a layer — repository, service, UI, and MainWindow tests all +must stay green. See [`tests/AGENTS.md`](tests/AGENTS.md) for detailed conventions. + +## Coding conventions + +- `from __future__ import annotations` in **every** module. +- `X | None`, not `Optional[X]`. +- Ruff is the linter **and** formatter (config in `pyproject.toml`). + First-party packages for isort: `database`, `ui`, `services`. +- Named constants over magic numbers. +- `init_db()` must be called before any DB access (app startup and test fixture). +- Every module, class, and public function must have a docstring. +- All hex colour values belong in `src/ui/styling/theme.py` -- never inline. +- Use `TypedDict` for dict schemas that cross module boundaries. +- No emoji in code comments -- use plain numbered steps (e.g. `# 1.`). +- **Directory file limit:** No directory may contain more than 5 `.py` files + (excluding `__init__.py`). When a directory reaches this limit, group + related files into a sub-package before adding more. Test directories + mirror the source tree; test file count may exceed 5 when multiple test + files cover a single source module. +- **File line limit:** No single `.py` file may exceed **600 lines** + (including docstrings and comments). When a file approaches this limit, + extract cohesive groups of methods, helper classes, or setup logic into + a sub-package. Re-export public symbols from the package's `__init__.py` + so external imports remain stable. Test files follow the same limit — + split by test class into separate files mirroring the sub-package. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad65abe --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: stubs stubs-check test + +# Regenerate `pm.d.ts` and `pm.pyi` from `src/services/scripting/pm_api_schema.py`. +stubs: + PYTHONPATH=src .venv/bin/python -m services.lsp.stubs_generator + +# CI guard: regenerate and fail if committed stubs are stale. +stubs-check: stubs + git diff --exit-code data/lsp/stubs/ + +test: + .venv/bin/python -m pytest diff --git a/README.md b/README.md index 2516a17..d0aba3d 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,105 @@ <img src="data/images/logo.png" alt="Postmark logo" width="200" /> </p> -# Postmark +<h1 align="center">Postmark</h1> -A native desktop API client for testing and managing HTTP requests, built with **PySide6** and **SQLAlchemy**. +<p align="center"> + A local-first desktop API client with a Postman-compatible scripting engine.<br/> + Design, test, and automate HTTP requests — built with <strong>PySide6</strong> and <strong>SQLAlchemy</strong>. +</p> + +--- + +Postmark is a native desktop API client for testing and managing HTTP requests. Organise +requests into collections, drive them with a full **JavaScript/TypeScript and Python** +scripting engine, and write your tests with **IDE-grade code intelligence** — autocomplete, +inline diagnostics, a step-through debugger, and a side-by-side version diff. Existing +Postman scripts run unmodified through a comprehensive, sandboxed `pm.*` API. + +## Scripting at a glance + +Write pre-request and post-response (test) scripts in JavaScript/TypeScript or Python — the +same `pm.*` API in both: + +```js +// Post-response (test) script — JavaScript +pm.test("status is 200", () => { + pm.expect(pm.response.code).to.equal(200); +}); + +const body = pm.response.json(); +pm.expect(body).to.have.property("id"); +pm.environment.set("token", body.token); // reuse in the next request +``` + +```python +# Post-response script — Python (Pyodide), same pm.* API in snake_case +pm.test("status is 200", lambda: pm.expect(pm.response.code).to.equal(200)) +pm.environment.set("token", pm.response.json()["token"]) +``` ## Features -- Organise requests into nested collections (folders) -- Drag-and-drop to rearrange collections and requests -- In-place rename with rollback on failure -- Background data loading (non-blocking UI) -- SQLite persistence via SQLAlchemy -- **Theme support** — automatic OS dark/light mode detection, with manual override -- **Settings dialog** — choose between Fusion (default) and native OS widget style -- Import from Postman collections, cURL commands, or raw URLs -- Environment variables with key-value editor and `{{var}}` substitution +### Requests & Collections +- Organise requests into nested collections (folders), with drag-and-drop reordering and in-place rename (rollback on failure) +- Import from **Postman collections, cURL commands, or raw URLs** - **GraphQL support** — schema introspection, syntax highlighting, and prettify -- **Code editor** — syntax highlighting, code folding, line numbers, bracket matching -- Code snippet generation (cURL, Python, JavaScript) -- Tabbed request editing with breadcrumb navigation -- Response viewer with search, JSONPath/XPath filtering, and beautify -- Response metadata popups (status, timing, size, network/TLS details) -- Console and history panels +- Tabbed request editing with breadcrumb navigation and back/forward **tab history** +- Response viewer with search, **JSONPath/XPath filtering**, and beautify; metadata popups for status, timing, size, and network/TLS details +- Generate request code in **cURL and 20+ languages** +- **Collection runner** — run every request under a folder in sequence with per-request test results, **data-driven CSV/JSON iterations**, flow control (`setNextRequest`/`skipRequest`), and result export (JSON / JUnit XML) +- Background, non-blocking data loading with SQLite persistence via SQLAlchemy + +### Scripting & Automation +- **JavaScript & TypeScript** scripts run in a sandboxed **Deno** subprocess; **Python** scripts run on a bundled **Pyodide** (WASM) runtime +- **Postman-compatible `pm.*` API** (`pm.environment`, `pm.globals`, `pm.collectionVariables`, `pm.request`, `pm.response`, `pm.test`, `pm.expect`, `pm.sendRequest`, `pm.cookies`, `pm.iterationData`, …) — paste Postman scripts and run them as-is +- **Chai-style assertions** via `pm.test()` + `pm.expect(...)`, plus a no-code **Assertions tab** for response checks without writing code +- **Script inheritance** — scripts cascade collection → folder → request (pre-request top-down, tests bottom-up) +- **Step-through debugger** — breakpoints (including conditional), step over/into/out, call stack, variable & watch inspector, and break-on-exception; breakpoints persist per request +- **Real HTTP from scripts** via `pm.sendRequest` (host-executed and rate-limited) for fetching tokens or chaining calls +- **Postman dynamic variables** — `{{$guid}}`, `{{$randomInt}}`, `{{$isoTimestamp}}`, and many more +- **Defense-in-depth sandbox** — scripts run with no filesystem, network, or OS access (network only through `pm.sendRequest`), bounded by per-run time and memory limits + +### Code Intelligence +- Real **language servers** back the script editors — **Deno** for JavaScript/TypeScript, **jedi** for Python +- **IntelliSense autocomplete** that merges the `pm.*` API with members of the packages and local modules you import +- **Live diagnostics** in a dedicated Problems panel, hover documentation, signature/parameter hints, and **go-to-definition** +- **Format-on-save** (`deno fmt` / Ruff) and inline validation that flags unsupported `pm`/`postman` usage and ESM↔CommonJS mismatches before you run + +### Packages & Libraries +- Bundled, **offline `require()` libraries** in JavaScript — lodash, moment, CryptoJS, Chai, tv4, Ajv, xml2js, and csv-parse +- **External packages on demand** via `pm.require` — `npm:` and `jsr:` specifiers in JavaScript (resolved by Deno) and **PyPI** packages in Python (via micropip), cached after first fetch +- **Private / self-hosted registries** with per-scope auth; credentials stored in the OS keychain (with an encrypted-file fallback), never in plain settings + +### Snippets & Local Scripts +- **In-editor Snippets palette** — a searchable popover that inserts ready-made `pm.*` boilerplate at the cursor, filtered to the current pre-request vs. test context +- **Personal snippets** you create, edit, rename, and organise by category from the sidebar — or **"Save selection as snippet"** straight from the editor +- **Local scripts** — a sidebar tree of standalone, reusable script files (JavaScript, TypeScript, Python) that open as full editors with Run, Debug, and Problems +- Call local modules from any script via **`pm.require("local:…")`**, import/export between files like a small TypeScript project, with **safe rename/move** that auto-rewrites references everywhere + +### Environments & Secrets +- Environment variables with a key-value editor and **`{{var}}` substitution** +- **Inline environment switching** in the sidebar — set active, clear, or open the full environment editor without leaving your collections +- **Encrypted credential storage** for private package registries (OS keychain or encrypted file); secrets are resolved only at run time and never written to plain settings + +### History & Versioning +- **Automatic script version history** — snapshots saved as you edit, with a searchable timeline and one-click restore +- **Side-by-side diff viewer** — two-column, syntax-highlighted diffs with intra-line change marking, change navigation, and whitespace-aware comparison +- **Collection run history** — per-run totals (pass/fail/skip, duration, average response time) with a per-request breakdown + +### Workspace & UI +- **VS Code-style left activity rail** with collapsible flyout pages: **Collections & Environments** and **Local scripts & snippets** +- **Bulk key-value editing** — paste many params/headers as one-row-per-line text (`key: value`); prefix a line with `//` to keep but disable it +- Resizable key-value columns with inline `{{variable}}` highlighting (distinct colour for unresolved variables) +- **Theme support** — automatic OS dark/light detection with manual override — plus Fusion or native widget style and Hi-DPI scaling ## Prerequisites -- Python 3.12+ -- [Poetry](https://python-poetry.org/) for dependency management +- **Python 3.12+** +- [**Poetry**](https://python-poetry.org/) for dependency management + +> JavaScript scripting runs on **Deno** and Python scripting on a bundled **Pyodide** runtime. +> See [Scripting → Overview](docs/scripting/overview.md) for runtime setup and configuration. ## Setup @@ -69,4 +141,18 @@ poetry run pytest ## Architecture -`src/` is organized into three layers: `database/` (SQLAlchemy models and repositories), `services/` (business logic bridging UI and DB), and `ui/` (PySide6 widgets). Tests in `tests/` mirror the source tree. See [`.github/copilot-instructions.md`](.github/copilot-instructions.md) for the full architecture tree and coding conventions. +`src/` is organised into three layers: **`database/`** (SQLAlchemy models and repositories), +**`services/`** (business logic bridging UI and DB), and **`ui/`** (PySide6 widgets). Tests in +`tests/` mirror the source tree. See [`AGENTS.md`](AGENTS.md) for the full architecture tree and +coding conventions, and [docs/architecture/overview.md](docs/architecture/overview.md) for a +narrative walkthrough (including the [script runtime](docs/architecture/script-runtime.md)). + +## Documentation + +Full documentation lives under [`docs/`](docs/README.md): + +- **Getting started** — [overview](docs/getting-started/overview.md) · [installation](docs/getting-started/installation.md) · [running](docs/getting-started/running.md) +- **Scripting** — [overview](docs/scripting/overview.md) · [JavaScript API](docs/scripting/javascript-api.md) · [Python API](docs/scripting/python-api.md) · [Postman parity](docs/scripting/postman-parity.md) · [examples](docs/scripting/examples.md) +- **Packages & modules** — [external packages](docs/scripting/external-packages.md) · [local modules](docs/scripting/local-modules.md) · [snippets](docs/scripting/snippets.md) · [security](docs/scripting/security.md) +- **Runner & UI** — [collection runner](docs/scripting/collection-runner.md) · [request editor](docs/ui-reference/request-editor.md) · [sidebar](docs/ui-reference/sidebar.md) · [local scripts](docs/ui-reference/local-scripts.md) +- **Contributing** — [writing scripts](docs/guides/writing-scripts.md) · [writing tests](docs/guides/writing-tests.md) · [adding a script language](docs/guides/adding-script-language.md) diff --git a/data/images/languages/README.md b/data/images/languages/README.md new file mode 100644 index 0000000..9739871 --- /dev/null +++ b/data/images/languages/README.md @@ -0,0 +1,17 @@ +# Script language icons + +SVG logos for JavaScript, TypeScript, and Python used in the local-scripts tree, +tabs, and **Create New** dialog. + +TypeScript and Python logos are from [Simple Icons](https://simpleicons.org/) (CC0). +The JavaScript logo uses Devicon two-tone artwork so the mark stays visible on light +backgrounds (Simple Icons uses a single fill with cut-out letters). + +| File | Brand | Source | +|------|-------|--------| +| `javascript.svg` | JavaScript (yellow tile + dark mark) | [Devicon](https://github.com/devicons/devicon) MIT | +| `typescript.svg` | TypeScript | [Simple Icons](https://simpleicons.org/) CC0 | +| `python.svg` | Python | [Simple Icons](https://simpleicons.org/) CC0 | + +Colors match each project’s usual brand palette. Icons are rendered with a small +inner margin so they stay legible at 16px in the sidebar tree. diff --git a/data/images/languages/javascript.svg b/data/images/languages/javascript.svg new file mode 100644 index 0000000..ec7681a --- /dev/null +++ b/data/images/languages/javascript.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="JavaScript"> + <path fill="#F0DB4F" d="M1.408 1.408h125.184v125.185H1.408z"/> + <path fill="#323330" d="M116.347 96.736c-.917-5.711-4.641-10.508-15.672-14.981-3.832-1.761-8.104-3.022-9.377-5.926-.452-1.69-.512-2.642-.226-3.665.821-3.32 4.784-4.355 7.925-3.403 2.023.678 3.938 2.237 5.093 4.724 5.402-3.498 5.391-3.475 9.163-5.879-1.381-2.141-2.118-3.129-3.022-4.045-3.249-3.629-7.676-5.498-14.756-5.355l-3.688.477c-3.534.893-6.902 2.748-8.877 5.235-5.926 6.724-4.236 18.492 2.975 23.335 7.104 5.332 17.54 6.545 18.873 11.531 1.297 6.104-4.486 8.08-10.234 7.378-4.236-.881-6.592-3.034-9.139-6.949-4.688 2.713-4.688 2.713-9.508 5.485 1.143 2.499 2.344 3.63 4.26 5.795 9.068 9.198 31.76 8.746 35.83-5.176.165-.478 1.261-3.666.38-8.581zM69.462 58.943H57.753l-.048 30.272c0 6.438.333 12.34-.714 14.149-1.713 3.558-6.152 3.117-8.175 2.427-2.059-1.012-3.106-2.451-4.319-4.485-.333-.584-.583-1.036-.667-1.071l-9.52 5.83c1.583 3.249 3.915 6.069 6.902 7.901 4.462 2.678 10.459 3.499 16.731 2.059 4.082-1.189 7.604-3.652 9.448-7.401 2.666-4.915 2.094-10.864 2.07-17.444.06-10.735.001-21.468.001-32.237z"/> +</svg> diff --git a/data/images/languages/python.svg b/data/images/languages/python.svg new file mode 100644 index 0000000..e1b14ec --- /dev/null +++ b/data/images/languages/python.svg @@ -0,0 +1 @@ +<svg fill="#3776AB" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Python \ No newline at end of file diff --git a/data/images/languages/typescript.svg b/data/images/languages/typescript.svg new file mode 100644 index 0000000..688ddcc --- /dev/null +++ b/data/images/languages/typescript.svg @@ -0,0 +1 @@ +TypeScript \ No newline at end of file diff --git a/data/lsp/stubs/pm.d.ts b/data/lsp/stubs/pm.d.ts new file mode 100644 index 0000000..3f73c3c --- /dev/null +++ b/data/lsp/stubs/pm.d.ts @@ -0,0 +1,127 @@ +/** Generated by services.lsp.stubs_generator — do not edit. */ + +declare namespace pm { + namespace collectionVariables { + /** `clear` */ + function clear(...args: any[]): any; + /** `get` */ + function get(...args: any[]): any; + /** `has` */ + function has(...args: any[]): any; + /** `replaceIn` */ + function replaceIn(...args: any[]): any; + /** `set` */ + function set(...args: any[]): any; + /** `toObject` */ + function toObject(...args: any[]): any; + /** `unset` */ + function unset(...args: any[]): any; + } + namespace cookies { + /** `get` */ + function get(...args: any[]): any; + /** `getAll` */ + function getAll(...args: any[]): any; + /** `jar` */ + function jar(...args: any[]): any; + } + namespace environment { + /** `clear` */ + function clear(...args: any[]): any; + /** `get` */ + function get(...args: any[]): any; + /** `has` */ + function has(...args: any[]): any; + /** `replaceIn` */ + function replaceIn(...args: any[]): any; + /** `set` */ + function set(...args: any[]): any; + /** `toObject` */ + function toObject(...args: any[]): any; + /** `unset` */ + function unset(...args: any[]): any; + } + namespace execution { + /** free-form `location` */ + const location: any; + /** `setNextRequest` */ + function setNextRequest(...args: any[]): any; + /** `skipRequest` */ + function skipRequest(...args: any[]): any; + } + /** `expect` */ + function expect(...args: any[]): any; + namespace globals { + /** `clear` */ + function clear(...args: any[]): any; + /** `get` */ + function get(...args: any[]): any; + /** `has` */ + function has(...args: any[]): any; + /** `replaceIn` */ + function replaceIn(...args: any[]): any; + /** `set` */ + function set(...args: any[]): any; + /** `toObject` */ + function toObject(...args: any[]): any; + /** `unset` */ + function unset(...args: any[]): any; + } + /** free-form `info` */ + const info: any; + namespace iterationData { + /** `get` */ + function get(...args: any[]): any; + /** `has` */ + function has(...args: any[]): any; + /** `toObject` */ + function toObject(...args: any[]): any; + } + /** free-form `request` */ + const request: any; + /** `require` */ + /** Fallback when specifier is not in pm_require_index.ts */ + function require(spec: string): unknown; + /** free-form `response` */ + const response: any; + /** `sendRequest` */ + function sendRequest(...args: any[]): any; + /** `test` */ + function test(...args: any[]): any; + namespace variables { + /** `clear` */ + function clear(...args: any[]): any; + /** `get` */ + function get(...args: any[]): any; + /** `has` */ + function has(...args: any[]): any; + /** `replaceIn` */ + function replaceIn(...args: any[]): any; + /** `set` */ + function set(...args: any[]): any; + /** `toObject` */ + function toObject(...args: any[]): any; + /** `unset` */ + function unset(...args: any[]): any; + } + namespace visualizer { + /** `set` */ + function set(...args: any[]): any; + } +} + +declare namespace postman { + /** `clearEnvironmentVariable` */ + function clearEnvironmentVariable(...args: any[]): any; + /** `clearGlobalVariable` */ + function clearGlobalVariable(...args: any[]): any; + /** `getEnvironmentVariable` */ + function getEnvironmentVariable(...args: any[]): any; + /** `getGlobalVariable` */ + function getGlobalVariable(...args: any[]): any; + /** `setEnvironmentVariable` */ + function setEnvironmentVariable(...args: any[]): any; + /** `setGlobalVariable` */ + function setGlobalVariable(...args: any[]): any; +} + diff --git a/data/lsp/stubs/pm.pyi b/data/lsp/stubs/pm.pyi new file mode 100644 index 0000000..2fef7ae --- /dev/null +++ b/data/lsp/stubs/pm.pyi @@ -0,0 +1,51 @@ +"""Generated by services.lsp.stubs_generator — do not edit.""" +from typing import Any + +class _Pm: + def clear(self, *args: Any, **kwargs: Any) -> Any: ... + def get(self, *args: Any, **kwargs: Any) -> Any: ... + def has(self, *args: Any, **kwargs: Any) -> Any: ... + def replaceIn(self, *args: Any, **kwargs: Any) -> Any: ... + def set(self, *args: Any, **kwargs: Any) -> Any: ... + def toObject(self, *args: Any, **kwargs: Any) -> Any: ... + def unset(self, *args: Any, **kwargs: Any) -> Any: ... + def get(self, *args: Any, **kwargs: Any) -> Any: ... + def getAll(self, *args: Any, **kwargs: Any) -> Any: ... + def jar(self, *args: Any, **kwargs: Any) -> Any: ... + def clear(self, *args: Any, **kwargs: Any) -> Any: ... + def get(self, *args: Any, **kwargs: Any) -> Any: ... + def has(self, *args: Any, **kwargs: Any) -> Any: ... + def replaceIn(self, *args: Any, **kwargs: Any) -> Any: ... + def set(self, *args: Any, **kwargs: Any) -> Any: ... + def toObject(self, *args: Any, **kwargs: Any) -> Any: ... + def unset(self, *args: Any, **kwargs: Any) -> Any: ... + location: Any + def setNextRequest(self, *args: Any, **kwargs: Any) -> Any: ... + def skipRequest(self, *args: Any, **kwargs: Any) -> Any: ... + def expect(self, *args: Any, **kwargs: Any) -> Any: ... + def clear(self, *args: Any, **kwargs: Any) -> Any: ... + def get(self, *args: Any, **kwargs: Any) -> Any: ... + def has(self, *args: Any, **kwargs: Any) -> Any: ... + def replaceIn(self, *args: Any, **kwargs: Any) -> Any: ... + def set(self, *args: Any, **kwargs: Any) -> Any: ... + def toObject(self, *args: Any, **kwargs: Any) -> Any: ... + def unset(self, *args: Any, **kwargs: Any) -> Any: ... + info: Any + def get(self, *args: Any, **kwargs: Any) -> Any: ... + def has(self, *args: Any, **kwargs: Any) -> Any: ... + def toObject(self, *args: Any, **kwargs: Any) -> Any: ... + request: Any + def require(self, *args: Any, **kwargs: Any) -> Any: ... + response: Any + def sendRequest(self, *args: Any, **kwargs: Any) -> Any: ... + def test(self, *args: Any, **kwargs: Any) -> Any: ... + def clear(self, *args: Any, **kwargs: Any) -> Any: ... + def get(self, *args: Any, **kwargs: Any) -> Any: ... + def has(self, *args: Any, **kwargs: Any) -> Any: ... + def replaceIn(self, *args: Any, **kwargs: Any) -> Any: ... + def set(self, *args: Any, **kwargs: Any) -> Any: ... + def toObject(self, *args: Any, **kwargs: Any) -> Any: ... + def unset(self, *args: Any, **kwargs: Any) -> Any: ... + def set(self, *args: Any, **kwargs: Any) -> Any: ... + +pm: _Pm diff --git a/data/lsp/workspace/js/tsconfig.json b/data/lsp/workspace/js/tsconfig.json new file mode 100644 index 0000000..3384de1 --- /dev/null +++ b/data/lsp/workspace/js/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": false, + "allowJs": true, + "checkJs": true, + "lib": ["esnext"], + "types": [] + }, + "include": ["**/*.ts", "**/*.js", "stubs/pm.d.ts"] +} diff --git a/data/scripts/deno_drain.mjs b/data/scripts/deno_drain.mjs new file mode 100644 index 0000000..619e233 --- /dev/null +++ b/data/scripts/deno_drain.mjs @@ -0,0 +1,106 @@ +// Appended to the end of a bundled user script; runs only under Deno. +// The bundle must begin with: import { readSync, writeSync } from "node:fs"; +// (see deno_runtime._NODE_FS_IMPORT). Deno 2.x no longer has Deno.writeSync / Deno.readSync. +// Drains `__pm_state._send_queue` via line JSON IPC to the host (see `deno_runtime.py`), +// then prints a final `__done__` line matching the MiniRacer extraction shape. +await (async function __denoIpcDrain() { + var _MAX_R = 20, _MAX_T = 50; + var enc = new TextEncoder(); + var total = 0; + for (var r = 0; r < _MAX_R; r++) { + var q = __pm_state._send_queue; + if (!q || q.length === 0) { break; } + __pm_state._send_queue = []; + for (var i = 0; i < q.length; i++) { + total += 1; + if (total > _MAX_T) { + __pm_state.console_logs.push({ + level: "error", + message: "[Script] pm.sendRequest total limit exceeded", + timestamp: Date.now() / 1000, + }); + await __printDone(); + return; + } + var item = q[i] || {}; + var spec = item.spec || {}; + var idx = parseInt(item.callbackIndex, 10) | 0; + var u = (spec && spec.url) ? String(spec.url) : ""; + var m = (spec && spec.method) ? String(spec.method) : "GET"; + var logMsg = JSON.stringify("[Script] pm.sendRequest(\"" + m + " " + u + "\")"); + __pm_state.console_logs.push({ + level: "log", + message: JSON.parse(logMsg), + timestamp: Date.now() / 1000, + }); + var out = JSON.stringify({ __ipc__: "sendRequest", spec: spec, callbackIndex: idx }) + + "\n"; + writeSync(1, enc.encode(out)); + var line = _readLineSyncDeno0(); + if (line == null) { + return; + } + var resp; + try { resp = JSON.parse(line); } catch (_e) { return; } + if (typeof __pm_fulfill_send === "function") { + __pm_fulfill_send(idx, resp); + } + } + } + var pending = __pm_state._pending_tests || []; + if (pending.length > 0) { + await Promise.allSettled( + pending.map(function (p) { return p.promise; }) + ); + } + await __printDone(); + + function _readLineSyncDeno0() { + const parts = []; + const u8 = new Uint8Array(1); + while (true) { + var n; + try { + n = readSync(0, u8); + } catch (_e) { + return null; + } + if (n === 0) { return null; } + if (u8[0] === 10) { break; } // \n + if (u8[0] === 13) { continue; } // \r + parts.push(String.fromCharCode(u8[0])); + } + return parts.join(""); + } + + async function __printDone() { + var legacy = (typeof globalThis !== "undefined" && globalThis.tests) || {}; + var existing = {}; + for (var ti = 0; ti < __pm_state.test_results.length; ti++) { + existing[__pm_state.test_results[ti].name] = true; + } + for (var k in legacy) { + if (!Object.prototype.hasOwnProperty.call(legacy, k)) { continue; } + if (existing[k]) { continue; } + __pm_state.test_results.push({ + name: String(k), + passed: !!legacy[k], + error: null, + duration_ms: 0, + }); + } + const o = { + __done__: true, + test_results: __pm_state.test_results, + console_logs: __pm_state.console_logs, + variable_changes: __pm_state.variable_changes, + request_mutations: __pm_state.request_mutations, + next_request: __pm_state.next_request, + skip_request: __pm_state.skip_request, + }; + if (__pm_state.global_variable_changes) { + o.global_variable_changes = __pm_state.global_variable_changes; + } + writeSync(1, enc.encode(JSON.stringify(o) + "\n")); + } +})(); diff --git a/data/scripts/dynamic_variables.json b/data/scripts/dynamic_variables.json new file mode 100644 index 0000000..24847ee --- /dev/null +++ b/data/scripts/dynamic_variables.json @@ -0,0 +1,870 @@ +{ + "pools": { + "firstNames": [ + "Alice", + "Bob", + "Carol", + "David", + "Eve", + "Frank", + "Grace", + "Henry", + "Ivy", + "Jack", + "Kate", + "Leo", + "Mia", + "Noah", + "Olivia", + "Paul", + "Quinn", + "Rose", + "Sam", + "Tina", + "Uma", + "Victor", + "Wendy", + "Xander", + "Yara", + "Zoe" + ], + "lastNames": [ + "Smith", + "Johnson", + "Williams", + "Brown", + "Jones", + "Garcia", + "Miller", + "Davis", + "Rodriguez", + "Martinez", + "Wilson", + "Anderson", + "Taylor", + "Thomas", + "Moore", + "Jackson", + "Martin", + "Lee", + "Perez", + "Thompson" + ], + "words": [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "alpha", + "beta", + "gamma", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet" + ], + "domains": [ + "com", + "net", + "org", + "io", + "dev", + "app", + "co", + "uk" + ], + "colors": [ + "red", + "green", + "blue", + "yellow", + "orange", + "purple", + "cyan", + "magenta", + "black", + "white", + "gray", + "navy", + "teal", + "maroon", + "lime" + ], + "countries": [ + "United States", + "United Kingdom", + "Canada", + "Germany", + "France", + "Australia", + "Japan", + "Brazil", + "India", + "Mexico" + ], + "countryCodes": [ + "US", + "GB", + "CA", + "DE", + "FR", + "AU", + "JP", + "BR", + "IN", + "MX" + ], + "currencyCodes": [ + "USD", + "EUR", + "GBP", + "JPY", + "CAD", + "AUD", + "CHF", + "CNY" + ], + "months": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ], + "weekdays": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "jobTitles": [ + "Engineer", + "Designer", + "Manager", + "Analyst", + "Developer", + "Consultant", + "Director", + "Specialist" + ], + "companySuffixes": [ + "Inc", + "LLC", + "Group", + "Corp", + "Ltd", + "Co" + ], + "fileExts": [ + "txt", + "pdf", + "png", + "json", + "csv", + "xml", + "html", + "js" + ], + "mimeTypes": [ + "application/json", + "text/plain", + "text/html", + "image/png", + "application/pdf" + ], + "productNames": [ + "Chair", + "Keyboard", + "Monitor", + "Desk", + "Lamp", + "Notebook", + "Pen", + "Mouse" + ], + "namePrefixes": [ + "Mr.", + "Mrs.", + "Ms.", + "Dr.", + "Prof." + ], + "nameSuffixes": [ + "Jr.", + "Sr.", + "II", + "III", + "PhD" + ], + "streets": [ + "Main St", + "Oak Ave", + "Maple Dr", + "Cedar Ln", + "Pine Rd" + ], + "cities": [ + "Springfield", + "Riverdale", + "Fairview", + "Madison", + "Georgetown" + ], + "bsWords": [ + "synergize", + "leverage", + "paradigm", + "bandwidth", + "ecosystem" + ], + "catchPhrases": [ + "Think outside the box", + "Move the needle", + "Low-hanging fruit" + ], + "loremWords": [ + "lorem", + "ipsum", + "dolor", + "amet", + "consectetur", + "adipiscing", + "elit" + ], + "hackerAdj": [ + "virtual", + "digital", + "quantum", + "neural", + "binary" + ], + "hackerNouns": [ + "firewall", + "protocol", + "matrix", + "sensor", + "pixel" + ], + "hackerVerbs": [ + "bypass", + "hack", + "compress", + "copy", + "navigate" + ], + "dbColumns": [ + "id", + "name", + "email", + "created_at", + "status" + ], + "dbTypes": [ + "varchar", + "int", + "boolean", + "timestamp", + "jsonb" + ], + "dbCollations": [ + "utf8_general_ci", + "utf8mb4_unicode_ci" + ], + "dbEngines": [ + "InnoDB", + "MyISAM", + "PostgreSQL" + ], + "transactionTypes": [ + "deposit", + "withdrawal", + "payment", + "refund" + ], + "currencyNames": [ + "US Dollar", + "Euro", + "British Pound", + "Japanese Yen" + ], + "currencySymbols": [ + "$", + "\u20ac", + "\u00a3", + "\u00a5" + ], + "productAdj": [ + "Ergonomic", + "Wireless", + "Portable", + "Premium" + ], + "productMaterials": [ + "Plastic", + "Metal", + "Wood", + "Glass" + ], + "departments": [ + "Electronics", + "Home", + "Office", + "Sports" + ], + "imageCategories": [ + "abstract", + "animals", + "business", + "cats", + "city", + "food", + "nature", + "nightlife", + "fashion", + "people", + "sports", + "technics", + "transport" + ], + "protocols": [ + "http", + "https", + "ftp", + "ws", + "wss" + ], + "locales": [ + "en-US", + "en-GB", + "de-DE", + "fr-FR", + "ja-JP" + ], + "userAgents": [ + "Mozilla/5.0", + "PostmanRuntime/7.32.3" + ], + "semverParts": [ + "1", + "2", + "0", + "3", + "4", + "5" + ] + }, + "vars": { + "$guid": { + "rule": "uuid" + }, + "$randomUUID": { + "rule": "uuid" + }, + "$timestamp": { + "rule": "unixTime" + }, + "$isoTimestamp": { + "rule": "isoTime" + }, + "$randomInt": { + "rule": "intRange", + "min": 0, + "max": 1000 + }, + "$randomFloat": { + "rule": "floatRange", + "min": 0, + "max": 1000, + "decimals": 2 + }, + "$randomBoolean": { + "rule": "boolean" + }, + "$randomAlphaNumeric": { + "rule": "alphaNumeric" + }, + "$randomColor": { + "rule": "pick", + "pool": "colors" + }, + "$randomHexColor": { + "rule": "hexColor" + }, + "$randomAbbreviation": { + "rule": "pick", + "pool": "words" + }, + "$randomIP": { + "rule": "ipv4" + }, + "$randomIPV6": { + "rule": "ipv6" + }, + "$randomMACAddress": { + "rule": "mac" + }, + "$randomPassword": { + "rule": "password" + }, + "$randomLocale": { + "rule": "pick", + "pool": "locales" + }, + "$randomUserAgent": { + "rule": "pick", + "pool": "userAgents" + }, + "$randomProtocol": { + "rule": "pick", + "pool": "protocols" + }, + "$randomSemver": { + "rule": "semver" + }, + "$randomFirstName": { + "rule": "pick", + "pool": "firstNames" + }, + "$randomLastName": { + "rule": "pick", + "pool": "lastNames" + }, + "$randomFullName": { + "rule": "template", + "parts": [ + { + "pool": "firstNames" + }, + " ", + { + "pool": "lastNames" + } + ] + }, + "$randomNamePrefix": { + "rule": "pick", + "pool": "namePrefixes" + }, + "$randomNameSuffix": { + "rule": "pick", + "pool": "nameSuffixes" + }, + "$randomJobArea": { + "rule": "pick", + "pool": "words" + }, + "$randomJobDescriptor": { + "rule": "pick", + "pool": "words" + }, + "$randomJobTitle": { + "rule": "pick", + "pool": "jobTitles" + }, + "$randomJobType": { + "rule": "pick", + "pool": "words" + }, + "$randomPhoneNumber": { + "rule": "phone" + }, + "$randomPhoneNumberExt": { + "rule": "phoneExt" + }, + "$randomCity": { + "rule": "pick", + "pool": "cities" + }, + "$randomStreetName": { + "rule": "pick", + "pool": "streets" + }, + "$randomStreetAddress": { + "rule": "streetAddress" + }, + "$randomCountry": { + "rule": "pick", + "pool": "countries" + }, + "$randomCountryCode": { + "rule": "pick", + "pool": "countryCodes" + }, + "$randomLatitude": { + "rule": "latitude" + }, + "$randomLongitude": { + "rule": "longitude" + }, + "$randomAvatarImage": { + "rule": "imageUrl", + "pool": "abstract" + }, + "$randomImageUrl": { + "rule": "imageUrl", + "pool": "abstract" + }, + "$randomAbstractImage": { + "rule": "imageUrl", + "pool": "abstract" + }, + "$randomAnimalsImage": { + "rule": "imageUrl", + "pool": "animals" + }, + "$randomBusinessImage": { + "rule": "imageUrl", + "pool": "business" + }, + "$randomCatsImage": { + "rule": "imageUrl", + "pool": "cats" + }, + "$randomCityImage": { + "rule": "imageUrl", + "pool": "city" + }, + "$randomFoodImage": { + "rule": "imageUrl", + "pool": "food" + }, + "$randomNatureImage": { + "rule": "imageUrl", + "pool": "nature" + }, + "$randomNightlifeImage": { + "rule": "imageUrl", + "pool": "nightlife" + }, + "$randomFashionImage": { + "rule": "imageUrl", + "pool": "fashion" + }, + "$randomPeopleImage": { + "rule": "imageUrl", + "pool": "people" + }, + "$randomSportsImage": { + "rule": "imageUrl", + "pool": "sports" + }, + "$randomTechnicsImage": { + "rule": "imageUrl", + "pool": "technics" + }, + "$randomTransportImage": { + "rule": "imageUrl", + "pool": "transport" + }, + "$randomImageDataUri": { + "rule": "imageDataUri" + }, + "$randomBankAccount": { + "rule": "bankAccount" + }, + "$randomBankAccountName": { + "rule": "template", + "parts": [ + { + "pool": "firstNames" + }, + " ", + { + "pool": "lastNames" + } + ] + }, + "$randomCreditCardMask": { + "rule": "creditCardMask" + }, + "$randomBankAccountBic": { + "rule": "bic" + }, + "$randomBankAccountIban": { + "rule": "iban" + }, + "$randomTransactionType": { + "rule": "pick", + "pool": "transactionTypes" + }, + "$randomCurrencyCode": { + "rule": "pick", + "pool": "currencyCodes" + }, + "$randomCurrencyName": { + "rule": "pick", + "pool": "currencyNames" + }, + "$randomCurrencySymbol": { + "rule": "pick", + "pool": "currencySymbols" + }, + "$randomBitcoin": { + "rule": "bitcoin" + }, + "$randomCompanyName": { + "rule": "companyName" + }, + "$randomCompanySuffix": { + "rule": "pick", + "pool": "companySuffixes" + }, + "$randomBs": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomBsAdjective": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomBsBuzz": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomBsNoun": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomCatchPhrase": { + "rule": "pick", + "pool": "catchPhrases" + }, + "$randomCatchPhraseAdjective": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomCatchPhraseDescriptor": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomCatchPhraseNoun": { + "rule": "pick", + "pool": "bsWords" + }, + "$randomDatabaseColumn": { + "rule": "pick", + "pool": "dbColumns" + }, + "$randomDatabaseType": { + "rule": "pick", + "pool": "dbTypes" + }, + "$randomDatabaseCollation": { + "rule": "pick", + "pool": "dbCollations" + }, + "$randomDatabaseEngine": { + "rule": "pick", + "pool": "dbEngines" + }, + "$randomDateFuture": { + "rule": "dateFuture" + }, + "$randomDatePast": { + "rule": "datePast" + }, + "$randomDateRecent": { + "rule": "dateRecent" + }, + "$randomWeekday": { + "rule": "pick", + "pool": "weekdays" + }, + "$randomMonth": { + "rule": "pick", + "pool": "months" + }, + "$randomDomainName": { + "rule": "domainName" + }, + "$randomDomainSuffix": { + "rule": "pick", + "pool": "domains" + }, + "$randomDomainWord": { + "rule": "pick", + "pool": "words" + }, + "$randomEmail": { + "rule": "template", + "parts": [ + { + "pool": "firstNames" + }, + ".", + { + "pool": "lastNames" + }, + "@", + { + "pool": "words" + }, + ".", + { + "pool": "domains" + } + ] + }, + "$randomExampleEmail": { + "rule": "exampleEmail" + }, + "$randomUserName": { + "rule": "userName" + }, + "$randomUrl": { + "rule": "url" + }, + "$randomFileName": { + "rule": "fileName" + }, + "$randomFileType": { + "rule": "pick", + "pool": "mimeTypes" + }, + "$randomFileExt": { + "rule": "pick", + "pool": "fileExts" + }, + "$randomCommonFileName": { + "rule": "fileName" + }, + "$randomCommonFileType": { + "rule": "pick", + "pool": "mimeTypes" + }, + "$randomCommonFileExt": { + "rule": "pick", + "pool": "fileExts" + }, + "$randomFilePath": { + "rule": "filePath" + }, + "$randomDirectoryPath": { + "rule": "directoryPath" + }, + "$randomMimeType": { + "rule": "pick", + "pool": "mimeTypes" + }, + "$randomPrice": { + "rule": "price" + }, + "$randomProduct": { + "rule": "pick", + "pool": "productNames" + }, + "$randomProductAdjective": { + "rule": "pick", + "pool": "productAdj" + }, + "$randomProductMaterial": { + "rule": "pick", + "pool": "productMaterials" + }, + "$randomProductName": { + "rule": "pick", + "pool": "productNames" + }, + "$randomDepartment": { + "rule": "pick", + "pool": "departments" + }, + "$randomNoun": { + "rule": "pick", + "pool": "words" + }, + "$randomVerb": { + "rule": "pick", + "pool": "hackerVerbs" + }, + "$randomIngverb": { + "rule": "ingVerb" + }, + "$randomAdjective": { + "rule": "pick", + "pool": "hackerAdj" + }, + "$randomWord": { + "rule": "pick", + "pool": "words" + }, + "$randomWords": { + "rule": "picks", + "pool": "words", + "min": 2, + "max": 5 + }, + "$randomPhrase": { + "rule": "picks", + "pool": "words", + "min": 3, + "max": 6 + }, + "$randomLoremWord": { + "rule": "pick", + "pool": "loremWords" + }, + "$randomLoremWords": { + "rule": "picks", + "pool": "loremWords", + "min": 2, + "max": 5 + }, + "$randomLoremSentence": { + "rule": "loremSentence" + }, + "$randomLoremSentences": { + "rule": "loremSentences" + }, + "$randomLoremParagraph": { + "rule": "loremParagraph" + }, + "$randomLoremParagraphs": { + "rule": "loremParagraphs" + }, + "$randomLoremText": { + "rule": "loremText" + }, + "$randomLoremSlug": { + "rule": "loremSlug" + }, + "$randomLoremLines": { + "rule": "loremLines" + }, + "$randomHackerAbbr": { + "rule": "hackerAbbr" + }, + "$randomHackerAdjective": { + "rule": "pick", + "pool": "hackerAdj" + }, + "$randomHackerNoun": { + "rule": "pick", + "pool": "hackerNouns" + }, + "$randomHackerPhrase": { + "rule": "hackerPhrase" + }, + "$randomHackerVerb": { + "rule": "pick", + "pool": "hackerVerbs" + }, + "$randomHackerIngverb": { + "rule": "ingVerb" + } + } +} \ No newline at end of file diff --git a/data/scripts/esprima_parse.mjs b/data/scripts/esprima_parse.mjs new file mode 100644 index 0000000..477ab69 --- /dev/null +++ b/data/scripts/esprima_parse.mjs @@ -0,0 +1,28 @@ +// One-shot: parse a JS file with our vendored esprima (Node require). +// Invoked: deno run --allow-read=... this.mjs +// Emits a single JSON line to stdout: { ok: true, tree } or { ok: false, line, column, message }. + +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const esprima = require(path.join(__dirname, "vendor", "esprima.js")); +const p = Deno.args[0]; +if (!p) { + console.log( + JSON.stringify({ ok: false, line: 1, column: 1, message: "missing source path" }), + ); + Deno.exit(1); +} +const src = await Deno.readTextFile(p); +try { + const tree = esprima.parseScript(src, { loc: true, tolerant: false }); + console.log(JSON.stringify({ ok: true, tree: tree })); +} catch (e) { + const line = e.lineNumber != null ? e.lineNumber : 1; + const col = e.column != null ? e.column + 1 : 1; + const msg = e.description != null ? e.description : (e.message != null ? e.message : String(e)); + console.log(JSON.stringify({ ok: false, line, column: col, message: msg })); +} diff --git a/data/scripts/pm_bootstrap.js b/data/scripts/pm_bootstrap.js new file mode 100644 index 0000000..336c85c --- /dev/null +++ b/data/scripts/pm_bootstrap.js @@ -0,0 +1,1574 @@ +// pm_bootstrap.js — Postmark JavaScript scripting API preamble. +// +// Injected before every user script. Provides the full pm.* API, +// console mock, and Chai-like assertion chains. +// +// After injection, __pm_context is set by the host with request, +// response, variables, environment, collection vars, and info. +// +// After user script runs, host reads JSON.stringify(__pm_state). + +"use strict"; + +// -- Internal state accumulator ---------------------------------------- + +var __pm_state = { + test_results: [], + console_logs: [], + variable_changes: {}, + global_variable_changes: {}, + request_mutations: null, + send_request_count: 0, + next_request: undefined, + skip_request: false, + _send_queue: [], + _pending_tests: [], +}; + +var __pm_callbacks = []; + +// Preserve host-injected context (set before bootstrap is evaluated). +var __pm_context = __pm_context || { + request: {}, + response: null, + variables: {}, + environment_vars: {}, + collection_vars: {}, + global_vars: {}, + info: {}, + is_pre_request: true, +}; + +// -- Console mock (rate-limited to 200 messages) ----------------------- +// Best-effort source_line: stack frames outside _PM_INTERNAL_FRAMES are mapped +// to 0-based editor lines via __pm_user_script_line0 (set in the Deno bundle). + +var __CONSOLE_LIMIT = 200; + +// Keep in sync with Python shims / debug wrapper names (see scripting roadmap A4). +var _PM_INTERNAL_FRAMES = [ + "pm_bootstrap.js", + "__pm_debugUserScript", + "__pm_runUserScript", + "deno_drain.mjs", + "pyodide_run.mjs", +]; + +function _parseConsoleSourceLine(stack) { + if (!stack) return null; + var base = + typeof __pm_user_script_line0 === "number" ? __pm_user_script_line0 : 0; + var lines = String(stack).split("\n"); + for (var i = 0; i < lines.length; i++) { + var ln = lines[i]; + var m = ln.match( + /(?:at\s+(?:[^\s]+\s+)?\(?)([^():]+):(\d+):(\d+)\)?\s*$/ + ); + if (!m) continue; + var file = m[1]; + var lineNum = parseInt(m[2], 10); + if (isNaN(lineNum)) continue; + var internal = false; + for (var j = 0; j < _PM_INTERNAL_FRAMES.length; j++) { + if (file.indexOf(_PM_INTERNAL_FRAMES[j]) !== -1) { + internal = true; + break; + } + } + if (internal) continue; + var editorLine = lineNum - 1 - base; + return editorLine >= 0 ? editorLine : null; + } + return null; +} + +var console = { + _emit: function (level, args) { + if (__pm_state.console_logs.length >= __CONSOLE_LIMIT) return; + var parts = []; + for (var i = 0; i < args.length; i++) { + try { + parts.push( + typeof args[i] === "string" + ? args[i] + : JSON.stringify(args[i]) + ); + } catch (e) { + parts.push(String(args[i])); + } + } + var entry = { + level: level, + message: parts.join(" "), + timestamp: Date.now() / 1000, + }; + try { + var sl = _parseConsoleSourceLine(new Error().stack); + if (sl !== null) entry.source_line = sl; + } catch (_e) { + /* best-effort */ + } + __pm_state.console_logs.push(entry); + }, + log: function () { + console._emit("log", arguments); + }, + warn: function () { + console._emit("warn", arguments); + }, + error: function () { + console._emit("error", arguments); + }, + info: function () { + console._emit("info", arguments); + }, +}; + +// -- Variable scope helper -------------------------------------------- + +function __makeVariableScope(initial, scopeName, changesKey) { + var store = {}; + var targetChanges = changesKey || "variable_changes"; + var keys = Object.keys(initial || {}); + for (var i = 0; i < keys.length; i++) { + store[keys[i]] = String(initial[keys[i]]); + } + + return { + get: function (key) { + return store.hasOwnProperty(key) ? store[key] : undefined; + }, + set: function (key, value) { + var strVal = String(value); + store[key] = strVal; + __pm_state[targetChanges][key] = strVal; + }, + has: function (key) { + return store.hasOwnProperty(key); + }, + unset: function (key) { + delete store[key]; + }, + toObject: function () { + var copy = {}; + var k = Object.keys(store); + for (var i = 0; i < k.length; i++) copy[k[i]] = store[k[i]]; + return copy; + }, + clear: function () { + var k = Object.keys(store); + for (var i = 0; i < k.length; i++) { + delete store[k[i]]; + __pm_state[targetChanges][k[i]] = ""; + } + }, + replaceIn: function (template) { + return template.replace(/\{\{(.+?)\}\}/g, function (m, key) { + key = String(key).trim(); + if (store.hasOwnProperty(key)) return store[key]; + if (key.charAt(0) === "$" && typeof __pm_resolveDynamic === "function") { + var dyn = __pm_resolveDynamic(key); + if (dyn !== null && dyn !== undefined) return dyn; + } + return m; + }); + }, + }; +} + +// -- HeaderList helper ------------------------------------------------ + +function __makeHeaderList(headerArray, mutable) { + var headers = []; + if (headerArray) { + for (var i = 0; i < headerArray.length; i++) { + headers.push({ + key: headerArray[i].key || headerArray[i][0] || "", + value: headerArray[i].value || headerArray[i][1] || "", + }); + } + } + + var obj = { + get: function (name) { + var lower = name.toLowerCase(); + for (var i = 0; i < headers.length; i++) { + if (headers[i].key.toLowerCase() === lower) + return headers[i].value; + } + return undefined; + }, + has: function (name) { + return obj.get(name) !== undefined; + }, + toObject: function () { + var result = {}; + for (var i = 0; i < headers.length; i++) { + result[headers[i].key] = headers[i].value; + } + return result; + }, + each: function (fn) { + for (var i = 0; i < headers.length; i++) { + fn({ key: headers[i].key, value: headers[i].value }); + } + }, + all: function () { + var out = []; + for (var i = 0; i < headers.length; i++) { + out.push({ key: headers[i].key, value: headers[i].value }); + } + return out; + }, + find: function (name) { + var lower = name.toLowerCase(); + for (var i = 0; i < headers.length; i++) { + if (headers[i].key.toLowerCase() === lower) { + return { key: headers[i].key, value: headers[i].value }; + } + } + return undefined; + }, + idx: function (n) { + if (typeof n !== "number" || n < 0 || n >= headers.length) { + return undefined; + } + return { key: headers[n].key, value: headers[n].value }; + }, + }; + + if (mutable) { + obj.add = function (header) { + headers.push({ + key: header.key || "", + value: header.value || "", + }); + }; + obj.remove = function (name) { + var lower = name.toLowerCase(); + for (var i = headers.length - 1; i >= 0; i--) { + if (headers[i].key.toLowerCase() === lower) + headers.splice(i, 1); + } + }; + obj.upsert = function (header) { + var lower = (header.key || "").toLowerCase(); + for (var i = 0; i < headers.length; i++) { + if (headers[i].key.toLowerCase() === lower) { + headers[i].value = header.value || ""; + return; + } + } + headers.push({ + key: header.key || "", + value: header.value || "", + }); + }; + obj._toArray = function () { + return headers; + }; + } + + return obj; +} + +// -- Chai-like Expectation class -------------------------------------- + +function __Expectation(value) { + this._value = value; + this._not = false; +} + +// Chainable no-op getters for readability +var __chains = [ + "to", + "be", + "been", + "is", + "that", + "which", + "and", + "has", + "have", + "with", + "at", + "of", + "same", + "but", + "does", + "deep", +]; +for (var __ci = 0; __ci < __chains.length; __ci++) { + (function (name) { + Object.defineProperty(__Expectation.prototype, name, { + get: function () { + return this; + }, + }); + })(__chains[__ci]); +} + +// Negation +Object.defineProperty(__Expectation.prototype, "not", { + get: function () { + this._not = !this._not; + return this; + }, +}); + +// Boolean/existence property assertions +var __propAssertions = { + true: function (v) { + return v === true; + }, + false: function (v) { + return v === false; + }, + null: function (v) { + return v === null; + }, + undefined: function (v) { + return v === undefined; + }, + NaN: function (v) { + return typeof v === "number" && isNaN(v); + }, + exist: function (v) { + return v !== null && v !== undefined; + }, + empty: function (v) { + if (typeof v === "string" || Array.isArray(v)) return v.length === 0; + if (v && typeof v === "object") return Object.keys(v).length === 0; + return false; + }, +}; + +var __propKeys = Object.keys(__propAssertions); +for (var __pi = 0; __pi < __propKeys.length; __pi++) { + (function (name, fn) { + Object.defineProperty(__Expectation.prototype, name, { + get: function () { + var result = fn(this._value); + if (this._not) result = !result; + if (!result) { + throw new Error( + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to be " + + name + ); + } + return this; + }, + }); + })(__propKeys[__pi], __propAssertions[__propKeys[__pi]]); +} + +__Expectation.prototype._assert = function (result, msg) { + if (this._not) result = !result; + if (!result) throw new Error(msg); + return this; +}; + +// Method assertions +__Expectation.prototype.equal = function (expected) { + return this._assert( + this._value === expected, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to equal " + + JSON.stringify(expected) + ); +}; +__Expectation.prototype.equals = __Expectation.prototype.equal; +__Expectation.prototype.eq = __Expectation.prototype.equal; + +__Expectation.prototype.eql = function (expected) { + var pass = JSON.stringify(this._value) === JSON.stringify(expected); + return this._assert( + pass, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to deeply equal " + + JSON.stringify(expected) + ); +}; + +__Expectation.prototype.a = function (type) { + var actual; + if (Array.isArray(this._value)) actual = "array"; + else actual = typeof this._value; + return this._assert( + actual === type, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to be a " + + type + ); +}; +__Expectation.prototype.an = __Expectation.prototype.a; + +__Expectation.prototype.include = function (val) { + var pass = false; + if (typeof this._value === "string") pass = this._value.indexOf(val) !== -1; + else if (Array.isArray(this._value)) pass = this._value.indexOf(val) !== -1; + else if (this._value && typeof this._value === "object") + pass = val in this._value; + return this._assert( + pass, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to include " + + JSON.stringify(val) + ); +}; +__Expectation.prototype.includes = __Expectation.prototype.include; +__Expectation.prototype.contain = __Expectation.prototype.include; +__Expectation.prototype.contains = __Expectation.prototype.include; + +__Expectation.prototype.property = function (name, val) { + var has = this._value != null && this._value.hasOwnProperty(name); + if (arguments.length === 2) { + has = has && this._value[name] === val; + } + return this._assert( + has, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to have property " + + JSON.stringify(name) + + (arguments.length === 2 + ? " with value " + JSON.stringify(val) + : "") + ); +}; + +__Expectation.prototype.lengthOf = function (n) { + var len = this._value && this._value.length; + return this._assert( + len === n, + "expected length " + len + (this._not ? " not " : " ") + "to be " + n + ); +}; +__Expectation.prototype.length = __Expectation.prototype.lengthOf; + +__Expectation.prototype.above = function (n) { + return this._assert( + this._value > n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be above " + + n + ); +}; +__Expectation.prototype.greaterThan = __Expectation.prototype.above; +__Expectation.prototype.gt = __Expectation.prototype.above; + +__Expectation.prototype.below = function (n) { + return this._assert( + this._value < n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be below " + + n + ); +}; +__Expectation.prototype.lessThan = __Expectation.prototype.below; +__Expectation.prototype.lt = __Expectation.prototype.below; + +__Expectation.prototype.least = function (n) { + return this._assert( + this._value >= n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be at least " + + n + ); +}; +__Expectation.prototype.gte = __Expectation.prototype.least; + +__Expectation.prototype.most = function (n) { + return this._assert( + this._value <= n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be at most " + + n + ); +}; +__Expectation.prototype.lte = __Expectation.prototype.most; + +__Expectation.prototype.match = function (re) { + return this._assert( + re.test(this._value), + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to match " + + re + ); +}; +__Expectation.prototype.matches = __Expectation.prototype.match; + +// HTTP status code to canonical reason phrase (used by ``.status("Created")``; +// referenced from ``data/snippets/javascript.json`` test snippets). +var __HTTP_REASON = { + 100: "Continue", + 101: "Switching Protocols", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 422: "Unprocessable Entity", + 429: "Too Many Requests", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +// HTTP-specific assertions +__Expectation.prototype.status = function (code) { + var actual = this._value; + if (actual && typeof actual === "object" && "code" in actual) + actual = actual.code; + if (typeof code === "string") { + var reason = __HTTP_REASON[actual] || ""; + return this._assert( + reason.toLowerCase() === code.toLowerCase(), + "expected status " + + actual + + " (" + + reason + + ")" + + (this._not ? " not " : " ") + + "to be " + + JSON.stringify(code) + ); + } + return this._assert( + actual === code, + "expected status " + + actual + + (this._not ? " not " : " ") + + "to be " + + code + ); +}; + +__Expectation.prototype.header = function (name, value) { + var resp = this._value; + if (resp && typeof resp === "object" && resp.headers) { + var headerVal = resp.headers.get + ? resp.headers.get(name) + : resp.headers[name]; + if (arguments.length === 2) { + return this._assert( + headerVal === value, + "expected header " + + name + + " to be " + + JSON.stringify(value) + + " but got " + + JSON.stringify(headerVal) + ); + } + return this._assert( + headerVal !== undefined, + "expected response to have header " + JSON.stringify(name) + ); + } + return this._assert(false, "expected a response object with headers"); +}; + +__Expectation.prototype.body = function (expected) { + var resp = this._value; + var actual = ""; + if (resp && typeof resp === "object") { + if (typeof resp.text === "function") { + actual = resp.text(); + } else if (typeof resp.body === "string") { + actual = resp.body; + } + } else if (typeof resp === "string") { + actual = resp; + } + var preview = + actual.length > 80 ? actual.slice(0, 77) + "..." : actual; + if (expected instanceof RegExp) { + return this._assert( + expected.test(actual), + "expected body to match " + + String(expected) + + " but got " + + JSON.stringify(preview) + ); + } + return this._assert( + actual === expected, + "expected body to equal " + + JSON.stringify(expected) + + " but got " + + JSON.stringify(preview) + ); +}; + +// Strict ``===`` membership (not deep-equal Chai semantics). +__Expectation.prototype.oneOf = function (allowed) { + var actual = this._value; + if (!Array.isArray(allowed)) { + return this._assert(false, "oneOf expects an array argument"); + } + var ok = false; + for (var i = 0; i < allowed.length; i++) { + if (allowed[i] === actual) { + ok = true; + break; + } + } + return this._assert( + ok, + "expected " + + JSON.stringify(actual) + + (this._not ? " not " : " ") + + "to be one of " + + JSON.stringify(allowed) + ); +}; + +__Expectation.prototype.jsonBody = function (path, value) { + var resp = this._value; + var body = resp; + if (resp && typeof resp === "object" && "body" in resp) { + body = + typeof resp.body === "string" ? JSON.parse(resp.body) : resp.body; + } + // Lodash-style path: ``a.b[0].c`` → ["a", "b", 0, "c"]. + var tokens = []; + var chunks = path.split("."); + for (var ci = 0; ci < chunks.length; ci++) { + var bracketParts = chunks[ci].split(/[\[\]]+/); + for (var bi = 0; bi < bracketParts.length; bi++) { + var tok = bracketParts[bi]; + if (tok === "") continue; + if (/^-?\d+$/.test(tok)) tokens.push(parseInt(tok, 10)); + else tokens.push(tok); + } + } + var cur = body; + for (var i = 0; i < tokens.length; i++) { + if (cur == null) { cur = undefined; break; } + cur = cur[tokens[i]]; + } + if (arguments.length === 2) { + return this._assert( + JSON.stringify(cur) === JSON.stringify(value), + "expected " + + path + + " to be " + + JSON.stringify(value) + + " but got " + + JSON.stringify(cur) + ); + } + return this._assert( + cur !== undefined, + "expected body to have path " + JSON.stringify(path) + ); +}; + +function __pm_jsonSchemaTarget(value) { + var resp = value; + if (resp && typeof resp === "object" && ("body" in resp || typeof resp.json === "function")) { + try { + if (typeof resp.json === "function") return resp.json(); + var raw = typeof resp.body === "string" ? resp.body : ""; + return raw ? JSON.parse(raw) : {}; + } catch (_e) { + return null; + } + } + return value; +} + +__Expectation.prototype.jsonSchema = function (schema) { + var data = __pm_jsonSchemaTarget(this._value); + var r = __pm_validateSchema(data, schema); + return this._assert( + r.ok, + "expected value to match schema: " + r.errors.join(", ") + ); +}; + +// -- Inline sendRequest IPC + response wrapper ----------------------- +// +// ``writeSync`` / ``readSync`` are imported at the top of the bundled +// Deno script (see ``deno_runtime._NODE_FS_IMPORT``). They give +// ``pm.sendRequest`` a synchronous IPC channel to the host: the spec +// is written to stdout as a JSON line, the host fetches the URL, and +// writes the response back as a JSON line on stdin. With this in +// place, ``await pm.sendRequest(...)`` matches Postman's modern API. +// +// The drain pass (``deno_drain.mjs``) is now mostly a no-op for +// ``sendRequest`` — kept only as a safety net for legacy code that +// still pushes to ``__pm_state._send_queue`` manually. + +function __pm_inline_ipc_send(spec) { + if (typeof writeSync !== "function" || typeof readSync !== "function") { + return { + error: "pm.sendRequest unavailable: no IPC channel", + body: "", + }; + } + var enc = new TextEncoder(); + var line = + JSON.stringify({ + __ipc__: "sendRequest", + spec: spec, + callbackIndex: 0, + }) + "\n"; + try { + writeSync(1, enc.encode(line)); + } catch (_e) { + return { error: "pm.sendRequest write failed", body: "" }; + } + var parts = []; + var u8 = new Uint8Array(1); + while (true) { + var n; + try { + n = readSync(0, u8); + } catch (_e) { + return { error: "pm.sendRequest read failed", body: "" }; + } + if (n === 0 || n == null) { + return { error: "pm.sendRequest read closed", body: "" }; + } + if (u8[0] === 10) break; // \n + if (u8[0] === 13) continue; // \r + parts.push(String.fromCharCode(u8[0])); + } + var raw = parts.join(""); + try { + return JSON.parse(raw); + } catch (_e) { + return { error: "pm.sendRequest bad json", body: "" }; + } +} + +// -- URL factory (Postman ``pm.request.url`` shape) ------------------- + +function __pm_makeUrl(rawUrl) { + var s = String(rawUrl || ""); + var parsed = null; + try { + parsed = new URL(s); + } catch (_e) { + parsed = null; + } + var queryItems = []; + if (parsed && parsed.searchParams) { + parsed.searchParams.forEach(function (v, k) { + queryItems.push({ key: k, value: v }); + }); + } + var query = __makeHeaderList(queryItems, true); + return { + toString: function () { + return parsed ? parsed.toString() : s; + }, + getHost: function () { + return parsed ? parsed.host : ""; + }, + getPath: function () { + return parsed ? parsed.pathname : ""; + }, + getQueryString: function () { + return parsed ? parsed.search.replace(/^\?/, "") : ""; + }, + protocol: parsed ? parsed.protocol.replace(/:$/, "") : "", + host: parsed ? parsed.hostname : "", + port: parsed ? parsed.port : "", + path: parsed ? parsed.pathname : "", + query: query, + _isPostmarkUrl: true, + }; +} + +// -- Cookie helpers (``pm.cookies`` / ``pm.response.cookies``) -------- + +function __pm_parseCookieHeaders(headerArray) { + var cookies = {}; + if (!headerArray) { + return cookies; + } + for (var i = 0; i < headerArray.length; i++) { + if ((headerArray[i].key || "").toLowerCase() === "set-cookie") { + var raw = headerArray[i].value || ""; + var eqIdx = raw.indexOf("="); + if (eqIdx > 0) { + var cName = raw.substring(0, eqIdx).trim(); + var rest = raw.substring(eqIdx + 1); + var semiIdx = rest.indexOf(";"); + var cVal = + semiIdx >= 0 + ? rest.substring(0, semiIdx).trim() + : rest.trim(); + cookies[cName] = cVal; + } + } + } + return cookies; +} + +function __pm_makeCookiesApi(cookies) { + return { + get: function (name) { + return cookies.hasOwnProperty(name) ? cookies[name] : undefined; + }, + getAll: function () { + var result = []; + var keys = Object.keys(cookies); + for (var i = 0; i < keys.length; i++) { + result.push({ + name: keys[i], + value: cookies[keys[i]], + }); + } + return result; + }, + has: function (name) { + return cookies.hasOwnProperty(name); + }, + }; +} + +function __pm_wrap_response(raw) { + if (!raw || typeof raw !== "object") { + return null; + } + var obj = { + code: raw.status_code || raw.code || 0, + status: raw.status || "", + headers: __makeHeaderList(raw.headers, false), + responseTime: raw.response_time || raw.responseTime || 0, + responseSize: raw.response_size || raw.responseSize || 0, + body: typeof raw.body === "string" ? raw.body : raw.body || "", + error: raw.error || null, + }; + obj.json = function () { + var s = typeof obj.body === "string" ? obj.body : ""; + if (s.length === 0) { + throw new Error( + "response.json(): response body is empty" + ); + } + try { + return JSON.parse(s); + } catch (e) { + throw new Error( + "response.json(): body is not valid JSON (" + + (e && e.message ? e.message : "parse error") + + ")" + ); + } + }; + obj.text = function () { + return typeof obj.body === "string" ? obj.body : String(obj.body); + }; + obj.reason = function () { + return __HTTP_REASON[obj.code] || ""; + }; + obj.mime = function () { + var ct = + obj.headers && obj.headers.get + ? obj.headers.get("Content-Type") || "" + : ""; + var sep = ct.indexOf(";"); + return { + type: sep >= 0 ? ct.slice(0, sep).trim() : ct.trim(), + charset: (function () { + var m = /charset=([^;]+)/i.exec(ct); + return m ? m[1].trim() : ""; + })(), + }; + }; + obj.dataURI = function () { + var ct = + obj.headers && obj.headers.get + ? obj.headers.get("Content-Type") || "application/octet-stream" + : "application/octet-stream"; + var raw = typeof obj.body === "string" ? obj.body : ""; + return ( + "data:" + + ct + + ";base64," + + (typeof btoa !== "undefined" + ? btoa(unescape(encodeURIComponent(raw))) + : "") + ); + }; + obj.size = function () { + return ( + obj.responseSize || + (typeof obj.body === "string" ? obj.body.length : 0) + ); + }; + Object.defineProperty(obj, "to", { + get: function () { + return new __Expectation(obj); + }, + }); + return obj; +} + +// -- pm object -------------------------------------------------------- + +var __pm_info_raw = __pm_context.info || {}; +var pm = { + info: { + eventName: __pm_info_raw.eventName || "", + requestName: __pm_info_raw.requestName || "", + requestId: __pm_info_raw.requestId || "", + iteration: __pm_info_raw.iteration != null ? __pm_info_raw.iteration : 0, + iterationCount: + __pm_info_raw.iterationCount != null + ? __pm_info_raw.iterationCount + : 0, + testFilter: __pm_info_raw.testFilter || null, + }, + + request: (function () { + var req = __pm_context.request || {}; + var bodyVal = req.body; + var bodyObj; + if (!bodyVal || typeof bodyVal === "string") { + var rawStr = typeof bodyVal === "string" ? bodyVal : ""; + bodyObj = { + mode: rawStr ? "raw" : "", + raw: rawStr, + urlencoded: __makeHeaderList([], true), + formdata: __makeHeaderList([], true), + graphql: null, + file: null, + toString: function () { + return this.raw || ""; + }, + }; + } else if (typeof bodyVal === "object") { + var mode = bodyVal.mode || "raw"; + bodyObj = { + mode: mode, + raw: bodyVal.raw || "", + urlencoded: __makeHeaderList(bodyVal.urlencoded || [], true), + formdata: __makeHeaderList(bodyVal.formdata || [], true), + graphql: bodyVal.graphql || null, + file: bodyVal.file || null, + toString: function () { + return typeof this.raw === "string" ? this.raw : ""; + }, + }; + } else { + bodyObj = { + mode: "", + raw: "", + urlencoded: __makeHeaderList([], true), + formdata: __makeHeaderList([], true), + graphql: null, + file: null, + toString: function () { + return ""; + }, + }; + } + return { + url: __pm_makeUrl(req.url || ""), + method: req.method || "GET", + headers: __makeHeaderList(req.headers, __pm_context.is_pre_request), + body: bodyObj, + auth: req.auth != null ? req.auth : null, + }; + })(), + + response: (function () { + var res = __pm_context.response; + if (!res) return null; + var obj = __pm_wrap_response(res); + obj.cookies = __pm_makeCookiesApi(__pm_parseCookieHeaders(res.headers || [])); + var ori = __pm_context.original_request; + if (ori) { + var ob = + typeof ori.body === "object" && + ori.body !== null && + typeof ori.body.toString === "function" + ? ori.body.toString() + : String(ori.body || ""); + obj.originalRequest = { + url: __pm_makeUrl(ori.url || ""), + method: String(ori.method || "GET"), + headers: __makeHeaderList(ori.headers, false), + body: ob, + }; + } else { + obj.originalRequest = null; + } + return obj; + })(), + + environment: (function () { + var scope = __makeVariableScope( + __pm_context.environment_vars, + "environment" + ); + scope.name = __pm_context.environment_name || ""; + return scope; + })(), + collectionVariables: __makeVariableScope( + __pm_context.collection_vars, + "collectionVariables" + ), + globals: __makeVariableScope(__pm_context.global_vars, "globals", "global_variable_changes"), + + test: (function () { + var fnTest = function (name, fn) { + var filter = + (typeof __pm_context !== "undefined" && + __pm_context.info && + __pm_context.info.testFilter) || + globalThis.__pm_test_filter || + null; + if (filter && String(name) !== String(filter)) { + return; + } + var start = Date.now(); + var result = { + name: name, + passed: true, + error: null, + duration_ms: 0, + skipped: false, + }; + var didSkip = false; + var skipFn = function () { + didSkip = true; + }; + try { + if (typeof fn !== "function") { + throw new Error("pm.test callback must be a function"); + } + var ret = fn.call({ skip: skipFn }, skipFn); + if ( + ret && + typeof ret === "object" && + typeof ret.then === "function" + ) { + result.duration_ms = Date.now() - start; + if (didSkip) { + result.passed = true; + result.skipped = true; + } + var srcAsync = globalThis.__pm_test_source_name; + if (srcAsync) { + result.source_name = String(srcAsync); + } + __pm_state.test_results.push(result); + var pending = { result: result, promise: ret }; + ret.then( + function () { + pending.result.duration_ms = Date.now() - start; + }, + function (e) { + pending.result.passed = false; + pending.result.error = e.message || String(e); + pending.result.duration_ms = Date.now() - start; + } + ); + __pm_state._pending_tests.push(pending); + return; + } + } catch (e) { + result.passed = false; + result.error = e.message || String(e); + } + result.duration_ms = Date.now() - start; + if (didSkip) { + result.passed = true; + result.skipped = true; + } + var src = globalThis.__pm_test_source_name; + if (src) { + result.source_name = String(src); + } + __pm_state.test_results.push(result); + }; + fnTest.skip = function (name, _fn) { + __pm_state.test_results.push({ + name: String(name), + passed: true, + skipped: true, + duration_ms: 0, + error: null, + }); + }; + return fnTest; + })(), + + expect: function (value) { + return new __Expectation(value); + }, + + require: function (specifier) { + if (typeof specifier !== "string" || specifier.length === 0) { + throw new Error("pm.require: specifier must be a non-empty string"); + } + if (specifier === "cheerio") { + throw new Error( + "Module 'cheerio' is not available in the Postmark sandbox; use pm.require('npm:cheerio')" + ); + } + if ( + typeof __pm_builtins !== "undefined" && + Object.prototype.hasOwnProperty.call(__pm_builtins, specifier) + ) { + var builtin = __pm_builtins[specifier]; + if (builtin === undefined) { + throw new Error( + "pm.require: built-in '" + specifier + "' is not bundled" + ); + } + return builtin; + } + if (specifier.indexOf("local:") === 0) { + var localRegistry = globalThis.__pm_require_modules || {}; + if (Object.prototype.hasOwnProperty.call(localRegistry, specifier)) { + return localRegistry[specifier]; + } + throw new Error( + "pm.require: local script '" + + specifier.slice(6) + + "' was not bundled. Use a string literal and check the path in " + + "Local scripts (path-safe names, case-sensitive)." + ); + } + if (specifier.indexOf("npm:") !== 0 && specifier.indexOf("jsr:") !== 0) { + throw new Error( + "pm.require: unknown package '" + + specifier + + "' (use a built-in name, local:, npm:, or jsr:)" + ); + } + var registry = globalThis.__pm_require_modules || {}; + if (Object.prototype.hasOwnProperty.call(registry, specifier)) { + return registry[specifier]; + } + var at = specifier.lastIndexOf("@"); + var slash = specifier.lastIndexOf("/"); + if (at > 4 && at > slash) { + var bare = specifier.slice(0, at); + if (Object.prototype.hasOwnProperty.call(registry, bare)) { + return registry[bare]; + } + } + throw new Error( + "pm.require: package '" + + specifier + + "' was not bundled. " + + "Make sure the call uses a string literal so the host can detect it." + ); + }, + + visualizer: { + set: function (_template, _data, _options) { + throw new Error( + "pm.visualizer.set is not supported in postmark — see data/snippets/README.md" + ); + }, + }, + + sendRequest: function (spec, callback) { + // Postman-API parity: synchronous-IPC fetch during user script + // execution. Returns a resolved Promise so ``await`` works; also + // fires the optional callback for the legacy form. Without the + // inline IPC, ``await`` would never resolve because the queue + // drain runs after the user script body has already finished. + if (__pm_state.send_request_count >= 10) { + throw new Error("pm.sendRequest rate limit exceeded (max 10)"); + } + __pm_state.send_request_count++; + var reqSpec = + typeof spec === "string" ? { url: spec, method: "GET" } : spec; + __pm_state.console_logs.push({ + level: "log", + message: + '[Script] pm.sendRequest("' + + (reqSpec.method || "GET") + + " " + + (reqSpec.url && reqSpec.url.toString + ? reqSpec.url.toString() + : String(reqSpec.url || "")) + + '")', + timestamp: Date.now() / 1000, + }); + var raw = __pm_inline_ipc_send(reqSpec); + var wrapped = __pm_wrap_response(raw); + if (typeof callback === "function") { + try { + callback(raw && raw.error ? raw.error : null, wrapped); + } catch (e) { + __pm_state.console_logs.push({ + level: "error", + message: + "[Script] sendRequest callback error: " + + (e && e.message ? e.message : String(e)), + timestamp: Date.now() / 1000, + }); + } + } + return Promise.resolve(wrapped); + }, + + cookies: (function () { + var hdrs = __pm_context.response ? __pm_context.response.headers || [] : []; + var api = __pm_makeCookiesApi(__pm_parseCookieHeaders(hdrs)); + api.jar = function () { + return { + getAll: function (_url) { + return []; + }, + set: function () { + throw new Error( + "pm.cookies.jar mutation is not yet supported in postmark" + ); + }, + unset: function () { + throw new Error( + "pm.cookies.jar mutation is not yet supported in postmark" + ); + }, + clear: function () { + throw new Error( + "pm.cookies.jar mutation is not yet supported in postmark" + ); + }, + }; + }; + return api; + })(), + + execution: (function () { + var loc = __pm_context.execution_location || { current: "" }; + return { + setNextRequest: function (name) { + __pm_state.next_request = + name === null || name === undefined ? null : String(name); + }, + skipRequest: function () { + __pm_state.skip_request = true; + }, + location: loc, + }; + })(), + + iterationData: (function () { + var data = __pm_context.iteration_data || {}; + return { + get: function (key) { + return data.hasOwnProperty(key) ? data[key] : undefined; + }, + toObject: function () { + var copy = {}; + var keys = Object.keys(data); + for (var i = 0; i < keys.length; i++) { + copy[keys[i]] = data[keys[i]]; + } + return copy; + }, + has: function (key) { + return data.hasOwnProperty(key); + }, + }; + })(), + + variables: (function () { + var local = {}; + return { + get: function (k) { + if (Object.prototype.hasOwnProperty.call(local, k)) { + return local[k]; + } + var it = __pm_context.iteration_data || {}; + if (Object.prototype.hasOwnProperty.call(it, k)) { + return it[k]; + } + var env = __pm_context.environment_vars || {}; + if (Object.prototype.hasOwnProperty.call(env, k)) { + return env[k]; + } + var coll = __pm_context.collection_vars || {}; + if (Object.prototype.hasOwnProperty.call(coll, k)) { + return coll[k]; + } + var glob = __pm_context.global_vars || {}; + if (Object.prototype.hasOwnProperty.call(glob, k)) { + return glob[k]; + } + return undefined; + }, + set: function (k, v) { + var strVal = String(v); + local[k] = strVal; + __pm_state.variable_changes[k] = strVal; + }, + unset: function (k) { + delete local[k]; + }, + has: function (k) { + return this.get(k) !== undefined; + }, + toObject: function () { + var out = {}; + var glob = __pm_context.global_vars || {}; + Object.keys(glob).forEach(function (key) { + out[key] = glob[key]; + }); + var coll = __pm_context.collection_vars || {}; + Object.keys(coll).forEach(function (key) { + out[key] = coll[key]; + }); + var env = __pm_context.environment_vars || {}; + Object.keys(env).forEach(function (key) { + out[key] = env[key]; + }); + var it = __pm_context.iteration_data || {}; + Object.keys(it).forEach(function (key) { + out[key] = it[key]; + }); + Object.keys(local).forEach(function (key) { + out[key] = local[key]; + }); + return out; + }, + replaceIn: function (template) { + var self = this; + return template.replace(/\{\{(.+?)\}\}/g, function (m, key) { + key = String(key).trim(); + var v = self.get(key); + if (v !== undefined) return v; + if (key.charAt(0) === "$" && typeof __pm_resolveDynamic === "function") { + var dyn = __pm_resolveDynamic(key); + if (dyn !== null && dyn !== undefined) return dyn; + } + return m; + }); + }, + clear: function () { + Object.keys(local).forEach(function (key) { + delete local[key]; + }); + }, + }; + })(), +}; + +function __pm_fulfill_send(index, resp) { + var cb = __pm_callbacks[index]; + if (cb) { + try { + cb(resp.error || null, resp); + } catch (e) { + __pm_state.console_logs.push({ + level: "error", + message: + "[Script] sendRequest callback error: " + + (e.message || String(e)), + timestamp: Date.now() / 1000, + }); + } + } +} + +// -- Legacy Postman compatibility shim --------------------------------- + +var postman = { + setEnvironmentVariable: function (key, value) { + pm.environment.set(key, String(value)); + }, + getEnvironmentVariable: function (key) { + return pm.environment.get(key); + }, + clearEnvironmentVariable: function (key) { + pm.environment.unset(key); + }, + setGlobalVariable: function (key, value) { + pm.globals.set(key, String(value)); + }, + getGlobalVariable: function (key) { + return pm.globals.get(key); + }, + clearGlobalVariable: function (key) { + pm.globals.unset(key); + }, + setNextRequest: function (name) { + __pm_state.next_request = name == null ? null : String(name); + }, +}; + +// -- Postman require() shim ------------------------------------------- +// Supports the most-used sandbox built-in libraries. + +var __pm_builtins = { + "crypto-js": typeof CryptoJS !== "undefined" ? CryptoJS : undefined, + lodash: typeof __pm_lodash !== "undefined" ? __pm_lodash : undefined, + moment: typeof __pm_moment !== "undefined" ? __pm_moment : undefined, + chai: typeof __pm_chai !== "undefined" ? __pm_chai : undefined, + tv4: typeof __pm_tv4 !== "undefined" ? __pm_tv4 : undefined, + ajv: + typeof __pm_ajv !== "undefined" + ? __pm_ajv.default || __pm_ajv + : undefined, + xml2js: typeof __pm_xml2js !== "undefined" ? __pm_xml2js : undefined, + "csv-parse/sync": + typeof __pm_csv_parse !== "undefined" ? __pm_csv_parse : undefined, + "csv-parse/lib/sync": + typeof __pm_csv_parse !== "undefined" ? __pm_csv_parse : undefined, + atob: typeof atob !== "undefined" ? atob : undefined, + btoa: typeof btoa !== "undefined" ? btoa : undefined, + uuid: (function () { + // Minimal UUIDv4 implementation. + function v4() { + var bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + var hex = []; + for (var i = 0; i < 16; i++) { + hex.push(("0" + bytes[i].toString(16)).slice(-2)); + } + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); + } + return { v4: v4 }; + })(), +}; + +function require(name) { + if (name in __pm_builtins) { + var mod = __pm_builtins[name]; + if (mod === undefined) { + throw new Error("Module '" + name + "' failed to load"); + } + return mod; + } + throw new Error( + "Module '" + name + "' is not available in the Postmark sandbox" + ); +} + +// -- Freeze read-only objects ----------------------------------------- + +Object.freeze(pm.info); +Object.freeze(pm.cookies); +Object.freeze(pm.execution); +Object.freeze(pm.iterationData); + +if (!__pm_context.is_pre_request) { + // In test context, request and response are read-only + if (pm.response) Object.freeze(pm.response); + Object.freeze(pm.request); +} else { + // In pre-request context, track request mutations + __pm_state.request_mutations = { + url: + typeof pm.request.url === "string" + ? pm.request.url + : pm.request.url && pm.request.url.toString + ? pm.request.url.toString() + : "", + method: pm.request.method, + headers: pm.request.headers._toArray + ? pm.request.headers._toArray() + : [], + body: + typeof pm.request.body === "string" + ? pm.request.body + : pm.request.body && pm.request.body.toString + ? pm.request.body.toString() + : "", + }; +} + +// ``Debugger.evaluateOnCallFrame`` evaluates in the paused user frame; module +// top-level ``var`` bindings are not in that lexical environment. Mirror the +// live objects onto ``globalThis`` so debug variable reads see ``variable_changes``. +if (typeof globalThis !== "undefined") { + globalThis.__pm_state = __pm_state; + globalThis.pm = pm; + globalThis.postman = postman; + globalThis.responseBody = pm.response ? pm.response.text() : undefined; + globalThis.responseCode = pm.response + ? { code: pm.response.code, name: pm.response.reason() } + : undefined; + globalThis.responseHeaders = pm.response ? pm.response.headers.toObject() : {}; + globalThis.responseTime = pm.response + ? pm.response.responseTime + : undefined; + globalThis.tests = {}; + globalThis.xml2Json = function (xml) { + var x = __pm_builtins.xml2js; + if (!x || typeof x.parseString !== "function") { + return null; + } + var out = null; + x.parseString(String(xml), function (err, r) { + if (!err) { + out = r; + } + }); + return out; + }; +} diff --git a/data/scripts/pm_bootstrap.py b/data/scripts/pm_bootstrap.py new file mode 100644 index 0000000..24b4e0f --- /dev/null +++ b/data/scripts/pm_bootstrap.py @@ -0,0 +1,1577 @@ +"""Postmark ``pm`` API for Pyodide (loaded by ``pyodide_run.mjs``). + +Built from :file:`src/services/scripting/_py_sandbox.py` (shared behaviour, no +``RestrictedPython``). ``__pm_context_json`` must exist before this module +initialises ``pm``. +""" + +from __future__ import annotations + +import hmac +import inspect +import json +import math +import re +import sys +import time +import uuid +from base64 import b64decode, b64encode +from datetime import UTC, datetime +from hashlib import md5, sha256 +from typing import Any +from urllib.parse import quote, urlencode + +_CONSOLE_LIMIT = 200 +_console_logs: list[dict[str, Any]] = [] + +# Prelude lines prepended to ``