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/.instructions.md`:
-```yaml
----
-name: ""
-description: "One sentence ..."
-applyTo: "src/path/**/*.py" # glob — auto-loaded for matching files
----
-#
-(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//` |
-| **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//SKILL.md`:
+```yaml
+---
+name: "" # kebab-case, matches folder name
+description: "One sentence ... when to load this skill"
+---
+#
+(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 @@
-# Postmark
+
Postmark
-A native desktop API client for testing and managing HTTP requests, built with **PySide6** and **SQLAlchemy**.
+
+ A local-first desktop API client with a Postman-compatible scripting engine.
+ Design, test, and automate HTTP requests — built with PySide6 and SQLAlchemy.
+
+
+---
+
+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 @@
+
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 @@
+
\ 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 @@
+
\ 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 ``