Skip to content

Commit b035eb6

Browse files
brettchienOrca (ecs-claude)claude
authored
feat(studio): edit fleets.toml in-app — CodeMirror TOML editor (ADR #19 C) (#30)
The edit half of the config panel: the operator edits the raw fleets.toml in a CodeMirror TOML editor and saves; the change validates, persists, and hot-reloads without a restart. Completes declare(edit) → switch → observe → reconcile. - studio-cp: `read_bindings_text` / `write_bindings_atomic` (temp+rename) / `save_bindings_text` — validate the text parses BEFORE writing (a bad edit never lands on disk) and store bytes verbatim, so comments/layout survive with no format-preserving lib. Unit tests: round-trip, reject-invalid-without-write, missing-file-is-empty. - oab-mcp: `fleet_config` now also returns the raw `text`; new write tool `fleet_config_write { text }` validates + writes + hot-reloads (bindings moved behind an RwLock; the per-cluster resolved-config memo is cleared on write). Catalog is 10 tools. - src-tauri: `fleet_config_write` bridge command. - console: CodeMirror 6 TOML editor (StreamLanguage + legacy TOML mode) mounted imperatively in a section separate from the re-rendered panel, so switching fleets never wipes an open edit. "Edit config" opens it; Save calls the write tool and surfaces a parse error inline; Cancel discards. 1 new render test. Slice C of the config panel, stacked on the A+B view/switch slice. Editing is raw-text (operator chose a TOML editor over a structured form). Co-authored-by: Orca (ecs-claude) <orca@ecs.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b72cb05 commit b035eb6

13 files changed

Lines changed: 552 additions & 22 deletions

File tree

console/index.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@
1919
</header>
2020
<main class="content">
2121
<section id="config" class="config-wrap"></section>
22+
<section id="config-editor" class="cfg-editor-wrap" hidden>
23+
<div class="cfg-editor-head">
24+
<span class="cfg-label">edit fleets.toml</span>
25+
<span class="cfg-editor-path" id="cfg-editor-path"></span>
26+
<span class="cfg-editor-spacer"></span>
27+
<button class="cfg-btn" id="cfg-save" type="button">Save</button>
28+
<button class="cfg-btn cfg-btn-ghost" id="cfg-cancel" type="button">Cancel</button>
29+
</div>
30+
<div id="cfg-editor-mount" class="cfg-editor-mount"></div>
31+
<div class="cfg-editor-error" id="cfg-editor-error" hidden></div>
32+
</section>
2233
<section id="identity" class="identity-wrap"></section>
2334
<section class="logs">
2435
<nav class="tabs" id="tabs">

console/package-lock.json

Lines changed: 160 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

console/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,12 @@
1616
"typescript": "^5.6.3",
1717
"vite": "^6.0.7",
1818
"vitest": "^2.1.8"
19+
},
20+
"dependencies": {
21+
"@codemirror/language": "^6.12.4",
22+
"@codemirror/legacy-modes": "^6.5.3",
23+
"@codemirror/state": "^6.7.1",
24+
"@codemirror/view": "^6.43.8",
25+
"codemirror": "^6.0.2"
1926
}
2027
}

console/src/fixtures.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,19 @@ export const FIXTURE_FLEET_CONFIG: FleetConfig = {
8383
expected_principal: null,
8484
},
8585
],
86+
text: `# OAB Studio fleet bindings — which credential manages which fleet.
87+
88+
[[fleet]]
89+
name = "prod"
90+
cluster = "oab"
91+
region = "ap-east-2"
92+
profile = "orca-prod"
93+
expected_principal = "arn:aws:iam::504190915686:role/openab-orca-task-role"
94+
95+
[[fleet]]
96+
name = "staging"
97+
cluster = "oab-staging"
98+
region = "ap-southeast-1"
99+
profile = "orca-staging"
100+
`,
86101
};

console/src/main.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { defaultSource } from "./source";
22
import { renderRoster, renderIdentity, renderFleetConfig } from "./render";
33
import type { FleetConfig } from "./types";
44
import { createPane, bindBackend, type Level } from "./log";
5+
import { EditorView, basicSetup } from "codemirror";
6+
import { EditorState } from "@codemirror/state";
7+
import { StreamLanguage } from "@codemirror/language";
8+
import { toml } from "@codemirror/legacy-modes/mode/toml";
59

610
const POLL_MS = 5000;
711
const DEFAULT_CLUSTER = "oab";
@@ -15,6 +19,12 @@ let fleetConfig: FleetConfig | null = null;
1519
const roster = document.getElementById("roster");
1620
const identityEl = document.getElementById("identity");
1721
const configEl = document.getElementById("config");
22+
const editorSection = document.getElementById("config-editor");
23+
const editorMount = document.getElementById("cfg-editor-mount");
24+
const editorError = document.getElementById("cfg-editor-error");
25+
const editorPathEl = document.getElementById("cfg-editor-path");
26+
const saveBtn = document.getElementById("cfg-save") as HTMLButtonElement | null;
27+
const cancelBtn = document.getElementById("cfg-cancel") as HTMLButtonElement | null;
1828
const clusterLabel = document.getElementById("cluster-label");
1929
const pollStatus = document.getElementById("poll-status");
2030
const logEl = document.getElementById("log");
@@ -138,10 +148,74 @@ function selectCluster(cluster: string): void {
138148
void tick();
139149
}
140150

