From 455394ef5bdfdf4d8f704de2d02a07a498419cce Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Wed, 4 Nov 2026 12:26:43 +0100 Subject: [PATCH 1/5] fix(editor): echo live session-trial content from GET /api/config/content GET /content returned an empty body for session-trial configs, so an editor reopened after Apply showed an empty document instead of the in-memory configuration the user had just applied (issue #97). The server parsed /load-content payloads into state and discarded the raw YAML -- there was nothing to echo back. - ServerState gains trial_content: the stored raw text of the current trial; cleared by every disk-anchoring path (_apply_config_from_disk, /reload) since a trial belongs to no real file. - /load-content stores the payload verbatim; /content echoes it with session_trial=True and config_path=None (contract shape unchanged). - The pre-existing drop-pinning test now also pins the echoed text, and a new test pins the disk-anchor clearing. Closes #97 --- src/profiles/server/routes/config.py | 16 ++++++--- src/profiles/server/state.py | 6 ++++ tests/server/routes/test_config_editor.py | 44 ++++++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/profiles/server/routes/config.py b/src/profiles/server/routes/config.py index 6da3034..db6a4c6 100644 --- a/src/profiles/server/routes/config.py +++ b/src/profiles/server/routes/config.py @@ -170,6 +170,7 @@ def _apply_config_from_disk(state: ServerState, config_path: Path) -> dict: loaded = ConfigReader(config_path).load() state.config = loaded state.session_trial = False + state.trial_content = None state.config_path = config_path state.active_directory = _choose_active_directory(loaded, config_path) return _config_response(state) @@ -264,6 +265,9 @@ async def load_content_endpoint(payload: LoadContentRequest, request: Request) - state.config = loaded state.session_trial = True + # Remember the raw payload (issue #97): parsing discards it, and it is + # the only record GET /content can echo for a path-less trial. + state.trial_content = payload.content state.active_directory = _choose_active_directory(loaded, None) response = _config_response(state) response["session_trial"] = True @@ -341,6 +345,7 @@ async def reload_config_endpoint(request: Request) -> dict: loaded.config_path = config_path state.config = loaded state.session_trial = False + state.trial_content = None state.config_path = config_path state.active_directory = _choose_active_directory(loaded, config_path) response = _config_response(state) @@ -364,17 +369,18 @@ async def get_config_content(request: Request) -> dict: """Return the active configuration file's text for the YAML editor. Responds ``{"filename", "content", "config_path", "session_trial"}``. - A session trial (config applied from dropped content) answers an - empty body flagged ``session_trial=True`` — ``config_path`` stays - ``None`` so the stale CWD-resolved path never leaks, and ``filename`` - carries the config's display name — instead of file text; a + A session trial (config applied from dropped content) answers the + text that was applied (``ServerState.trial_content``, issue #97) with + ``session_trial=True`` and ``config_path=None`` — ``config_path`` + stays ``None`` so the stale CWD-resolved path never leaks, and + ``filename`` carries the config's display name. A recorded-but-missing file answers 404. """ state: ServerState = request.app.state.server_state if not has_disk_path(state): return { "filename": state.config.title or "session-trial", - "content": "", + "content": state.trial_content or "", "config_path": None, "session_trial": True, } diff --git a/src/profiles/server/state.py b/src/profiles/server/state.py index 45f8be4..efc4e32 100644 --- a/src/profiles/server/state.py +++ b/src/profiles/server/state.py @@ -39,6 +39,12 @@ class ServerState: # True while the active config is an in-memory trial from a # dropped/loaded content payload (no disk anchor). session_trial: bool = False + # Raw YAML text behind ``session_trial`` trials (issue #97): the ONLY + # record of what was applied, since parsing discards the payload. + # GET /content echoes it so a reopened editor shows the live trial + # configuration. Cleared by every disk-anchoring path — it belongs to + # no real file, so anchoring invalidates it. + trial_content: str | None = None # Shared secret verifying handoff requests from a second ProFiles # launch (issue #4); None disables the identity/handoff endpoints. instance_token: str | None = None diff --git a/tests/server/routes/test_config_editor.py b/tests/server/routes/test_config_editor.py index 3f87baa..16d5273 100644 --- a/tests/server/routes/test_config_editor.py +++ b/tests/server/routes/test_config_editor.py @@ -130,12 +130,17 @@ async def test_content_flags_session_trial_after_live_drop( Ruling R4: ``_config_response`` re-records a resolved path into the state even for a drop, so trial detection must ride the explicit ``ServerState.session_trial`` flag — not path absence. + + The endpoint echoes the applied YAML text (issue #97): an editor + reopened after an Apply must show the live in-memory configuration, + not an empty body. """ + trial_text = "defaults:\n title: Dropped\n extensions:\n - .mttl\n" app = create_app(config_state) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: drop = await client.post( "/api/config/load-content", - json={"filename": "my.profiles", "content": "defaults:\n title: Dropped\n"}, + json={"filename": "my.profiles", "content": trial_text}, ) assert drop.status_code == 200 assert drop.json()["session_trial"] is True @@ -149,6 +154,43 @@ async def test_content_flags_session_trial_after_live_drop( assert payload["session_trial"] is True assert payload["config_path"] is None assert payload["filename"] == "Dropped" + assert payload["content"] == trial_text + + +@pytest.mark.anyio +async def test_content_returns_disk_text_after_trial_is_anchored( + config_state: ServerState, tmp_path: Path +) -> None: + """Verify anchoring the session to disk clears the stored trial text. + + Given: A session holding a session-trial config (applied content) + When: POST /load anchors a real disk config file + Then: GET /content returns the disk file text with session_trial + cleared — the trial content is dropped, never leaked into the + disk-anchored response + """ + disk_path = tmp_path / ".profiles" + disk_text = 'defaults:\n title: "On Disk"\n' + disk_path.write_text(disk_text, encoding="utf-8") + + app = create_app(config_state) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + drop = await client.post( + "/api/config/load-content", + json={"filename": "my.profiles", "content": "defaults:\n title: Dropped\n"}, + ) + assert drop.status_code == 200 + + load = await client.post("/api/config/load", json={"config_path": str(disk_path)}) + assert load.status_code == 200 + + response = await client.get("/api/config/content") + + assert response.status_code == 200 + payload = response.json() + assert payload["session_trial"] is False + assert payload["config_path"] == str(disk_path) + assert payload["content"] == disk_text @pytest.mark.anyio From f869743bc32c098733cc23009b294795447705ef Mon Sep 17 00:00:00 2001 From: "f.albany" Date: Wed, 4 Nov 2026 12:48:22 +0100 Subject: [PATCH 2/5] style(editor): solid VSCode-style CodeMirror theme + bordered editor card #98 #99 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visual issues from the post-#95 review, landed together because they touch exactly the same surface (the editor theme and its host). ## Issue #98 — solid VSCode-style theme The CodeMirror theme painted `transparent` over the frosted modal glass. New dedicated `--app-editor-*` tokens in index.css (both `data-theme` blocks — dark tracks VSCode Dark Modern values, light its slate/white counterparts): --app-editor-bg / gutter-bg / active-line / selection / border The theme object is rebuilt on them: solid canvas, opaque gutter with a borderRight of the new border token, visible indigo-alpha selection, clear active-line and active-line-gutter, matching/non-matching bracket colors, and the Ctrl+F search panel now sits on the solid editor background instead of the glass. ## Issue #99 — margin + bordered card The editor host now sits inside a padded body (`p-3 sm:p-4`) and a rounded card (`data-testid="editor-card"`, rounded-lg + the editor border/background tokens). Loading and error states render inside the card so the layout does not jump between states; the host stays a plain `h-full` child. ## TDD evidence - RED: 3 new vitest chrome tests failed for the right reasons (no token-backed canvas rule, tokens absent from index.css, no card). Two test refinements while redder than the code: CodeMirror merges built-in base-theme rules into the same