Skip to content

Commit a0ce641

Browse files
authored
feat: object model as source of truth with save/dirty editors (#155)
## Summary - Replace debounced sync with explicit save/dirty model across all editors - FmScript gains structured mutation API (AddStep, RemoveStep, MoveStep, FindSteps, etc.) - ScriptStep gains typed accessors (GetCalculation, GetFieldReference, GetScriptReference, etc.) - Fix multi-line comment merging bug: consecutive separate comments no longer incorrectly merged - Multi-line comments render truncated in text editor; full content preserved via model merge on save - All editors validate content on save — invalid content stays dirty, last valid state preserved - Plugins only notified with valid content after successful saves
1 parent 186047a commit a0ce641

File tree

15 files changed

+1349
-127
lines changed

15 files changed

+1349
-127
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
# Design: Object Model as Source of Truth
2+
3+
## Problem
4+
5+
SharpFM currently treats **display text as canonical** during script editing. The roundtrip is:
6+
7+
```
8+
XML → display text → [user edits text] → re-parse to model → XML
9+
```
10+
11+
This works for the text editor but makes programmatic access (agents via MCP, plugins, structured editors) fragile — they'd need to manipulate bracket syntax and parse display text. The object model (`FmScript`, `ScriptStep`, `StepParamValue`) already exists but is derived, not authoritative.
12+
13+
## Goal
14+
15+
Make the **object model the single source of truth** for all clip content. All editors, agents, and plugins read from and write to the model. Text and XML become projections (renderings) of the model, not the other way around.
16+
17+
```
18+
┌─── Script Text Editor (render/parse)
19+
20+
Object Model ───────┼─── XML Editor (render/parse)
21+
(truth) │
22+
├─── Table/Field DataGrid (render/bind)
23+
24+
├─── Agent / MCP (structured API)
25+
26+
└─── Plugin API (IPluginHost)
27+
```
28+
29+
## Scope
30+
31+
This design covers:
32+
- What the object model owns and how it's mutated
33+
- The contract between the model and its editors/consumers
34+
- How this integrates with the existing plugin system
35+
- How MCP/agent access would work against this model
36+
37+
This design does NOT cover:
38+
- Specific MCP tool definitions
39+
- UI changes to switch between editors
40+
- Detailed implementation of each editor adapter
41+
42+
---
43+
44+
## Core Concepts
45+
46+
### The Clip Model
47+
48+
A clip is already represented by `FileMakerClip` (name, format, XML) wrapped in `ClipViewModel` (editor state, change detection). The model-as-truth change deepens this: instead of `ClipViewModel` holding a text document that IS the state, it holds a **typed model** that renderers project into text/grid/XML.
49+
50+
For script clips, the typed model is `FmScript` (a list of `ScriptStep` objects).
51+
For table clips, the typed model is `FmTable` (a list of `FmField` objects).
52+
For other clip types, the model is the raw XML string (unchanged from today).
53+
54+
### Mutation Paths
55+
56+
Every change to clip content goes through the model:
57+
58+
| Source | How it mutates | Example |
59+
|--------|---------------|---------|
60+
| Script text editor | Parse changed text → update `FmScript.Steps` | User types a new line |
61+
| XML editor | Parse XML → rebuild model | User edits raw XML |
62+
| Table DataGrid | Direct property mutation on `FmField` | User changes field type |
63+
| Agent (MCP) | Structured API call → model mutation | "Add If step at index 3" |
64+
| Plugin | `IPluginHost.UpdateSelectedClipXml()` or future model API | Plugin reformats XML |
65+
66+
### Rendering (Model → View)
67+
68+
After any mutation, all **other** views re-render from the model:
69+
70+
| View | Renders from |
71+
|------|-------------|
72+
| Script text editor | `FmScript.ToDisplayText()` |
73+
| XML editor | `FmScript.ToXml()` (or raw XML for non-script clips) |
74+
| Table DataGrid | Binding to `FmTable.Fields` collection |
75+
| Plugin panels | `ClipContentChanged` event with `ClipInfo` snapshot |
76+
77+
The view that **caused** the mutation does NOT re-render from the model. It already has the user's state. This is the "origin tagging" pattern already used in the plugin system.
78+
79+
---
80+
81+
## Editor Contract
82+
83+
### Saved/Unsaved State
84+
85+
Each editor maintains a **local buffer** that is either **clean** (matches the model) or **dirty** (user has unsaved changes). The model only changes when the user explicitly **saves** or when an external mutation occurs.
86+
87+
- **Clean**: editor content matches the model. No risk of data loss.
88+
- **Dirty**: editor has unsaved changes that haven't been flushed to the model.
89+
90+
This is NOT a live/real-time sync. Edits are batched — the user works freely in their editor and saves when ready. The model updates discretely, not on every keystroke.
91+
92+
### Save (User Action)
93+
94+
When the user saves (Ctrl+S, button, or editor-specific trigger):
95+
96+
1. Editor **parses** its local state into the model (`FmScript.FromDisplayText()` or `FmScript.FromXml()`).
97+
2. Model **replaces** its state with the parsed result.
98+
3. Model fires `StepsChanged` event.
99+
4. Other views **re-render** from the updated model.
100+
5. The saving editor is marked **clean**.
101+
102+
### External Mutation (Agent, Plugin, Other Editor)
103+
104+
When the model changes from any external source:
105+
106+
1. Model fires `StepsChanged` event with origin tag.
107+
2. All editors **re-render** from the model, replacing their local buffer.
108+
3. **Unsaved changes in the active editor are lost.** This is acceptable — external mutations are discrete operations (agent commands, plugin actions), not continuous collaborative edits. The user can see the change and continue editing.
109+
4. **Cursor position is preserved.** After re-rendering, restore the cursor offset (clamped to the new text length). This avoids disorienting the user.
110+
5. All editors are marked **clean** after re-render.
111+
112+
### Dirty State and External Mutations
113+
114+
If an editor is dirty and an external mutation arrives, the editor re-renders and the unsaved changes are lost. This is the explicit contract: **save early if you want to keep your work.** The UI should indicate dirty state (e.g., dot on tab, modified indicator) so the user knows they have unsaved changes.
115+
116+
Future consideration: we could prompt "You have unsaved changes — discard?" before applying external mutations. But for the initial implementation, the simpler model (external wins) is sufficient.
117+
118+
### Editor Transitions
119+
120+
When the user switches from editor A to editor B:
121+
122+
1. If editor A is **dirty**, prompt to save or discard (or auto-save — TBD).
123+
2. Editor B **renders** from the model.
124+
3. Editor B becomes the active editor, marked **clean**.
125+
126+
### No Debounce Needed
127+
128+
Since the model only changes on explicit save or external mutation, there's no need for debounced sync timers. The generation counter / debounce pattern currently in `ClipViewModel` can be removed in favor of this simpler contract.
129+
130+
---
131+
132+
## Model Mutation API
133+
134+
The `FmScript` model needs a mutation API beyond the current factory methods. These operations are what editors, agents, and plugins would use:
135+
136+
### Script Operations
137+
138+
```
139+
AddStep(int index, ScriptStep step)
140+
RemoveStep(int index)
141+
MoveStep(int fromIndex, int toIndex)
142+
UpdateStep(int index, ScriptStep replacement)
143+
ReplaceSteps(IReadOnlyList<ScriptStep> steps) // bulk: parse from text/XML
144+
```
145+
146+
### Step-Level Operations
147+
148+
```
149+
SetEnabled(bool enabled)
150+
SetParamValue(string paramName, string? value)
151+
```
152+
153+
### Query Operations (for agents/plugins)
154+
155+
```
156+
FindSteps(string stepName) → IReadOnlyList<(int index, ScriptStep step)>
157+
GetStep(int index) → ScriptStep
158+
StepCount → int
159+
```
160+
161+
### Change Notification
162+
163+
```
164+
event StepsChanged // fired after any mutation, with change details
165+
```
166+
167+
The `StepsChanged` event carries enough info for renderers to decide whether to do a full re-render or a targeted update (e.g., single step changed at index 5).
168+
169+
---
170+
171+
## Table/Field Model
172+
173+
Tables already have a typed model (`FmTable` with `FmField` list). The same principles apply:
174+
175+
- `FmTable` is the source of truth
176+
- DataGrid binds to `FmTable.Fields`
177+
- XML editor renders from `FmTable.ToXml()`
178+
- Agent/MCP can call `AddField()`, `RemoveField()`, `UpdateField()`
179+
180+
The table model is already closer to this architecture than scripts are.
181+
182+
---
183+
184+
## Integration with Plugin System
185+
186+
### Current: XML-level
187+
188+
Plugins interact via `IPluginHost.UpdateSelectedClipXml(xml, originId)`. This stays as-is for backwards compatibility and for plugins that want to work with raw XML.
189+
190+
### Future: Model-level (additive)
191+
192+
Add model-aware methods to `IPluginHost`:
193+
194+
```
195+
IFmScript? GetScriptModel() // null if not a script clip
196+
IFmTable? GetTableModel() // null if not a table clip
197+
void UpdateScriptModel(Action<IFmScript> mutation, string originId)
198+
void UpdateTableModel(Action<IFmTable> mutation, string originId)
199+
```
200+
201+
The `Action<IFmScript>` pattern lets plugins make multiple mutations atomically — the model fires a single change event after the action completes.
202+
203+
Plugins compiled against the old API still work (XML-level). New plugins can use the model API for structured access. The host translates between them: an XML push re-parses the model; a model mutation re-derives the XML for legacy listeners.
204+
205+
### MCP Tools
206+
207+
MCP tools would wrap the same model API:
208+
209+
```
210+
sharpfm_script_add_step(index, step_name, params)
211+
sharpfm_script_remove_step(index)
212+
sharpfm_script_find_steps(step_name) → list of steps
213+
sharpfm_script_get_all_steps() → full script model
214+
sharpfm_table_add_field(name, type, ...)
215+
sharpfm_table_get_fields() → field list
216+
```
217+
218+
These are thin wrappers over the mutation/query API on the model.
219+
220+
---
221+
222+
## Migration Path
223+
224+
### Phase A — Model as hub for scripts
225+
226+
1. `FmScript` gains mutation methods (`AddStep`, `RemoveStep`, etc.)
227+
2. `FmScript` gains `StepsChanged` event
228+
3. `ScriptClipEditor` refactored: text changes → parse → `ReplaceSteps()` on model; model changes → re-render text (unless self-originated)
229+
4. XML editor for scripts: XML changes → `FmScript.FromXml()``ReplaceSteps()`; model changes → `FmScript.ToXml()` → re-render XML
230+
5. All existing tests still pass — roundtrip behavior unchanged
231+
232+
### Phase B — Model-level plugin/agent API
233+
234+
6. Add `GetScriptModel()` / `UpdateScriptModel()` to `IPluginHost`
235+
7. Add MCP tool definitions that wrap the model API
236+
8. Origin tagging extended to model-level mutations
237+
238+
### Phase C — Table model alignment
239+
240+
9. Apply the same pattern to `FmTable` (already mostly there)
241+
10. `TableClipEditor` refactored to same hub pattern
242+
11. Add `GetTableModel()` / `UpdateTableModel()` to `IPluginHost`
243+
244+
### Phase D — Typed step accessors
245+
246+
12. Add convenience accessors on `ScriptStep` for common patterns:
247+
- `GetCalculation()`, `GetFieldReference()`, `GetScriptReference()`
248+
- `GetNamedParam(string label)` — typed wrapper over `ParamValues`
249+
13. These are additive — no breaking changes, just ergonomic API for agents/plugins
250+
251+
---
252+
253+
## What Doesn't Change
254+
255+
- `FileMakerClip` still holds the XML string as the persistence format
256+
- `ClipRepository` / `IClipRepository` still loads/saves XML
257+
- `StepCatalog` / `StepDefinition` / `StepParam` unchanged
258+
- Handler registry unchanged (handlers render/serialize, they don't own state)
259+
- Plugin `IPluginHost.UpdateSelectedClipXml()` still works
260+
- Display text format (`Step [ param ; param ]`) unchanged
261+
- All existing tests pass at each phase
Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,85 @@
11
using System;
2-
using Avalonia.Threading;
2+
using System.Xml.Linq;
33
using AvaloniaEdit.Document;
44

55
namespace SharpFM.Editors;
66

77
/// <summary>
88
/// Editor for clips with no specialized editor (layouts, unknown formats).
99
/// The user edits the raw XML directly via a TextDocument.
10+
/// Save validates the XML before accepting it — invalid XML stays dirty.
1011
/// </summary>
1112
public class FallbackXmlEditor : IClipEditor
1213
{
13-
private readonly DispatcherTimer _debounceTimer;
14+
private string _savedXml;
15+
private bool _suppressDirty;
1416

15-
public event EventHandler? ContentChanged;
17+
public event EventHandler? BecameDirty;
18+
public event EventHandler? Saved;
1619

1720
/// <summary>The TextDocument bound to the AvaloniaEdit XML editor.</summary>
1821
public TextDocument Document { get; }
1922

20-
public bool IsPartial => false;
23+
public bool IsDirty { get; private set; }
24+
public bool IsPartial { get; private set; }
2125

2226
public FallbackXmlEditor(string? xml)
2327
{
24-
Document = new TextDocument(xml ?? "");
28+
_savedXml = xml ?? "";
29+
Document = new TextDocument(_savedXml);
2530

26-
_debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
27-
_debounceTimer.Tick += (_, _) =>
31+
Document.TextChanged += OnTextChanged;
32+
}
33+
34+
private void OnTextChanged(object? sender, EventArgs e)
35+
{
36+
if (_suppressDirty) return;
37+
if (!IsDirty)
2838
{
29-
_debounceTimer.Stop();
30-
ContentChanged?.Invoke(this, EventArgs.Empty);
31-
};
39+
IsDirty = true;
40+
BecameDirty?.Invoke(this, EventArgs.Empty);
41+
}
42+
}
43+
44+
public bool Save()
45+
{
46+
var text = Document.Text;
3247

33-
Document.TextChanged += (_, _) =>
48+
// Validate XML before accepting
49+
try
3450
{
35-
_debounceTimer.Stop();
36-
_debounceTimer.Start();
37-
};
51+
XDocument.Parse(text);
52+
}
53+
catch
54+
{
55+
IsPartial = true;
56+
return false;
57+
}
58+
59+
_savedXml = text;
60+
IsPartial = false;
61+
IsDirty = false;
62+
Saved?.Invoke(this, EventArgs.Empty);
63+
return true;
3864
}
3965

40-
public string ToXml() => Document.Text;
66+
public string ToXml() => _savedXml;
4167

4268
public void FromXml(string xml)
4369
{
44-
Document.Text = xml;
70+
_savedXml = xml;
71+
72+
_suppressDirty = true;
73+
try
74+
{
75+
Document.Text = xml;
76+
}
77+
finally
78+
{
79+
_suppressDirty = false;
80+
}
81+
82+
IsDirty = false;
83+
IsPartial = false;
4584
}
4685
}

0 commit comments

Comments
 (0)