141-
// One delegated listener: a click on any fleet button switches to its cluster.
151+
// ---- fleets.toml editor (ADR #19 slice C: the "edit" side) -------------------
152+
// A CodeMirror TOML editor over the raw config file. Kept imperative (CM owns
153+
// real DOM) and separate from the re-rendered config panel, so switching fleets
154+
// never wipes an open editor.
155+
let editorView: EditorView | null = null;
156+
157+
function showEditorError(msg: string | null): void {
158+
if (!editorError) return;
159+
editorError.textContent = msg ?? "";
160+
editorError.hidden = !msg;
161+
}
162+
163+
function openEditor(): void {
164+
if (!editorSection || !editorMount) return;
165+
showEditorError(null);
166+
if (editorPathEl) editorPathEl.textContent = fleetConfig?.path ?? "";
167+
editorView?.destroy();
168+
editorView = new EditorView({
169+
parent: editorMount,
170+
state: EditorState.create({
171+
doc: fleetConfig?.text ?? "",
172+
extensions: [basicSetup, StreamLanguage.define(toml)],
173+
}),
174+
});
175+
editorSection.hidden = false;
176+
editorView.focus();
177+
}
178+
179+
function closeEditor(): void {
180+
editorView?.destroy();
181+
editorView = null;
182+
if (editorSection) editorSection.hidden = true;
183+
showEditorError(null);
184+
}
185+
186+
async function saveEditor(): Promise<void> {
187+
if (!editorView || !saveBtn) return;
188+
const text = editorView.state.doc.toString();
189+
saveBtn.disabled = true;
190+
showEditorError(null);
191+
try {
192+
// The backend validates the TOML and rejects (without writing) on error.
193+
fleetConfig = await source.writeFleetConfig(text);
194+
if (configEl) renderFleetConfig(configEl, fleetConfig, activeCluster);
195+
note("info", "fleet config saved");
196+
closeEditor();
197+
// A binding change may alter the active fleet's credential — re-observe.
198+
void refreshIdentity();
199+
} catch (e) {
200+
showEditorError(`save failed — ${errText(e)}`);
201+
} finally {
202+
saveBtn.disabled = false;
203+
}
204+
}
205+
206+
saveBtn?.addEventListener("click", () => void saveEditor());
207+
cancelBtn?.addEventListener("click", () => closeEditor());
208+
209+
// One delegated listener on the config panel: "Edit config" opens the editor;
210+
// a click on any fleet button switches to its cluster.
142211
if (configEl) {
143212
configEl.addEventListener("click", (ev) => {
144-
const btn = (ev.target as HTMLElement).closest<HTMLElement>("[data-cluster]");
213+
const target = ev.target as HTMLElement;
214+
if (target.closest('[data-action="edit-config"]')) {
215+
openEditor();
216+
return;
217+
}
218+
const btn = target.closest<HTMLElement>("[data-cluster]");
145219
if (btn?.dataset.cluster) selectCluster(btn.dataset.cluster);
146220
});
147221
}

console/src/render.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,25 @@ describe("fleetConfigHtml", () => {
154154

155155
it("renders an empty state with the config path when no fleets", () => {
156156
const html = fleetConfigHtml(
157-
{ path: "~/.config/oab-studio/fleets.toml", default_cluster: "oab", fleets: [] },
157+
{
158+
path: "~/.config/oab-studio/fleets.toml",
159+
default_cluster: "oab",
160+
fleets: [],
161+
text: "",
162+
},
158163
"oab",
159164
);
160165
expect(html).toContain("No fleets configured");
161166
expect(html).toContain("fleets.toml");
162167
expect(html).not.toContain("cfg-fleet");
163168
});
164169

170+
it("always offers the Edit config action (even with fleets)", () => {
171+
expect(fleetConfigHtml(FIXTURE_FLEET_CONFIG, "oab")).toContain(
172+
'data-action="edit-config"',
173+
);
174+
});
175+
165176
it("renders an unavailable state for null", () => {
166177
expect(fleetConfigHtml(null, "oab")).toContain("fleet config unavailable");
167178
});

console/src/render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export function fleetConfigHtml(
163163
<div class="cfg-head">
164164
<span class="cfg-label">fleets</span>
165165
${path}
166+
<button class="cfg-edit" type="button" data-action="edit-config">Edit config</button>
166167
</div>
167168
${body}
168169
</div>`;

0 commit comments

Comments
 (0)