diff --git a/TuneLab.GUI/GUI/Assets.cs b/TuneLab.GUI/GUI/Assets.cs
index 2562b718..7c224fbd 100644
--- a/TuneLab.GUI/GUI/Assets.cs
+++ b/TuneLab.GUI/GUI/Assets.cs
@@ -16,6 +16,8 @@ internal static class Assets
public static SvgIcon Part = new("");
// 音符(双符头 + 横梁),用作 Note 属性侧栏页签图标,与卡片状的 Part 图标区分。
public static SvgIcon Note = new("");
+ // 历史(逆时针回转箭头 + 时钟指针),用于可导航的撤销/重做记录侧栏。
+ public static SvgIcon History = new("");
public static SvgIcon Properties = new("\r\n");
public static SvgIcon Track = new("");
public static SvgIcon Gain = new("");
diff --git a/TuneLab.GUI/GUI/Controllers/ArrayController.cs b/TuneLab.GUI/GUI/Controllers/ArrayController.cs
index 77ca1eaf..88274b2d 100644
--- a/TuneLab.GUI/GUI/Controllers/ArrayController.cs
+++ b/TuneLab.GUI/GUI/Controllers/ArrayController.cs
@@ -27,16 +27,18 @@ protected ArrayControllerBase()
}
// 绑定到(新的)数组数据外观;切换数据时清空旧行(复用的 widget 仍绑在旧 token 上)。
- public void Bind(IDataPropertyArray array)
+ public void Bind(IDataPropertyArray array, string? detail = null)
{
ResetRows();
mArray = array;
+ mDetail = detail;
}
public void Unbind()
{
ResetRows();
mArray = null;
+ mDetail = null;
}
protected virtual bool Deletable => false;
@@ -66,7 +68,7 @@ void RemoveRow(int position, string? token)
return;
mArray.RemoveAt(index);
- mArray.Commit();
+ mArray.Commit("Delete List Item", mDetail);
}
// 行的删除回调(Deletable 时非 null):真实行按 token、seed 行按位置。
@@ -131,7 +133,7 @@ protected void ReconcileRows(IReadOnlyList elements)
}
else
{
- nextByKey.Add(key, new ElementRow(host, key, cfg, onDelete));
+ nextByKey.Add(key, new ElementRow(host, key, cfg, mDetail, onDelete));
structureChanged = true; // 新建(含同 key 换类型 / seed 位物化为真实位)
}
nextOrder.Add(key);
@@ -195,6 +197,8 @@ void ResetRows()
}
protected IDataPropertyArray? mArray;
+ protected string? Detail => mDetail;
+ string? mDetail;
IReadOnlyList mElements = []; // 当前各元素 config(用于 seed 物化默认值)
readonly StackPanel mRowsPanel = new() { Orientation = Orientation.Vertical };
Dictionary mRowsByKey = new();
@@ -254,7 +258,7 @@ void AddElement(AddableElement addable)
{
MaterializeSeed(); // 先把当前展示的 seed 行物化为真实元素,再追加——否则其余 seed 行会塌掉
Array.Add(addable.Template.GetDefaultValue());
- Array.Commit();
+ Array.Commit("Add List Item", Detail);
}
readonly Button mAddButton;
@@ -268,10 +272,10 @@ sealed class ElementRow : IDisposable
public Control Root => mRoot;
public Type ConfigType => mWidget.ConfigType;
- public ElementRow(IDataPropertyObject host, string bindKey, IControllerConfig config, Action? onDelete)
+ public ElementRow(IDataPropertyObject host, string bindKey, IControllerConfig config, string? detail, Action? onDelete)
{
// host = 现存位的数组外观(bindKey = token)或越界位的 SeedPositionView(bindKey 仅作行身份、视图按 position 寻址)。
- mWidget = ElementWidget.Create(host, bindKey, config);
+ mWidget = ElementWidget.Create(host, bindKey, config, detail);
mRoot = new LayerPanel { Background = Brushes.Transparent, Margin = new(24, 6, 24, 6) };
mRoot.Children.Add(mWidget.View); // 底层:控件填满整行
@@ -313,26 +317,26 @@ internal abstract class ElementWidget : IDisposable
protected readonly DisposableManager s = new();
- public static ElementWidget Create(IDataPropertyObject dataObject, string token, IControllerConfig config) => config switch
+ public static ElementWidget Create(IDataPropertyObject dataObject, string token, IControllerConfig config, string? detail = null) => config switch
{
- SliderConfig c => new SliderElement(dataObject, token, c),
- TextBoxConfig c => new TextElement(dataObject, token, c),
- ComboBoxConfig c => new ComboElement(dataObject, token, c),
- CheckBoxConfig c => new CheckElement(dataObject, token, c),
+ SliderConfig c => new SliderElement(dataObject, token, c, detail),
+ TextBoxConfig c => new TextElement(dataObject, token, c, detail),
+ ComboBoxConfig c => new ComboElement(dataObject, token, c, detail),
+ CheckBoxConfig c => new CheckElement(dataObject, token, c, detail),
ObjectConfig c => new ObjectElement(dataObject, token, c),
- ArrayConfig c => new NestedArrayElement(dataObject, token, c),
- ListConfig c => new NestedListElement(dataObject, token, c),
- ExtensibleObjectConfig c => new NestedExtensibleObjectElement(dataObject, token, c),
+ ArrayConfig c => new NestedArrayElement(dataObject, token, c, detail),
+ ListConfig c => new NestedListElement(dataObject, token, c, detail),
+ ExtensibleObjectConfig c => new NestedExtensibleObjectElement(dataObject, token, c, detail),
_ => new UnknownElement(config),
};
sealed class SliderElement : ElementWidget
{
- public SliderElement(IDataPropertyObject dataObject, string token, SliderConfig config)
+ public SliderElement(IDataPropertyObject dataObject, string token, SliderConfig config, string? detail)
{
mController = new SliderController { HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch };
Apply(config);
- mController.BindDataProperty(dataObject.DoubleField(token, config.DefaultValue), s);
+ mController.BindDataProperty(dataObject.DoubleField(token, config.DefaultValue), s, detail: detail);
}
void Apply(SliderConfig config)
@@ -352,10 +356,10 @@ void Apply(SliderConfig config)
sealed class TextElement : ElementWidget
{
- public TextElement(IDataPropertyObject dataObject, string token, TextBoxConfig config)
+ public TextElement(IDataPropertyObject dataObject, string token, TextBoxConfig config, string? detail)
{
mController = new SingleLineTextController { HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, IsPassword = config.IsPassword };
- mController.BindDataProperty(dataObject.StringField(token, config.DefaultValue), s);
+ mController.BindDataProperty(dataObject.StringField(token, config.DefaultValue), s, detail: detail);
}
public override Control View => mController;
@@ -367,10 +371,11 @@ public TextElement(IDataPropertyObject dataObject, string token, TextBoxConfig c
sealed class ComboElement : ElementWidget
{
- public ComboElement(IDataPropertyObject dataObject, string token, ComboBoxConfig config)
+ public ComboElement(IDataPropertyObject dataObject, string token, ComboBoxConfig config, string? detail)
{
mDataObject = dataObject;
mToken = token;
+ mDetail = detail;
mConfig = config;
mController = new ComboBoxController { HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch };
BindWith(config);
@@ -380,7 +385,7 @@ public ComboElement(IDataPropertyObject dataObject, string token, ComboBoxConfig
void BindWith(ComboBoxConfig config)
{
mController.SetConfig(config);
- mController.BindDataProperty(mDataObject.ValueField(mToken, config.DefaultOption.Value), s);
+ mController.BindDataProperty(mDataObject.ValueField(mToken, config.DefaultOption.Value), s, detail: mDetail);
}
public override Control View => mController;
@@ -399,16 +404,17 @@ public override void Update(IControllerConfig config)
readonly IDataPropertyObject mDataObject;
readonly string mToken;
+ readonly string? mDetail;
readonly ComboBoxController mController;
ComboBoxConfig mConfig;
}
sealed class CheckElement : ElementWidget
{
- public CheckElement(IDataPropertyObject dataObject, string token, CheckBoxConfig config)
+ public CheckElement(IDataPropertyObject dataObject, string token, CheckBoxConfig config, string? detail)
{
mController = new CheckBox { VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center };
- mController.BindDataProperty(dataObject.BooleanField(token, config.DefaultValue), s);
+ mController.BindDataProperty(dataObject.BooleanField(token, config.DefaultValue), s, detail: detail);
}
public override Control View => mController;
@@ -442,9 +448,9 @@ public override void Dispose()
// 数组套数组元素:嵌 ArrayController/ListController,导航进 dataObject.Array(token)。
sealed class NestedArrayElement : ElementWidget
{
- public NestedArrayElement(IDataPropertyObject dataObject, string token, ArrayConfig config)
+ public NestedArrayElement(IDataPropertyObject dataObject, string token, ArrayConfig config, string? detail)
{
- mController.Bind(dataObject.Array(token));
+ mController.Bind(dataObject.Array(token), detail);
mController.Apply(config);
}
@@ -462,9 +468,9 @@ public override void Dispose()
sealed class NestedListElement : ElementWidget
{
- public NestedListElement(IDataPropertyObject dataObject, string token, ListConfig config)
+ public NestedListElement(IDataPropertyObject dataObject, string token, ListConfig config, string? detail)
{
- mController.Bind(dataObject.Array(token));
+ mController.Bind(dataObject.Array(token), detail);
mController.Apply(config);
}
@@ -483,9 +489,9 @@ public override void Dispose()
// 数组套变长键控对象元素:嵌 ExtensibleObjectController,导航进 dataObject.Object(token)。
sealed class NestedExtensibleObjectElement : ElementWidget
{
- public NestedExtensibleObjectElement(IDataPropertyObject dataObject, string token, ExtensibleObjectConfig config)
+ public NestedExtensibleObjectElement(IDataPropertyObject dataObject, string token, ExtensibleObjectConfig config, string? detail)
{
- mController.Bind(dataObject.Object(token));
+ mController.Bind(dataObject.Object(token), detail);
mController.Apply(config);
}
@@ -532,6 +538,7 @@ abstract class ForwardingDataObject(IDataObject inner) : IDataObject
public void EndMergeNotify() => inner.EndMergeNotify();
public bool Pushable() => inner.Pushable();
public bool Commit() => inner.Commit();
+ public bool Commit(string description, string? detail = null) => inner.Commit(description, detail);
public bool Discard() => inner.Discard();
public bool DiscardTo(Head head) => inner.DiscardTo(head);
public bool Undo() => inner.Undo();
diff --git a/TuneLab.GUI/GUI/Controllers/ExtensibleObjectController.cs b/TuneLab.GUI/GUI/Controllers/ExtensibleObjectController.cs
index d34b9d1c..5de19f3f 100644
--- a/TuneLab.GUI/GUI/Controllers/ExtensibleObjectController.cs
+++ b/TuneLab.GUI/GUI/Controllers/ExtensibleObjectController.cs
@@ -31,16 +31,18 @@ public ExtensibleObjectController()
Children.Add(mAddButton);
}
- public void Bind(IDataPropertyObject dataObject)
+ public void Bind(IDataPropertyObject dataObject, string? detail = null)
{
ResetRows();
mDataObject = dataObject;
+ mDetail = detail;
}
public void Unbind()
{
ResetRows();
mDataObject = null;
+ mDetail = null;
}
public void Apply(ExtensibleObjectConfig config)
@@ -164,7 +166,8 @@ void AddKey(AddableKey addable)
if (mDataObject == null)
return;
mDataObject.SetValue(addable.Key.Id, addable.Template.GetDefaultValue());
- mDataObject.Commit();
+ var detail = addable.Key.DisplayText ?? addable.Key.Id;
+ mDataObject.Commit("Add Property", string.IsNullOrWhiteSpace(detail) ? mDetail : detail);
}
// 删键 = 移除该键(presence 翻回 absent),提交。
@@ -172,8 +175,9 @@ void RemoveKey(string id)
{
if (mDataObject == null)
return;
+ var detail = mRowsByKey.TryGetValue(id, out var row) ? row.Detail : id;
mDataObject.RemoveValue(id);
- mDataObject.Commit();
+ mDataObject.Commit("Delete Property", string.IsNullOrWhiteSpace(detail) ? mDetail : detail);
}
void ResetRows()
@@ -187,6 +191,7 @@ void ResetRows()
}
IDataPropertyObject? mDataObject;
+ string? mDetail;
IReadOnlyList mAddableElements = [];
HashSet mPresentKeys = new();
readonly StackPanel mRowsPanel = new() { Orientation = Orientation.Vertical };
@@ -200,11 +205,13 @@ sealed class KeyedRow : IDisposable
{
public Control Root => mRoot;
public Type ConfigType => mWidget.ConfigType;
+ public string Detail { get; private set; }
public KeyedRow(IDataPropertyObject dataObject, PropertyKey key, IControllerConfig config, Action onDelete)
{
- mWidget = ElementWidget.Create(dataObject, key.Id, config);
- mTitle = ArrayControlsFactory.MakeRowTitle(key.DisplayText ?? key.Id);
+ Detail = key.DisplayText ?? key.Id;
+ mWidget = ElementWidget.Create(dataObject, key.Id, config, Detail);
+ mTitle = ArrayControlsFactory.MakeRowTitle(Detail);
var content = new StackPanel { Orientation = Orientation.Vertical };
content.Children.Add(mTitle);
@@ -228,7 +235,8 @@ public KeyedRow(IDataPropertyObject dataObject, PropertyKey key, IControllerConf
public void Update(PropertyKey key, IControllerConfig config)
{
- mTitle.Content = key.DisplayText ?? key.Id; // 语言切换等仅 DisplayText 变:重贴标签、不重建
+ Detail = key.DisplayText ?? key.Id;
+ mTitle.Content = Detail;
mWidget.Update(config);
}
diff --git a/TuneLab.GUI/GUI/Controllers/IDataValueController.cs b/TuneLab.GUI/GUI/Controllers/IDataValueController.cs
index 848f9919..fca20fc0 100644
--- a/TuneLab.GUI/GUI/Controllers/IDataValueController.cs
+++ b/TuneLab.GUI/GUI/Controllers/IDataValueController.cs
@@ -30,17 +30,17 @@ public static IDataValueController Select(this IDataValueController val
return new SelectController(valueController, to, x => x);
}
- public static void Bind(this IDataValueController controller, IHolder> propertyHolder, DisposableManager? context = null) where T : notnull
+ public static void Bind(this IDataValueController controller, IHolder> propertyHolder, DisposableManager? context = null, string description = "Edit Properties", string? detail = null) where T : notnull
{
- var binding = new DataPropertyHolderBinding(controller, propertyHolder);
+ var binding = new DataPropertyHolderBinding(controller, propertyHolder, description, detail);
context?.Add(binding);
}
// 把一个固定的 IDataProperty 直接绑定到控件(属性面板逐字段绑定用)。
// 字段对象由面板在 SetConfig 时一次性创建,对象切换时整面板重建,故用常量 provider(事件永不触发)即可。
- public static void BindDataProperty(this IDataValueController controller, IDataProperty property, DisposableManager? context = null) where T : notnull
+ public static void BindDataProperty(this IDataValueController controller, IDataProperty property, DisposableManager? context = null, string description = "Edit Properties", string? detail = null) where T : notnull
{
- controller.Bind(new ConstantHolder>(property), context);
+ controller.Bind(new ConstantHolder>(property), context, description, detail);
}
class ConstantHolder(T value) : IHolder
@@ -53,51 +53,43 @@ class ConstantHolder(T value) : IHolder
class DataPropertyHolderBinding : IDisposable where T : notnull
{
- public DataPropertyHolderBinding(IDataValueController controller, IHolder> propertyHolder)
+ public DataPropertyHolderBinding(IDataValueController controller, IHolder> propertyHolder, string description, string? detail)
{
mController = controller;
mPropertyHolder = propertyHolder;
+ mDescription = description;
+ mDetail = detail;
mController.ValueWillChange.Subscribe(() =>
{
- if (Property == null)
+ var property = Property;
+ if (property == null || mEditingProperty != null)
return;
// 编辑全程套一层 merge:中间态(每帧/每键的 Set)被纳入同一作用域,只发 canIgnore 中间通知、不发结果态,
// 直到 ValueCommitted 退出 merge 才发一次结果态。使"结果态 Modified = 用户提交"语义成立——面板重算
//(订阅结果态)只在提交时触发、拖动/输入过程中不触发;并把整段编辑归为一个撤销单元。
- if (!mMerging)
- {
- Property.BeginMergeNotify();
- mMerging = true;
- }
+ mEditingProperty = property;
+ mMergeStartHead = property.Head;
+ mCanDiscardEmptyMerge = property.Pushable();
+ property.BeginMergeNotify();
// mHead 必须捕获在 BeginMergeNotify 之后:ValueChanged 里 DiscardTo(mHead) 只回退本次编辑写入的值,
// 绝不能把 BeginMergeNotify 命令一并回退——否则首个 ValueChanged 就把 merge 作用域撤销掉,flag 归 0,
// 此后每次 Set 都落在 flag=0 发结果态,使拖动/输入全程持续触发面板重算(merge 形同虚设)。
- mHead = Property.Head;
+ mHead = property.Head;
}, s);
mController.ValueChanged.Subscribe(() =>
{
- if (Property == null)
+ var property = mEditingProperty;
+ if (property == null)
return;
- var value = mController.Value;
- Property.DiscardTo(mHead);
- Property.Set(value);
+ var value = mController.Value;
+ property.DiscardTo(mHead);
+ property.Set(value);
}, s);
- mController.ValueCommitted.Subscribe(() =>
- {
- if (Property == null)
- return;
-
- EndMerge();
- var head = Property.Head;
- if (mHead == head)
- return;
-
- Property.Commit();
- }, s);
+ mController.ValueCommitted.Subscribe(() => FinishEdit(true), s);
// 跟随属性内容变化刷新控件显示。订阅带 canIgnore 的形式,中间态(canIgnore==true)与结果态都刷新,
// 使本控件能实时反映他处编辑(如钢琴窗拖动)对同一属性的中间过程,而不必等其提交。
@@ -114,18 +106,44 @@ public void Dispose()
{
// 兜底:编辑中途绑定被释放(如编辑时切换选中导致控件重绑)时补一次 EndMergeNotify,
// 否则 BeginMergeNotify 无配对、数据对象 merge 计数泄漏,将永不再发结果态。
- EndMerge();
+ FinishEdit(false);
s.DisposeAll();
}
+ // 必须在 EndMergeNotify 前判断是否真的写入过数据:EndMergeNotify 自身也是命令,会推进 Head。
+ // 无变化时关闭 merge 后撤掉空的 begin/end 命令对;若编辑开始时文档是干净的,Discard 还能补发
+ // 最终 StatusChanged,使保存标记和 Undo/Redo 可用状态回到编辑前。
+ void FinishEdit(bool commit)
+ {
+ var property = mEditingProperty;
+ if (property == null)
+ return;
+
+ bool dataChanged = property.Head != mHead;
+ EndMerge();
+
+ if (!dataChanged)
+ {
+ if (mCanDiscardEmptyMerge)
+ property.Discard();
+ else
+ property.DiscardTo(mMergeStartHead);
+ return;
+ }
+
+ if (commit)
+ property.Commit(mDescription, mDetail);
+ }
+
// 退出编辑 merge(幂等):仅在已进入时 EndMergeNotify,避免无配对的多发。
void EndMerge()
{
- if (!mMerging)
+ var property = mEditingProperty;
+ if (property == null)
return;
- mMerging = false;
- Property?.EndMergeNotify();
+ mEditingProperty = null;
+ property.EndMergeNotify();
}
// 按三态分派:原始值为 Multiple→DisplayMultiple、Invalid→DisplayNull,否则 coerce 成 T 走 Display。
@@ -160,10 +178,14 @@ void Refresh()
IDataProperty? Property => mPropertyHolder.Value;
Head mHead;
- bool mMerging;
+ Head mMergeStartHead;
+ bool mCanDiscardEmptyMerge;
readonly DisposableManager s = new();
readonly IDataValueController mController;
readonly IHolder> mPropertyHolder;
+ readonly string mDescription;
+ readonly string? mDetail;
+ IDataProperty? mEditingProperty;
}
}
diff --git a/TuneLab.GUI/GUI/Controllers/PropertyObjectController.cs b/TuneLab.GUI/GUI/Controllers/PropertyObjectController.cs
index 4e6407c7..5931d0d0 100644
--- a/TuneLab.GUI/GUI/Controllers/PropertyObjectController.cs
+++ b/TuneLab.GUI/GUI/Controllers/PropertyObjectController.cs
@@ -272,7 +272,7 @@ public SliderCreator(PropertyObjectController parent, PropertyKey key, SliderCon
// 先绑定(初次刷新即把真实值写入),Relayout 才加入可视树——否则池复用的控件会以残留旧值/旧量程
// 先布局渲染一帧,thumb 随后才跳到正确位置(初次选中音符时可见的瞬间挪动)。
- mController.BindDataProperty(parent.DataObject.DoubleField(key.Id, config.DefaultValue), s);
+ mController.BindDataProperty(parent.DataObject.DoubleField(key.Id, config.DefaultValue), s, detail: key.DisplayText ?? key.Id);
}
void Apply(SliderConfig config)
@@ -324,7 +324,7 @@ public DraggableNumberBoxCreator(PropertyObjectController parent, PropertyKey ke
Apply(config);
AttachContextMenu(mDockPanel, key, () => mConfig);
- mController.BindDataProperty(parent.DataObject.DoubleField(key.Id, config.DefaultValue), s);
+ mController.BindDataProperty(parent.DataObject.DoubleField(key.Id, config.DefaultValue), s, detail: key.DisplayText ?? key.Id);
}
void Apply(DraggableNumberBoxConfig config)
@@ -366,7 +366,7 @@ public SingleLineTextCreator(PropertyObjectController parent, PropertyKey key, T
mController.Margin = new(24, 12);
mController.IsPassword = config.IsPassword;
- mController.BindDataProperty(parent.DataObject.StringField(key.Id, config.DefaultValue), s);
+ mController.BindDataProperty(parent.DataObject.StringField(key.Id, config.DefaultValue), s, detail: key.DisplayText ?? key.Id);
}
public override Type ConfigType => typeof(TextBoxConfig);
@@ -389,6 +389,7 @@ class ComboBoxCreator : Creator
public ComboBoxCreator(PropertyObjectController parent, PropertyKey key, ComboBoxConfig config) : base(parent)
{
mKey = key.Id;
+ mDetail = key.DisplayText ?? key.Id;
mConfig = config;
mTitle = CreateTitle(key.DisplayText ?? key.Id, 30);
@@ -404,7 +405,7 @@ void BindWith(ComboBoxConfig config)
{
mController.SetConfig(config);
// 绑裸 PropertyValue 字段:option 值可为任意基础类型,存进数据的就是该值本身(非显示文本)。
- mController.BindDataProperty(Parent.DataObject.ValueField(mKey, config.DefaultOption.Value), s);
+ mController.BindDataProperty(Parent.DataObject.ValueField(mKey, config.DefaultOption.Value), s, detail: mDetail);
}
public override Type ConfigType => typeof(ComboBoxConfig);
@@ -430,6 +431,7 @@ public override void Dispose()
}
readonly string mKey;
+ readonly string mDetail;
readonly Label mTitle;
readonly ComboBoxController mController;
ComboBoxConfig mConfig;
@@ -442,7 +444,7 @@ public ArrayCreator(PropertyObjectController parent, PropertyKey key, ArrayConfi
{
mTitle = CreateTitle(key.DisplayText ?? key.Id, 30);
mController = new ArrayController();
- mController.Bind(parent.DataObject.Array(key.Id));
+ mController.Bind(parent.DataObject.Array(key.Id), key.DisplayText ?? key.Id);
mController.Apply(config);
}
@@ -467,7 +469,7 @@ public ListCreator(PropertyObjectController parent, PropertyKey key, ListConfig
{
mTitle = CreateTitle(key.DisplayText ?? key.Id, 30);
mController = new ListController();
- mController.Bind(parent.DataObject.Array(key.Id));
+ mController.Bind(parent.DataObject.Array(key.Id), key.DisplayText ?? key.Id);
mController.Apply(config);
}
@@ -494,7 +496,7 @@ public ExtensibleObjectCreator(PropertyObjectController parent, PropertyKey key,
{
mTitle = CreateTitle(key.DisplayText ?? key.Id, 30);
mController = new ExtensibleObjectController();
- mController.Bind(parent.DataObject.Object(key.Id));
+ mController.Bind(parent.DataObject.Object(key.Id), key.DisplayText ?? key.Id);
mController.Apply(config);
}
@@ -528,7 +530,7 @@ public CheckBoxCreator(PropertyObjectController parent, PropertyKey key, CheckBo
mTitle.VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center;
mDockPanel.Children.Add(mTitle);
- mController.BindDataProperty(parent.DataObject.BooleanField(key.Id, config.DefaultValue), s);
+ mController.BindDataProperty(parent.DataObject.BooleanField(key.Id, config.DefaultValue), s, detail: key.DisplayText ?? key.Id);
}
public override Type ConfigType => typeof(CheckBoxConfig);
diff --git a/TuneLab.Hosting.Foundation/Document/DataDocument.cs b/TuneLab.Hosting.Foundation/Document/DataDocument.cs
index 2519d997..8c195c01 100644
--- a/TuneLab.Hosting.Foundation/Document/DataDocument.cs
+++ b/TuneLab.Hosting.Foundation/Document/DataDocument.cs
@@ -1,63 +1,76 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using TuneLab.Foundation;
-
namespace TuneLab.Foundation;
public class DataDocument : DataObject
{
public event Action? StatusChanged;
- public override Head Head => new(mCommitedCommands.Count + mUncommitedCommands.Count);
-
- public DataDocument() { }
+ public override Head Head => mHead;
+ public IReadOnlyList History => mReadOnlyHistory;
+ public int HistoryPosition => mHistoryPosition;
+
+ public DataDocument()
+ {
+ mReadOnlyHistory = mHistory.AsReadOnly();
+ mHead = AllocateHead();
+ }
public void Clear()
{
- mCommitedCommands.Clear();
- mRedoCommands.Clear();
- mUncommitedCommands.Clear();
+ mHistory.Clear();
+ mHistoryPosition = 0;
+ mUncommittedCommands.Clear();
+ mHead = AllocateHead();
StatusChanged?.Invoke();
}
public override bool Pushable()
{
- return mUncommitedCommands.IsEmpty();
+ return mUncommittedCommands.IsEmpty();
}
public bool Undoable()
{
- if (!mUncommitedCommands.IsEmpty())
- return false;
-
- if (mCommitedCommands.IsEmpty())
+ if (!mUncommittedCommands.IsEmpty())
return false;
- return true;
+ return mHistoryPosition > 0;
}
public bool Redoable()
{
- if (!mUncommitedCommands.IsEmpty())
- return false;
-
- if (mRedoCommands.IsEmpty())
+ if (!mUncommittedCommands.IsEmpty())
return false;
- return true;
+ return mHistoryPosition < mHistory.Count;
}
public override bool Commit()
{
- if (mUncommitedCommands.IsEmpty())
+ return Commit(DefaultHistoryDescription);
+ }
+
+ public override bool Commit(string description, string? detail = null)
+ {
+ if (mUncommittedCommands.IsEmpty())
return false;
- mCommitedCommands.Push(new CompositeCommand(mUncommitedCommands));
- mUncommitedCommands.Clear();
- mRedoCommands.Clear();
+ description = string.IsNullOrWhiteSpace(description) ? DefaultHistoryDescription : description;
+ var commands = mUncommittedCommands.Select(command => command.Command).ToArray();
+ var entry = new HistoryEntry(
+ mUncommittedCommands[0].BeforeState,
+ mUncommittedCommands.ConstLast().AfterState,
+ description,
+ detail,
+ new CompositeCommand(commands));
+
+ if (mHistoryPosition < mHistory.Count)
+ {
+ mHistory.RemoveRange(mHistoryPosition, mHistory.Count - mHistoryPosition);
+ }
+
+ mHistory.Add(entry);
+ mHistoryPosition++;
+ mUncommittedCommands.Clear();
StatusChanged?.Invoke();
return true;
@@ -65,14 +78,17 @@ public override bool Commit()
public override bool Discard()
{
- if (mUncommitedCommands.IsEmpty())
+ if (mUncommittedCommands.IsEmpty())
return false;
- for (int i = mUncommitedCommands.Count - 1; i >= 0; i--)
+ while (!mUncommittedCommands.IsEmpty())
{
- mUncommitedCommands[i].Undo();
+ int index = mUncommittedCommands.Count - 1;
+ var command = mUncommittedCommands[index];
+ command.Command.Undo();
+ mHead = command.BeforeState;
+ mUncommittedCommands.RemoveAt(index);
}
- mUncommitedCommands.Clear();
StatusChanged?.Invoke();
return true;
@@ -80,16 +96,19 @@ public override bool Discard()
public override bool DiscardTo(Head head)
{
- if (Head == head)
+ if (mHead == head)
+ return false;
+
+ int firstCommandToDiscard = mUncommittedCommands.FindIndex(command => command.BeforeState == head);
+ if (firstCommandToDiscard < 0)
return false;
- // TODO: 优化,预先检查未提交的改动中是否包含传入的head
- while (!mUncommitedCommands.IsEmpty())
+ for (int i = mUncommittedCommands.Count - 1; i >= firstCommandToDiscard; i--)
{
- mUncommitedCommands.ConstLast().Undo();
- mUncommitedCommands.RemoveAt(mUncommitedCommands.Count - 1);
- if (Head == head)
- break;
+ var command = mUncommittedCommands[i];
+ command.Command.Undo();
+ mHead = command.BeforeState;
+ mUncommittedCommands.RemoveAt(i);
}
return true;
@@ -97,37 +116,91 @@ public override bool DiscardTo(Head head)
public override bool Undo()
{
- if (!Undoable())
- return false;
-
- var command = mCommitedCommands.Pop();
- command.Undo();
- mRedoCommands.Push(command);
- StatusChanged?.Invoke();
-
- return true;
+ return MoveToHistory(mHistoryPosition - 1);
}
public override bool Redo()
{
- if (!Redoable())
+ return MoveToHistory(mHistoryPosition + 1);
+ }
+
+ public bool MoveToHistory(int position)
+ {
+ if (!mUncommittedCommands.IsEmpty() || position < 0 || position > mHistory.Count || position == mHistoryPosition)
return false;
- var command = mRedoCommands.Pop();
- command.Redo();
- mCommitedCommands.Push(command);
- StatusChanged?.Invoke();
+ IDisposable? mergeScope = Math.Abs(position - mHistoryPosition) > 1
+ ? MergeNotifyWithoutCommand()
+ : null;
- return true;
+ try
+ {
+ while (mHistoryPosition > position)
+ {
+ UndoOneHistoryEntry();
+ }
+
+ while (mHistoryPosition < position)
+ {
+ RedoOneHistoryEntry();
+ }
+
+ return true;
+ }
+ finally
+ {
+ try
+ {
+ mergeScope?.Dispose();
+ }
+ finally
+ {
+ StatusChanged?.Invoke();
+ }
+ }
}
protected override void Push(ICommand command)
{
- mUncommitedCommands.Add(command);
+ var state = AllocateHead();
+ mUncommittedCommands.Add(new UncommittedCommand(command, mHead, state));
+ mHead = state;
StatusChanged?.Invoke();
}
- readonly Stack mCommitedCommands = new();
- readonly Stack mRedoCommands = new();
- readonly List mUncommitedCommands = new();
+ void UndoOneHistoryEntry()
+ {
+ var entry = mHistory[mHistoryPosition - 1];
+ entry.Command.Undo();
+ mHead = entry.BeforeState;
+ mHistoryPosition--;
+ }
+
+ void RedoOneHistoryEntry()
+ {
+ var entry = mHistory[mHistoryPosition];
+ entry.Command.Redo();
+ mHead = entry.State;
+ mHistoryPosition++;
+ }
+
+ Head AllocateHead()
+ {
+ return new Head(checked(++mLastAllocatedHead));
+ }
+
+ sealed class UncommittedCommand(ICommand command, Head beforeState, Head afterState)
+ {
+ public ICommand Command { get; } = command;
+ public Head BeforeState { get; } = beforeState;
+ public Head AfterState { get; } = afterState;
+ }
+
+ readonly List mHistory = new();
+ readonly IReadOnlyList mReadOnlyHistory;
+ readonly List mUncommittedCommands = new();
+ const string DefaultHistoryDescription = "Edit Project";
+ Head mHead;
+ int mHistoryPosition = 0;
+ int mLastAllocatedHead = 0;
}
diff --git a/TuneLab.Hosting.Foundation/Document/DataObject.cs b/TuneLab.Hosting.Foundation/Document/DataObject.cs
index 948afecf..403c3622 100644
--- a/TuneLab.Hosting.Foundation/Document/DataObject.cs
+++ b/TuneLab.Hosting.Foundation/Document/DataObject.cs
@@ -31,9 +31,16 @@ public IDisposable MergeNotify()
return new MergeScope(this);
}
+ protected IDisposable MergeNotifyWithoutCommand()
+ {
+ ChangeNotifyFlag(1);
+ return new DirectMergeScope(this);
+ }
+
// 委托到撤销根;无父(游离/根本身另行 override)默认可申请。
public virtual bool Pushable() => mParent?.Pushable() ?? true;
public virtual bool Commit() => mParent?.Commit() ?? false;
+ public virtual bool Commit(string description, string? detail = null) => mParent?.Commit(description, detail) ?? false;
public virtual bool Discard() => mParent?.Discard() ?? false;
public virtual bool DiscardTo(Head head) => mParent?.DiscardTo(head) ?? false;
public virtual bool Undo() => mParent?.Undo() ?? false;
@@ -166,6 +173,11 @@ class MergeScope(DataObject dataObject) : IDisposable
public void Dispose() => dataObject.EndMergeNotify();
}
+ class DirectMergeScope(DataObject dataObject) : IDisposable
+ {
+ public void Dispose() => dataObject.CloseMergeScope();
+ }
+
DataObject? mParent = null;
readonly List mChildren = new();
int mNotifyFlag = 0;
diff --git a/TuneLab.Hosting.Foundation/Document/HistoryEntry.cs b/TuneLab.Hosting.Foundation/Document/HistoryEntry.cs
new file mode 100644
index 00000000..09bb08a5
--- /dev/null
+++ b/TuneLab.Hosting.Foundation/Document/HistoryEntry.cs
@@ -0,0 +1,20 @@
+namespace TuneLab.Foundation;
+
+public sealed class HistoryEntry
+{
+ internal HistoryEntry(Head beforeState, Head state, string description, string? detail, ICommand command)
+ {
+ BeforeState = beforeState;
+ State = state;
+ Description = description;
+ Detail = detail;
+ Command = command;
+ }
+
+ public Head State { get; }
+ public string Description { get; }
+ public string? Detail { get; }
+
+ internal Head BeforeState { get; }
+ internal ICommand Command { get; }
+}
diff --git a/TuneLab.Hosting.Foundation/Document/IDataObject.cs b/TuneLab.Hosting.Foundation/Document/IDataObject.cs
index a0c412a2..0147812a 100644
--- a/TuneLab.Hosting.Foundation/Document/IDataObject.cs
+++ b/TuneLab.Hosting.Foundation/Document/IDataObject.cs
@@ -29,6 +29,7 @@ public interface IDataObject : IReadOnlyNotifiable
// 委托到撤销根(DataDocument),用于在另一处 UI 操作中途时拒绝发起脚本/批量提交,避免吞掉它的未提交改动。
bool Pushable();
bool Commit();
+ bool Commit(string description, string? detail = null);
bool Discard();
bool DiscardTo(Head head);
bool Undo();
@@ -48,6 +49,7 @@ internal class Wrapper(IDataObject dataObject) : IDataObject
public void EndMergeNotify() => dataObject.EndMergeNotify();
public bool Pushable() => dataObject.Pushable();
public bool Commit() => dataObject.Commit();
+ public bool Commit(string description, string? detail = null) => dataObject.Commit(description, detail);
public bool Discard() => dataObject.Discard();
public bool DiscardTo(Head head) => dataObject.DiscardTo(head);
public bool Undo() => dataObject.Undo();
diff --git a/TuneLab.Hosting.Foundation/Property/MultipleDataProperty.cs b/TuneLab.Hosting.Foundation/Property/MultipleDataProperty.cs
index fa4b2509..789b301b 100644
--- a/TuneLab.Hosting.Foundation/Property/MultipleDataProperty.cs
+++ b/TuneLab.Hosting.Foundation/Property/MultipleDataProperty.cs
@@ -72,6 +72,7 @@ public IDisposable MergeNotify()
public void EndMergeNotify() { foreach (var p in mProperties) p.EndMergeNotify(); }
public bool Pushable() => mRoot?.Pushable() ?? true;
public bool Commit() => mRoot?.Commit() ?? false;
+ public bool Commit(string description, string? detail = null) => mRoot?.Commit(description, detail) ?? false;
public bool Discard() => mRoot?.Discard() ?? false;
public bool DiscardTo(Head head) => mRoot?.DiscardTo(head) ?? false;
public bool Undo() => mRoot?.Undo() ?? false;
diff --git a/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyArray.cs b/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyArray.cs
index 71a2d261..bfd9bd34 100644
--- a/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyArray.cs
+++ b/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyArray.cs
@@ -148,6 +148,7 @@ void FanOut(Action action)
public void EndMergeNotify() => mBase.EndMergeNotify();
public bool Pushable() => mBase.Pushable();
public bool Commit() => mBase.Commit();
+ public bool Commit(string description, string? detail = null) => mBase.Commit(description, detail);
public bool Discard() => mBase.Discard();
public bool DiscardTo(Head head) => mBase.DiscardTo(head);
public bool Undo() => mBase.Undo();
diff --git a/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyObject.cs b/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyObject.cs
index 2c4f9e83..78cb11b5 100644
--- a/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyObject.cs
+++ b/TuneLab.Hosting.Foundation/Property/MultipleDataPropertyObject.cs
@@ -104,6 +104,7 @@ public IDisposable MergeNotify()
public void EndMergeNotify() { foreach (var dataObject in mDataObjects) dataObject.EndMergeNotify(); }
public bool Pushable() => mRoot?.Pushable() ?? true;
public bool Commit() => mRoot?.Commit() ?? false;
+ public bool Commit(string description, string? detail = null) => mRoot?.Commit(description, detail) ?? false;
public bool Discard() => mRoot?.Discard() ?? false;
public bool DiscardTo(Head head) => mRoot?.DiscardTo(head) ?? false;
public bool Undo() => mRoot?.Undo() ?? false;
diff --git a/TuneLab/Resources/Translations/de-DE.toml b/TuneLab/Resources/Translations/de-DE.toml
index c270e33f..18dfcbad 100644
--- a/TuneLab/Resources/Translations/de-DE.toml
+++ b/TuneLab/Resources/Translations/de-DE.toml
@@ -306,6 +306,43 @@
"Recent" = "Zuletzt verwendet"
"Set Instrument" = "Instrument setzen"
"About TuneLab" = "Über TuneLab"
+"Add List Item" = "Listenelement hinzufügen"
+"Add Note" = "Note hinzufügen"
+"Add Part" = "Part hinzufügen"
+"Add Property" = "Eigenschaft hinzufügen"
+"Add Vibrato" = "Vibrato hinzufügen"
+"Change Lyric" = "Liedtext ändern"
+"Delete List Item" = "Listenelement löschen"
+"Delete Notes" = "Noten löschen"
+"Delete Part" = "Part löschen"
+"Delete Parts" = "Parts löschen"
+"Delete Phoneme" = "Phonem löschen"
+"Delete Property" = "Eigenschaft löschen"
+"Delete Track" = "Spur löschen"
+"Delete Vibratos" = "Vibratos löschen"
+"Draw Pitch" = "Tonhöhe zeichnen"
+"Edit Automation" = "Automation bearbeiten"
+"Edit Effects" = "Effekte bearbeiten"
+"Edit Project" = "Projekt bearbeiten"
+"Edit Properties" = "Eigenschaften bearbeiten"
+"Edit Vibrato" = "Vibrato bearbeiten"
+"Erase Pitch" = "Tonhöhe löschen"
+"History" = "Verlauf"
+"Move Notes" = "Noten verschieben"
+"Move Part" = "Part verschieben"
+"Move Parts" = "Parts verschieben"
+"Move Track" = "Spur verschieben"
+"Move Tracks" = "Spuren verschieben"
+"Opened Project" = "Geöffnetes Projekt"
+"Recover Project" = "Projekt wiederherstellen"
+"Rename Part" = "Part umbenennen"
+"Rename Track" = "Spur umbenennen"
+"Resize Notes" = "Notenlänge ändern"
+"Resize Part" = "Part-Länge ändern"
+"Run Script" = "Skript ausführen"
+"Set Track Color" = "Spurfarbe festlegen"
+"Split Phoneme" = "Phonem teilen"
+"Transpose Notes" = "Noten transponieren"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/el-GR.toml b/TuneLab/Resources/Translations/el-GR.toml
index aa92a56a..4a48c8d7 100644
--- a/TuneLab/Resources/Translations/el-GR.toml
+++ b/TuneLab/Resources/Translations/el-GR.toml
@@ -306,6 +306,43 @@
"Recent" = "Πρόσφατα"
"Set Instrument" = "Επιλογή Οργάνου"
"About TuneLab" = "Σχετικά με το TuneLab"
+"Add List Item" = "Προσθήκη Στοιχείου Λίστας"
+"Add Note" = "Προσθήκη Νότας"
+"Add Part" = "Προσθήκη Μέρους"
+"Add Property" = "Προσθήκη Ιδιότητας"
+"Add Vibrato" = "Προσθήκη Βιμπράτο"
+"Change Lyric" = "Αλλαγή Στίχου"
+"Delete List Item" = "Διαγραφή Στοιχείου Λίστας"
+"Delete Notes" = "Διαγραφή Νοτών"
+"Delete Part" = "Διαγραφή Μέρους"
+"Delete Parts" = "Διαγραφή Μερών"
+"Delete Phoneme" = "Διαγραφή Φωνήματος"
+"Delete Property" = "Διαγραφή Ιδιότητας"
+"Delete Track" = "Διαγραφή Κομματιού"
+"Delete Vibratos" = "Διαγραφή Βιμπράτο"
+"Draw Pitch" = "Σχεδίαση Ύψους Φωνής"
+"Edit Automation" = "Επεξεργασία Αυτοματισμού"
+"Edit Effects" = "Επεξεργασία Εφέ"
+"Edit Project" = "Επεξεργασία Έργου"
+"Edit Properties" = "Επεξεργασία Ιδιοτήτων"
+"Edit Vibrato" = "Επεξεργασία Βιμπράτο"
+"Erase Pitch" = "Διαγραφή Ύψους Φωνής"
+"History" = "Ιστορικό"
+"Move Notes" = "Μετακίνηση Νοτών"
+"Move Part" = "Μετακίνηση Μέρους"
+"Move Parts" = "Μετακίνηση Μερών"
+"Move Track" = "Μετακίνηση Κομματιού"
+"Move Tracks" = "Μετακίνηση Κομματιών"
+"Opened Project" = "Ανοιχτό Έργο"
+"Recover Project" = "Ανάκτηση Έργου"
+"Rename Part" = "Μετονομασία Μέρους"
+"Rename Track" = "Μετονομασία Κομματιού"
+"Resize Notes" = "Αλλαγή Διάρκειας Νοτών"
+"Resize Part" = "Αλλαγή Διάρκειας Μέρους"
+"Run Script" = "Εκτέλεση Script"
+"Set Track Color" = "Ορισμός Χρώματος Κομματιού"
+"Split Phoneme" = "Διαχωρισμός Φωνήματος"
+"Transpose Notes" = "Μεταφορά Νοτών"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/en-US.toml b/TuneLab/Resources/Translations/en-US.toml
index e69de29b..69b17e79 100644
--- a/TuneLab/Resources/Translations/en-US.toml
+++ b/TuneLab/Resources/Translations/en-US.toml
@@ -0,0 +1,65 @@
+[Menu]
+"Add List Item" = "Add List Item"
+"Add Note" = "Add Note"
+"Add Part" = "Add Part"
+"Add Property" = "Add Property"
+"Add Tempo" = "Add Tempo"
+"Add Time Signature" = "Add Time Signature"
+"Add Track" = "Add Track"
+"Add Vibrato" = "Add Vibrato"
+"Change Lyric" = "Change Lyric"
+"Clear Locked Phonemes" = "Clear Locked Phonemes"
+"Delete List Item" = "Delete List Item"
+"Delete Notes" = "Delete Notes"
+"Delete Part" = "Delete Part"
+"Delete Parts" = "Delete Parts"
+"Delete Phoneme" = "Delete Phoneme"
+"Delete Property" = "Delete Property"
+"Delete Selection" = "Delete Selection"
+"Delete Tempo" = "Delete Tempo"
+"Delete Time Signature" = "Delete Time Signature"
+"Delete Track" = "Delete Track"
+"Delete Vibratos" = "Delete Vibratos"
+"Draw Pitch" = "Draw Pitch"
+"Edit Automation" = "Edit Automation"
+"Edit Effects" = "Edit Effects"
+"Edit Project" = "Edit Project"
+"Edit Properties" = "Edit Properties"
+"Edit Tempo" = "Edit Tempo"
+"Edit Time Signature" = "Edit Time Signature"
+"Edit Vibrato" = "Edit Vibrato"
+"Erase Pitch" = "Erase Pitch"
+"Hidden as Refer" = "Hidden as Refer"
+"History" = "History"
+"Import Audio" = "Import Audio"
+"Import Track" = "Import Track"
+"Lock Phonemes" = "Lock Phonemes"
+"Merge" = "Merge"
+"Move Lyrics Backward" = "Move Lyrics Backward"
+"Move Lyrics Forward" = "Move Lyrics Forward"
+"Move Notes" = "Move Notes"
+"Move Part" = "Move Part"
+"Move Parts" = "Move Parts"
+"Move Track" = "Move Track"
+"Move Tracks" = "Move Tracks"
+"Opened Project" = "Opened Project"
+"Paste" = "Paste"
+"Paste Automations" = "Paste Automations"
+"Paste Notes" = "Paste Notes"
+"Paste Pitch" = "Paste Pitch"
+"Paste Vibratos" = "Paste Vibratos"
+"Recover Project" = "Recover Project"
+"Remove Overlaps" = "Remove Overlaps"
+"Rename Part" = "Rename Part"
+"Rename Track" = "Rename Track"
+"Resize Notes" = "Resize Notes"
+"Resize Part" = "Resize Part"
+"Run Script" = "Run Script"
+"Set Instrument" = "Set Instrument"
+"Set Track Color" = "Set Track Color"
+"Set Voice" = "Set Voice"
+"Split" = "Split"
+"Split by Phonemes" = "Split by Phonemes"
+"Split Phoneme" = "Split Phoneme"
+"Transpose Notes" = "Transpose Notes"
+"Visible as Refer" = "Visible as Refer"
diff --git a/TuneLab/Resources/Translations/es-US.toml b/TuneLab/Resources/Translations/es-US.toml
index 2067b321..f6826a0e 100644
--- a/TuneLab/Resources/Translations/es-US.toml
+++ b/TuneLab/Resources/Translations/es-US.toml
@@ -306,6 +306,43 @@
"Recent" = "Recientes"
"Set Instrument" = "Establecer instrumento"
"About TuneLab" = "Acerca de TuneLab"
+"Add List Item" = "Agregar elemento de lista"
+"Add Note" = "Agregar nota"
+"Add Part" = "Agregar parte"
+"Add Property" = "Agregar propiedad"
+"Add Vibrato" = "Agregar vibrato"
+"Change Lyric" = "Cambiar letra"
+"Delete List Item" = "Eliminar elemento de lista"
+"Delete Notes" = "Eliminar notas"
+"Delete Part" = "Eliminar parte"
+"Delete Parts" = "Eliminar partes"
+"Delete Phoneme" = "Eliminar fonema"
+"Delete Property" = "Eliminar propiedad"
+"Delete Track" = "Eliminar pista"
+"Delete Vibratos" = "Eliminar vibratos"
+"Draw Pitch" = "Dibujar tono"
+"Edit Automation" = "Editar automatización"
+"Edit Effects" = "Editar efectos"
+"Edit Project" = "Editar proyecto"
+"Edit Properties" = "Editar propiedades"
+"Edit Vibrato" = "Editar vibrato"
+"Erase Pitch" = "Borrar tono"
+"History" = "Historial"
+"Move Notes" = "Mover notas"
+"Move Part" = "Mover parte"
+"Move Parts" = "Mover partes"
+"Move Track" = "Mover pista"
+"Move Tracks" = "Mover pistas"
+"Opened Project" = "Proyecto abierto"
+"Recover Project" = "Recuperar proyecto"
+"Rename Part" = "Renombrar parte"
+"Rename Track" = "Renombrar pista"
+"Resize Notes" = "Cambiar duración de notas"
+"Resize Part" = "Cambiar duración de parte"
+"Run Script" = "Ejecutar script"
+"Set Track Color" = "Establecer color de pista"
+"Split Phoneme" = "Dividir fonema"
+"Transpose Notes" = "Transponer notas"
[AgentSideBarContentProvider]
"Agent" = "Agente"
diff --git a/TuneLab/Resources/Translations/fr-FR.toml b/TuneLab/Resources/Translations/fr-FR.toml
index 6a908263..4fc5069f 100644
--- a/TuneLab/Resources/Translations/fr-FR.toml
+++ b/TuneLab/Resources/Translations/fr-FR.toml
@@ -306,6 +306,43 @@
"Recent" = "Récent"
"Set Instrument" = "Choisir l'instrument"
"About TuneLab" = "À propos de TuneLab"
+"Add List Item" = "Ajouter un élément de liste"
+"Add Note" = "Ajouter une note"
+"Add Part" = "Ajouter une partie"
+"Add Property" = "Ajouter une propriété"
+"Add Vibrato" = "Ajouter un vibrato"
+"Change Lyric" = "Modifier les paroles"
+"Delete List Item" = "Supprimer un élément de liste"
+"Delete Notes" = "Supprimer les notes"
+"Delete Part" = "Supprimer la partie"
+"Delete Parts" = "Supprimer les parties"
+"Delete Phoneme" = "Supprimer le phonème"
+"Delete Property" = "Supprimer la propriété"
+"Delete Track" = "Supprimer la piste"
+"Delete Vibratos" = "Supprimer les vibratos"
+"Draw Pitch" = "Dessiner la tonalité"
+"Edit Automation" = "Modifier l'automatisation"
+"Edit Effects" = "Modifier les effets"
+"Edit Project" = "Modifier le projet"
+"Edit Properties" = "Modifier les propriétés"
+"Edit Vibrato" = "Modifier le vibrato"
+"Erase Pitch" = "Effacer la tonalité"
+"History" = "Historique"
+"Move Notes" = "Déplacer les notes"
+"Move Part" = "Déplacer la partie"
+"Move Parts" = "Déplacer les parties"
+"Move Track" = "Déplacer la piste"
+"Move Tracks" = "Déplacer les pistes"
+"Opened Project" = "Projet ouvert"
+"Recover Project" = "Récupérer le projet"
+"Rename Part" = "Renommer la partie"
+"Rename Track" = "Renommer la piste"
+"Resize Notes" = "Modifier la durée des notes"
+"Resize Part" = "Modifier la durée de la partie"
+"Run Script" = "Exécuter le script"
+"Set Track Color" = "Définir la couleur de la piste"
+"Split Phoneme" = "Diviser le phonème"
+"Transpose Notes" = "Transposer les notes"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/it-IT.toml b/TuneLab/Resources/Translations/it-IT.toml
index da099843..0de7f5f5 100644
--- a/TuneLab/Resources/Translations/it-IT.toml
+++ b/TuneLab/Resources/Translations/it-IT.toml
@@ -306,6 +306,43 @@
"Recent" = "Recenti"
"Set Instrument" = "Scegli strumento"
"About TuneLab" = "Informazioni su TuneLab"
+"Add List Item" = "Aggiungi elemento elenco"
+"Add Note" = "Aggiungi nota"
+"Add Part" = "Aggiungi parte"
+"Add Property" = "Aggiungi proprietà"
+"Add Vibrato" = "Aggiungi vibrato"
+"Change Lyric" = "Modifica testo"
+"Delete List Item" = "Elimina elemento elenco"
+"Delete Notes" = "Elimina note"
+"Delete Part" = "Elimina parte"
+"Delete Parts" = "Elimina parti"
+"Delete Phoneme" = "Elimina fonema"
+"Delete Property" = "Elimina proprietà"
+"Delete Track" = "Elimina traccia"
+"Delete Vibratos" = "Elimina vibrati"
+"Draw Pitch" = "Disegna intonazione"
+"Edit Automation" = "Modifica automazione"
+"Edit Effects" = "Modifica effetti"
+"Edit Project" = "Modifica progetto"
+"Edit Properties" = "Modifica proprietà"
+"Edit Vibrato" = "Modifica vibrato"
+"Erase Pitch" = "Cancella intonazione"
+"History" = "Cronologia"
+"Move Notes" = "Sposta note"
+"Move Part" = "Sposta parte"
+"Move Parts" = "Sposta parti"
+"Move Track" = "Sposta traccia"
+"Move Tracks" = "Sposta tracce"
+"Opened Project" = "Progetto aperto"
+"Recover Project" = "Recupera progetto"
+"Rename Part" = "Rinomina parte"
+"Rename Track" = "Rinomina traccia"
+"Resize Notes" = "Modifica durata delle note"
+"Resize Part" = "Modifica durata della parte"
+"Run Script" = "Esegui script"
+"Set Track Color" = "Imposta colore traccia"
+"Split Phoneme" = "Dividi fonema"
+"Transpose Notes" = "Trasponi note"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/ja-JP.toml b/TuneLab/Resources/Translations/ja-JP.toml
index ffe739bf..0786680a 100644
--- a/TuneLab/Resources/Translations/ja-JP.toml
+++ b/TuneLab/Resources/Translations/ja-JP.toml
@@ -306,6 +306,43 @@
"Recent" = "最近"
"Set Instrument" = "インストゥルメントを設定"
"About TuneLab" = "TuneLabについて"
+"Add List Item" = "リスト項目を追加"
+"Add Note" = "ノートを追加"
+"Add Part" = "パートを追加"
+"Add Property" = "プロパティを追加"
+"Add Vibrato" = "ビブラートを追加"
+"Change Lyric" = "歌詞を変更"
+"Delete List Item" = "リスト項目を削除"
+"Delete Notes" = "ノートを削除"
+"Delete Part" = "パートを削除"
+"Delete Parts" = "複数パートを削除"
+"Delete Phoneme" = "音素を削除"
+"Delete Property" = "プロパティを削除"
+"Delete Track" = "トラックを削除"
+"Delete Vibratos" = "ビブラートを削除"
+"Draw Pitch" = "ピッチを描画"
+"Edit Automation" = "オートメーションを編集"
+"Edit Effects" = "エフェクトを編集"
+"Edit Project" = "プロジェクトを編集"
+"Edit Properties" = "プロパティを編集"
+"Edit Vibrato" = "ビブラートを編集"
+"Erase Pitch" = "ピッチを消去"
+"History" = "履歴"
+"Move Notes" = "ノートを移動"
+"Move Part" = "パートを移動"
+"Move Parts" = "複数パートを移動"
+"Move Track" = "トラックを移動"
+"Move Tracks" = "複数トラックを移動"
+"Opened Project" = "プロジェクトを開いた時点"
+"Recover Project" = "プロジェクトを復元"
+"Rename Part" = "パート名を変更"
+"Rename Track" = "トラック名を変更"
+"Resize Notes" = "ノートの長さを変更"
+"Resize Part" = "パートの長さを変更"
+"Run Script" = "スクリプトを実行"
+"Set Track Color" = "トラックの色を設定"
+"Split Phoneme" = "音素を分割"
+"Transpose Notes" = "ノートを移調"
[AgentSideBarContentProvider]
"Agent" = "エージェント"
diff --git a/TuneLab/Resources/Translations/ko-KR.toml b/TuneLab/Resources/Translations/ko-KR.toml
index e08002b6..4a84d3cf 100644
--- a/TuneLab/Resources/Translations/ko-KR.toml
+++ b/TuneLab/Resources/Translations/ko-KR.toml
@@ -306,6 +306,43 @@
"Recent" = "최근"
"Set Instrument" = "악기 설정"
"About TuneLab" = "TuneLab 정보"
+"Add List Item" = "목록 항목 추가"
+"Add Note" = "노트 추가"
+"Add Part" = "파트 추가"
+"Add Property" = "프로퍼티 추가"
+"Add Vibrato" = "비브라토 추가"
+"Change Lyric" = "가사 변경"
+"Delete List Item" = "목록 항목 삭제"
+"Delete Notes" = "노트 삭제"
+"Delete Part" = "파트 삭제"
+"Delete Parts" = "여러 파트 삭제"
+"Delete Phoneme" = "음소 삭제"
+"Delete Property" = "프로퍼티 삭제"
+"Delete Track" = "트랙 삭제"
+"Delete Vibratos" = "비브라토 삭제"
+"Draw Pitch" = "피치 그리기"
+"Edit Automation" = "자동화 편집"
+"Edit Effects" = "이펙트 편집"
+"Edit Project" = "프로젝트 편집"
+"Edit Properties" = "프로퍼티 편집"
+"Edit Vibrato" = "비브라토 편집"
+"Erase Pitch" = "피치 지우기"
+"History" = "기록"
+"Move Notes" = "노트 이동"
+"Move Part" = "파트 이동"
+"Move Parts" = "여러 파트 이동"
+"Move Track" = "트랙 이동"
+"Move Tracks" = "여러 트랙 이동"
+"Opened Project" = "프로젝트를 연 시점"
+"Recover Project" = "프로젝트 복구"
+"Rename Part" = "파트 이름 변경"
+"Rename Track" = "트랙 이름 변경"
+"Resize Notes" = "노트 길이 변경"
+"Resize Part" = "파트 길이 변경"
+"Run Script" = "스크립트 실행"
+"Set Track Color" = "트랙 색상 설정"
+"Split Phoneme" = "음소 분할"
+"Transpose Notes" = "노트 조옮김"
[AgentSideBarContentProvider]
"Agent" = "에이전트"
diff --git a/TuneLab/Resources/Translations/nl-NL.toml b/TuneLab/Resources/Translations/nl-NL.toml
index 8442319c..e73d196b 100644
--- a/TuneLab/Resources/Translations/nl-NL.toml
+++ b/TuneLab/Resources/Translations/nl-NL.toml
@@ -306,6 +306,43 @@
"Recent" = "Recent"
"Set Instrument" = "Instrument instellen"
"About TuneLab" = "Over TuneLab"
+"Add List Item" = "Lijstitem toevoegen"
+"Add Note" = "Note toevoegen"
+"Add Part" = "Part toevoegen"
+"Add Property" = "Eigenschap toevoegen"
+"Add Vibrato" = "Vibrato toevoegen"
+"Change Lyric" = "Songtekst wijzigen"
+"Delete List Item" = "Lijstitem verwijderen"
+"Delete Notes" = "Noten verwijderen"
+"Delete Part" = "Part verwijderen"
+"Delete Parts" = "Parts verwijderen"
+"Delete Phoneme" = "Foneem verwijderen"
+"Delete Property" = "Eigenschap verwijderen"
+"Delete Track" = "Track verwijderen"
+"Delete Vibratos" = "Vibrato's verwijderen"
+"Draw Pitch" = "Pitch tekenen"
+"Edit Automation" = "Automatisering bewerken"
+"Edit Effects" = "Effecten bewerken"
+"Edit Project" = "Project bewerken"
+"Edit Properties" = "Eigenschappen bewerken"
+"Edit Vibrato" = "Vibrato bewerken"
+"Erase Pitch" = "Pitch wissen"
+"History" = "Geschiedenis"
+"Move Notes" = "Noten verplaatsen"
+"Move Part" = "Part verplaatsen"
+"Move Parts" = "Parts verplaatsen"
+"Move Track" = "Track verplaatsen"
+"Move Tracks" = "Tracks verplaatsen"
+"Opened Project" = "Geopend project"
+"Recover Project" = "Project herstellen"
+"Rename Part" = "Part hernoemen"
+"Rename Track" = "Track hernoemen"
+"Resize Notes" = "Lengte van noten wijzigen"
+"Resize Part" = "Lengte van part wijzigen"
+"Run Script" = "Script uitvoeren"
+"Set Track Color" = "Trackkleur instellen"
+"Split Phoneme" = "Foneem splitsen"
+"Transpose Notes" = "Noten transponeren"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/pt-BR.toml b/TuneLab/Resources/Translations/pt-BR.toml
index fc05c448..0aefe377 100644
--- a/TuneLab/Resources/Translations/pt-BR.toml
+++ b/TuneLab/Resources/Translations/pt-BR.toml
@@ -306,6 +306,43 @@
"Recent" = "Recentes"
"Set Instrument" = "Definir Instrumento"
"About TuneLab" = "Sobre o TuneLab"
+"Add List Item" = "Adicionar item à lista"
+"Add Note" = "Adicionar nota"
+"Add Part" = "Adicionar parte"
+"Add Property" = "Adicionar propriedade"
+"Add Vibrato" = "Adicionar vibrato"
+"Change Lyric" = "Alterar letra"
+"Delete List Item" = "Deletar item da lista"
+"Delete Notes" = "Deletar notas"
+"Delete Part" = "Deletar parte"
+"Delete Parts" = "Deletar partes"
+"Delete Phoneme" = "Deletar fonema"
+"Delete Property" = "Deletar propriedade"
+"Delete Track" = "Deletar track"
+"Delete Vibratos" = "Deletar vibratos"
+"Draw Pitch" = "Desenhar pitch"
+"Edit Automation" = "Editar automação"
+"Edit Effects" = "Editar efeitos"
+"Edit Project" = "Editar projeto"
+"Edit Properties" = "Editar propriedades"
+"Edit Vibrato" = "Editar vibrato"
+"Erase Pitch" = "Apagar pitch"
+"History" = "Histórico"
+"Move Notes" = "Mover notas"
+"Move Part" = "Mover parte"
+"Move Parts" = "Mover partes"
+"Move Track" = "Mover track"
+"Move Tracks" = "Mover tracks"
+"Opened Project" = "Projeto aberto"
+"Recover Project" = "Recuperar projeto"
+"Rename Part" = "Renomear parte"
+"Rename Track" = "Renomear track"
+"Resize Notes" = "Alterar duração das notas"
+"Resize Part" = "Alterar duração da parte"
+"Run Script" = "Executar script"
+"Set Track Color" = "Definir cor da track"
+"Split Phoneme" = "Dividir fonema"
+"Transpose Notes" = "Transpor notas"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/ru-RU.toml b/TuneLab/Resources/Translations/ru-RU.toml
index ded90d60..6aad2d3b 100644
--- a/TuneLab/Resources/Translations/ru-RU.toml
+++ b/TuneLab/Resources/Translations/ru-RU.toml
@@ -306,6 +306,43 @@
"Recent" = "Недавние"
"Set Instrument" = "Поставить инструмент"
"About TuneLab" = "О TuneLab"
+"Add List Item" = "Добавить элемент списка"
+"Add Note" = "Добавить ноту"
+"Add Part" = "Добавить часть"
+"Add Property" = "Добавить свойство"
+"Add Vibrato" = "Добавить вибрато"
+"Change Lyric" = "Изменить текст"
+"Delete List Item" = "Удалить элемент списка"
+"Delete Notes" = "Удалить ноты"
+"Delete Part" = "Удалить часть"
+"Delete Parts" = "Удалить части"
+"Delete Phoneme" = "Удалить фонему"
+"Delete Property" = "Удалить свойство"
+"Delete Track" = "Удалить дорожку"
+"Delete Vibratos" = "Удалить вибрато"
+"Draw Pitch" = "Нарисовать питч"
+"Edit Automation" = "Редактировать автоматизацию"
+"Edit Effects" = "Редактировать эффекты"
+"Edit Project" = "Редактировать проект"
+"Edit Properties" = "Редактировать свойства"
+"Edit Vibrato" = "Редактировать вибрато"
+"Erase Pitch" = "Стереть питч"
+"History" = "История"
+"Move Notes" = "Переместить ноты"
+"Move Part" = "Переместить часть"
+"Move Parts" = "Переместить части"
+"Move Track" = "Переместить дорожку"
+"Move Tracks" = "Переместить дорожки"
+"Opened Project" = "Открытый проект"
+"Recover Project" = "Восстановить проект"
+"Rename Part" = "Переименовать часть"
+"Rename Track" = "Переименовать дорожку"
+"Resize Notes" = "Изменить длительность нот"
+"Resize Part" = "Изменить длительность части"
+"Run Script" = "Запустить скрипт"
+"Set Track Color" = "Установить цвет дорожки"
+"Split Phoneme" = "Разделить фонему"
+"Transpose Notes" = "Транспонировать ноты"
[AgentSideBarContentProvider]
"Agent" = "Агент"
diff --git a/TuneLab/Resources/Translations/sv-SE.toml b/TuneLab/Resources/Translations/sv-SE.toml
index 1eb54bbe..0ce74c03 100644
--- a/TuneLab/Resources/Translations/sv-SE.toml
+++ b/TuneLab/Resources/Translations/sv-SE.toml
@@ -306,6 +306,43 @@
"Recent" = "Senaste"
"Set Instrument" = "Välj instrument"
"About TuneLab" = "Om TuneLab"
+"Add List Item" = "Lägg till listobjekt"
+"Add Note" = "Lägg till not"
+"Add Part" = "Lägg till del"
+"Add Property" = "Lägg till egenskap"
+"Add Vibrato" = "Lägg till vibrato"
+"Change Lyric" = "Ändra sångtext"
+"Delete List Item" = "Ta bort listobjekt"
+"Delete Notes" = "Ta bort noter"
+"Delete Part" = "Ta bort del"
+"Delete Parts" = "Ta bort delar"
+"Delete Phoneme" = "Ta bort fonem"
+"Delete Property" = "Ta bort egenskap"
+"Delete Track" = "Ta bort spår"
+"Delete Vibratos" = "Ta bort vibraton"
+"Draw Pitch" = "Rita pitch"
+"Edit Automation" = "Redigera automatisering"
+"Edit Effects" = "Redigera effekter"
+"Edit Project" = "Redigera projekt"
+"Edit Properties" = "Redigera egenskaper"
+"Edit Vibrato" = "Redigera vibrato"
+"Erase Pitch" = "Radera pitch"
+"History" = "Historik"
+"Move Notes" = "Flytta noter"
+"Move Part" = "Flytta del"
+"Move Parts" = "Flytta delar"
+"Move Track" = "Flytta spår"
+"Move Tracks" = "Flytta spår"
+"Opened Project" = "Öppnat projekt"
+"Recover Project" = "Återställ projekt"
+"Rename Part" = "Byt namn på del"
+"Rename Track" = "Byt namn på spår"
+"Resize Notes" = "Ändra notlängd"
+"Resize Part" = "Ändra dellängd"
+"Run Script" = "Kör skript"
+"Set Track Color" = "Ställ in spårfärg"
+"Split Phoneme" = "Dela fonem"
+"Transpose Notes" = "Transponera noter"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/tr-TR.toml b/TuneLab/Resources/Translations/tr-TR.toml
index f58f4a0e..0235d00b 100644
--- a/TuneLab/Resources/Translations/tr-TR.toml
+++ b/TuneLab/Resources/Translations/tr-TR.toml
@@ -306,6 +306,43 @@
"Recent" = "Son Kullanılanlar"
"Set Instrument" = "Enstrüman Ayarla"
"About TuneLab" = "TuneLab Hakkında"
+"Add List Item" = "Liste Öğesi Ekle"
+"Add Note" = "Nota Ekle"
+"Add Part" = "Bölüm Ekle"
+"Add Property" = "Özellik Ekle"
+"Add Vibrato" = "Vibrato Ekle"
+"Change Lyric" = "Sözleri Değiştir"
+"Delete List Item" = "Liste Öğesini Sil"
+"Delete Notes" = "Notaları Sil"
+"Delete Part" = "Bölümü Sil"
+"Delete Parts" = "Bölümleri Sil"
+"Delete Phoneme" = "Fonemi Sil"
+"Delete Property" = "Özelliği Sil"
+"Delete Track" = "Parçayı Sil"
+"Delete Vibratos" = "Vibratoları Sil"
+"Draw Pitch" = "Ses Perdesini Çiz"
+"Edit Automation" = "Otomasyonu Düzenle"
+"Edit Effects" = "Efektleri Düzenle"
+"Edit Project" = "Projeyi Düzenle"
+"Edit Properties" = "Özellikleri Düzenle"
+"Edit Vibrato" = "Vibratoyu Düzenle"
+"Erase Pitch" = "Ses Perdesini Sil"
+"History" = "Geçmiş"
+"Move Notes" = "Notaları Taşı"
+"Move Part" = "Bölümü Taşı"
+"Move Parts" = "Bölümleri Taşı"
+"Move Track" = "Parçayı Taşı"
+"Move Tracks" = "Parçaları Taşı"
+"Opened Project" = "Açılan Proje"
+"Recover Project" = "Projeyi Geri Yükle"
+"Rename Part" = "Bölümü Yeniden Adlandır"
+"Rename Track" = "Parçayı Yeniden Adlandır"
+"Resize Notes" = "Nota Sürelerini Değiştir"
+"Resize Part" = "Bölüm Süresini Değiştir"
+"Run Script" = "Betiği Çalıştır"
+"Set Track Color" = "Parça Rengini Ayarla"
+"Split Phoneme" = "Fonemi Böl"
+"Transpose Notes" = "Notaları Transpoze Et"
[AgentSideBarContentProvider]
"Agent" = "Aracı"
diff --git a/TuneLab/Resources/Translations/uk-UA.toml b/TuneLab/Resources/Translations/uk-UA.toml
index 658e03ed..6f50d19a 100644
--- a/TuneLab/Resources/Translations/uk-UA.toml
+++ b/TuneLab/Resources/Translations/uk-UA.toml
@@ -306,6 +306,43 @@
"Recent" = "Нещодавні"
"Set Instrument" = "Встановити інструмент"
"About TuneLab" = "Про TuneLab"
+"Add List Item" = "Додати елемент списку"
+"Add Note" = "Додати ноту"
+"Add Part" = "Додати частину"
+"Add Property" = "Додати властивість"
+"Add Vibrato" = "Додати вібрато"
+"Change Lyric" = "Змінити текст"
+"Delete List Item" = "Видалити елемент списку"
+"Delete Notes" = "Видалити ноти"
+"Delete Part" = "Видалити частину"
+"Delete Parts" = "Видалити частини"
+"Delete Phoneme" = "Видалити фонему"
+"Delete Property" = "Видалити властивість"
+"Delete Track" = "Видалити доріжку"
+"Delete Vibratos" = "Видалити вібрато"
+"Draw Pitch" = "Намалювати пітч"
+"Edit Automation" = "Редагувати автоматизацію"
+"Edit Effects" = "Редагувати ефекти"
+"Edit Project" = "Редагувати проект"
+"Edit Properties" = "Редагувати властивості"
+"Edit Vibrato" = "Редагувати вібрато"
+"Erase Pitch" = "Стерти пітч"
+"History" = "Історія"
+"Move Notes" = "Перемістити ноти"
+"Move Part" = "Перемістити частину"
+"Move Parts" = "Перемістити частини"
+"Move Track" = "Перемістити доріжку"
+"Move Tracks" = "Перемістити доріжки"
+"Opened Project" = "Відкритий проект"
+"Recover Project" = "Відновити проект"
+"Rename Part" = "Перейменувати частину"
+"Rename Track" = "Перейменувати доріжку"
+"Resize Notes" = "Змінити тривалість нот"
+"Resize Part" = "Змінити тривалість частини"
+"Run Script" = "Запустити скрипт"
+"Set Track Color" = "Встановити колір доріжки"
+"Split Phoneme" = "Розділити фонему"
+"Transpose Notes" = "Транспонувати ноти"
[AgentSideBarContentProvider]
"Agent" = "Агент"
diff --git a/TuneLab/Resources/Translations/zh-CN.toml b/TuneLab/Resources/Translations/zh-CN.toml
index 7becc874..9bd3bf13 100644
--- a/TuneLab/Resources/Translations/zh-CN.toml
+++ b/TuneLab/Resources/Translations/zh-CN.toml
@@ -311,6 +311,43 @@
"Recent" = "最近使用"
"Set Instrument" = "设置乐器"
"About TuneLab" = "关于 TuneLab"
+"Add List Item" = "添加列表项"
+"Add Note" = "添加音符"
+"Add Part" = "添加片段"
+"Add Property" = "添加属性"
+"Add Vibrato" = "添加颤音"
+"Change Lyric" = "修改歌词"
+"Delete List Item" = "删除列表项"
+"Delete Notes" = "删除音符"
+"Delete Part" = "删除片段"
+"Delete Parts" = "删除多个片段"
+"Delete Phoneme" = "删除音素"
+"Delete Property" = "删除属性"
+"Delete Track" = "删除轨道"
+"Delete Vibratos" = "删除颤音"
+"Draw Pitch" = "绘制音高"
+"Edit Automation" = "编辑参数"
+"Edit Effects" = "编辑效果"
+"Edit Project" = "编辑工程"
+"Edit Properties" = "编辑属性"
+"Edit Vibrato" = "编辑颤音"
+"Erase Pitch" = "擦除音高"
+"History" = "历史记录"
+"Move Notes" = "移动音符"
+"Move Part" = "移动片段"
+"Move Parts" = "移动多个片段"
+"Move Track" = "移动轨道"
+"Move Tracks" = "移动多个轨道"
+"Opened Project" = "工程打开时"
+"Recover Project" = "恢复工程"
+"Rename Part" = "重命名片段"
+"Rename Track" = "重命名轨道"
+"Resize Notes" = "调整音符长度"
+"Resize Part" = "调整片段长度"
+"Run Script" = "运行脚本"
+"Set Track Color" = "设置轨道颜色"
+"Split Phoneme" = "拆分音素"
+"Transpose Notes" = "移调音符"
[AgentSideBarContentProvider]
"Agent" = "Agent"
diff --git a/TuneLab/Resources/Translations/zh-TW.toml b/TuneLab/Resources/Translations/zh-TW.toml
index a0da2d5b..140cf6a2 100644
--- a/TuneLab/Resources/Translations/zh-TW.toml
+++ b/TuneLab/Resources/Translations/zh-TW.toml
@@ -311,6 +311,43 @@
"Recent" = "最近使用"
"Set Instrument" = "設定樂器"
"About TuneLab" = "關於 TuneLab"
+"Add List Item" = "新增清單項目"
+"Add Note" = "新增音符"
+"Add Part" = "新增片段"
+"Add Property" = "新增屬性"
+"Add Vibrato" = "新增顫音"
+"Change Lyric" = "修改歌詞"
+"Delete List Item" = "刪除清單項目"
+"Delete Notes" = "刪除音符"
+"Delete Part" = "刪除片段"
+"Delete Parts" = "刪除多個片段"
+"Delete Phoneme" = "刪除音素"
+"Delete Property" = "刪除屬性"
+"Delete Track" = "刪除軌道"
+"Delete Vibratos" = "刪除顫音"
+"Draw Pitch" = "繪製音高"
+"Edit Automation" = "編輯參數"
+"Edit Effects" = "編輯效果"
+"Edit Project" = "編輯專案"
+"Edit Properties" = "編輯屬性"
+"Edit Vibrato" = "編輯顫音"
+"Erase Pitch" = "擦除音高"
+"History" = "歷史記錄"
+"Move Notes" = "移動音符"
+"Move Part" = "移動片段"
+"Move Parts" = "移動多個片段"
+"Move Track" = "移動軌道"
+"Move Tracks" = "移動多個軌道"
+"Opened Project" = "開啟專案時"
+"Recover Project" = "復原專案"
+"Rename Part" = "重新命名片段"
+"Rename Track" = "重新命名軌道"
+"Resize Notes" = "調整音符長度"
+"Resize Part" = "調整片段長度"
+"Run Script" = "執行腳本"
+"Set Track Color" = "設定軌道顏色"
+"Split Phoneme" = "拆分音素"
+"Transpose Notes" = "移調音符"
[AgentSideBarContentProvider]
"Agent" = "助手"
diff --git a/TuneLab/Scripting/ScriptContext.cs b/TuneLab/Scripting/ScriptContext.cs
index f5956913..d89aef0b 100644
--- a/TuneLab/Scripting/ScriptContext.cs
+++ b/TuneLab/Scripting/ScriptContext.cs
@@ -31,6 +31,7 @@ internal sealed class ScriptContext
readonly Func? mLanguage;
readonly Func? mSelection;
readonly Func? mPianoSelection;
+ readonly string? mHistoryDetail;
readonly Head mStartHead; // 运行前的撤销锚点(构造时即捕获,早于任何 merge 括号/改动);出错回退至此
// 本次运行能否写:构造时取一次 Pushable()。脚本同步跑、运行期用户无法插入新操作,故此值全程不变——
// 守卫只在"首次写入"时检查它(EnsureWritable),从而只读脚本即便在用户操作中途也畅通,只拦写。
@@ -46,7 +47,7 @@ internal sealed class ScriptContext
readonly HashSet mBracketed = new();
int mChanges; // 发生的改动计数(>0 才 Commit)
- public ScriptContext(IProject project, Func? currentPart, Func? quantization, Func? language, Func? selection, Func? pianoSelection)
+ public ScriptContext(IProject project, Func? currentPart, Func? quantization, Func? language, Func? selection, Func? pianoSelection, string? historyDetail = null)
{
mProject = project;
mCurrentPart = currentPart;
@@ -54,6 +55,7 @@ public ScriptContext(IProject project, Func? currentPart, Func 0)
{
- mProject.Commit();
+ mProject.Commit("Run Script", mHistoryDetail);
return true;
}
mProject.DiscardTo(mStartHead);
diff --git a/TuneLab/Scripting/ScriptRunner.cs b/TuneLab/Scripting/ScriptRunner.cs
index 288c0d16..6d1db0c8 100644
--- a/TuneLab/Scripting/ScriptRunner.cs
+++ b/TuneLab/Scripting/ScriptRunner.cs
@@ -52,10 +52,10 @@ internal static Engine CreateEngine(ScriptLimits limits, CancellationToken cance
});
}
- public static ScriptRunResult Run(IProject project, Func? currentPart, Func? quantization, Func? language, Func? selection, Func? pianoSelection, ScriptLimits limits, string code, CancellationToken cancellationToken)
+ public static ScriptRunResult Run(IProject project, Func? currentPart, Func? quantization, Func? language, Func? selection, Func? pianoSelection, ScriptLimits limits, string code, CancellationToken cancellationToken, string? historyDetail = null)
{
// 写守卫不在入口、而下沉到首次写入(ScriptContext.EnsureWritable):只读脚本即便在用户操作中途也畅通,只拦写。
- var context = new ScriptContext(project, currentPart, quantization, language, selection, pianoSelection);
+ var context = new ScriptContext(project, currentPart, quantization, language, selection, pianoSelection, historyDetail);
var output = new StringBuilder();
string? resultText = null;
string? error = null;
diff --git a/TuneLab/UI/LyricInput/LyricInput.axaml.cs b/TuneLab/UI/LyricInput/LyricInput.axaml.cs
index 977dd03d..2d063e92 100644
--- a/TuneLab/UI/LyricInput/LyricInput.axaml.cs
+++ b/TuneLab/UI/LyricInput/LyricInput.axaml.cs
@@ -106,7 +106,7 @@ void OnLyricInputConfirm()
note.Pronunciation.Set(current.Pronunciation);
}
- mNotes.First().Commit();
+ mNotes.First().Commit("Change Lyric");
Close();
}
diff --git a/TuneLab/UI/MainWindow/Editor/Editor.cs b/TuneLab/UI/MainWindow/Editor/Editor.cs
index 66e77913..34f63a36 100644
--- a/TuneLab/UI/MainWindow/Editor/Editor.cs
+++ b/TuneLab/UI/MainWindow/Editor/Editor.cs
@@ -59,6 +59,7 @@ internal class Editor : DockPanel, PianoWindow.IDependency, TrackWindow.IDepende
public INotifiableProperty PlayScrollTarget { get; } = new NotifiableProperty(UI.PlayScrollTarget.None);
public Editor()
{
+ mHistorySideBarContentProvider = new(mDocument);
Background = Style.BACK.ToBrush();
Focusable = true;
IsTabStop = false;
@@ -201,8 +202,10 @@ public Editor()
mRightSideTabBar.SelectedTab.Modified.Subscribe(() =>
{
+ var selectedTab = mRightSideTabBar.SelectedTab.Value;
+ mHistorySideBarContentProvider.SetActive(selectedTab == SideBarTab.History);
mRightSideBar.IsVisible = true;
- switch (mRightSideTabBar.SelectedTab.Value)
+ switch (selectedTab)
{
case SideBarTab.PartProperties:
mRightSideBar.SetContent(SideBarTab.PartProperties, mPartPropertySideBarContentProvider.Content);
@@ -210,6 +213,9 @@ public Editor()
case SideBarTab.NoteProperties:
mRightSideBar.SetContent(SideBarTab.NoteProperties, mNotePropertySideBarContentProvider.Content);
break;
+ case SideBarTab.History:
+ mRightSideBar.SetFullContent(SideBarTab.History, mHistorySideBarContentProvider.Icon, mHistorySideBarContentProvider.Name, mHistorySideBarContentProvider.Root);
+ break;
case SideBarTab.Extensions:
mExtensionSideBarContentProvider.RefreshExtensions();
mRightSideBar.SetContent(SideBarTab.Extensions, mExtensionSideBarContentProvider.Content);
@@ -1051,7 +1057,7 @@ public void AddTrack()
return;
project.NewTrack();
- project.Commit();
+ project.Commit("Add Track", project.Tracks[project.Tracks.Count - 1].Name.Value);
}
public void ImportAudio()
@@ -1573,6 +1579,7 @@ enum PartPanelFocusArea { Piano, Arrangement }
readonly ExportSideBarContentProvider mExportSideBarContentProvider = new();
readonly AgentSideBarContentProvider mAgentSideBarContentProvider = new();
readonly ScriptSideBarContentProvider mScriptSideBarContentProvider = new();
+ readonly HistorySideBarContentProvider mHistorySideBarContentProvider;
readonly PlayheadForProject mPlayhead;
diff --git a/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRenderer.cs b/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRenderer.cs
index b29b0ff8..94965e7d 100644
--- a/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRenderer.cs
+++ b/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRenderer.cs
@@ -702,7 +702,7 @@ public void DeleteSelectedAnchors()
return;
}
- Part.Commit();
+ Part.Commit("Edit Automation");
InvalidateVisual();
UpdateAnchorValueInput();
}
@@ -780,7 +780,7 @@ void OnAnchorValueInputEndInput()
part.BeginMergeDirty();
automation.MoveSelectedPoints(0, valueOffset);
part.EndMergeDirty();
- part.Commit();
+ part.Commit("Edit Automation");
InvalidateVisual();
UpdateAnchorValueInput();
}
diff --git a/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererOperation.cs b/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererOperation.cs
index e3e58547..34eca06c 100644
--- a/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererOperation.cs
+++ b/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererOperation.cs
@@ -132,7 +132,7 @@ protected override void OnMouseDown(MouseDownEventArgs e)
{
vibrato.RemoveAssociation(automationKey.Value);
}
- Part.Commit();
+ Part.Commit("Edit Vibrato");
}
else
{
@@ -512,7 +512,7 @@ public void Up()
return;
mPart!.EndMergeDirty();
- mPart.Commit();
+ mPart.Commit("Edit Properties");
mPart = null;
mValues.Clear();
State = State.None;
@@ -597,7 +597,7 @@ public void Up()
return;
mPart!.EndMergeDirty();
- mPart.Commit();
+ mPart.Commit("Edit Properties");
mPart = null;
State = State.None;
}
@@ -672,7 +672,7 @@ public void Up()
return;
mPart!.EndMergeDirty();
- mPart.Commit();
+ mPart.Commit("Edit Properties");
mPart = null;
mTargets.Clear();
State = State.None;
@@ -766,7 +766,7 @@ public void Up()
return;
mPart!.EndMergeDirty();
- mPart.Commit();
+ mPart.Commit("Edit Properties");
mPart = null;
State = State.None;
}
@@ -975,7 +975,7 @@ public void Up()
mAutomation.AddLine(line.Simplify(5, 2), Settings.ParameterBoundaryExtension);
}
AutomationRenderer.Part.EndMergeDirty();
- mAutomation.Commit();
+ mAutomation.Commit("Edit Automation");
mAutomation = null;
mPointLines.Clear();
State = State.None;
@@ -1050,7 +1050,7 @@ public void Up()
mAutomation.DiscardTo(mHead);
mAutomation.Clear(mStart, mEnd, Settings.ParameterBoundaryExtension);
AutomationRenderer.Part.EndMergeDirty();
- mAutomation.Commit();
+ mAutomation.Commit("Edit Automation");
mAutomation = null;
State = State.None;
}
@@ -1204,7 +1204,7 @@ public void Up()
mAutomation.DiscardTo(mHead);
mAutomation.DeletePoints(mStart, mEnd);
AutomationRenderer.Part.EndMergeDirty();
- mAutomation.Commit();
+ mAutomation.Commit("Edit Automation");
mAutomation = null;
State = State.None;
AutomationRenderer.UpdateAnchorValueInput();
@@ -1277,7 +1277,7 @@ public void Up()
AutomationRenderer.Part.EndMergeDirty();
if (mMoved || mKeepChangeWithoutMove)
{
- AutomationRenderer.Part.Commit();
+ AutomationRenderer.Part.Commit("Edit Automation");
}
else
{
@@ -1390,7 +1390,7 @@ public void Up()
}
else
{
- AutomationRenderer.Part.Commit();
+ AutomationRenderer.Part.Commit("Edit Vibrato");
}
mVibratos = null;
}
diff --git a/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererPiecewiseOperation.cs b/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererPiecewiseOperation.cs
index 1d2ba5be..965a9c51 100644
--- a/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererPiecewiseOperation.cs
+++ b/TuneLab/UI/MainWindow/Editor/PianoWindow/ParameterArea/AutomationRendererPiecewiseOperation.cs
@@ -192,7 +192,7 @@ public void Up()
foreach (var line in mPointLines)
mAutomation.AddLine(line.Simplify(5, 2), Settings.ParameterBoundaryExtension);
AutomationRenderer.Part.EndMergeDirty();
- mAutomation.Commit();
+ mAutomation.Commit("Edit Automation");
mAutomation = null;
mPointLines.Clear();
State = State.None;
@@ -254,7 +254,7 @@ public void Up()
mAutomation.DiscardTo(mHead);
mAutomation.Clear(mStart, mEnd);
AutomationRenderer.Part.EndMergeDirty();
- mAutomation.Commit();
+ mAutomation.Commit("Edit Automation");
mAutomation = null;
State = State.None;
}
@@ -307,7 +307,7 @@ public void Up()
mAutomation.DiscardTo(mHead);
mAutomation.DeletePoints(mStart, mEnd);
AutomationRenderer.Part.EndMergeDirty();
- mAutomation.Commit();
+ mAutomation.Commit("Edit Automation");
mAutomation = null;
State = State.None;
}
@@ -371,7 +371,7 @@ public void Up()
AutomationRenderer.Part.EndMergeDirty();
if (mMoved || mKeepChangeWithoutMove)
{
- AutomationRenderer.Part.Commit();
+ AutomationRenderer.Part.Commit("Edit Automation");
}
else
{
diff --git a/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollView.cs b/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollView.cs
index ad5c9a6d..aa78b16d 100644
--- a/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollView.cs
+++ b/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollView.cs
@@ -1474,7 +1474,14 @@ public void PasteRegion(RegionDataKind kind, double pos)
Part.PasteAt(new ParameterClipboard { Pitch = [], Automations = mParameterClipboard.Automations }, pos, Settings.ParameterBoundaryExtension);
break;
}
- Part.Commit();
+ Part.Commit(kind switch
+ {
+ RegionDataKind.Notes => "Paste Notes",
+ RegionDataKind.Vibratos => "Paste Vibratos",
+ RegionDataKind.Pitch => "Paste Pitch",
+ RegionDataKind.Automations => "Paste Automations",
+ _ => "Paste",
+ });
}
// 删除选区内指定类型(kind=null 全部;不清区本身)。Pitch/Automations 各自拆出(= ClearParameters 的拆分),支持粒度删/剪。
@@ -1495,7 +1502,14 @@ public void DeleteRegion(RegionDataKind? kind)
if (kind is null or RegionDataKind.Automations)
foreach (var automation in Part.Automations.Values)
automation.Clear(s, e, Settings.ParameterBoundaryExtension);
- Part.Commit();
+ Part.Commit(kind switch
+ {
+ RegionDataKind.Notes => "Delete Notes",
+ RegionDataKind.Vibratos => "Delete Vibratos",
+ RegionDataKind.Pitch => "Erase Pitch",
+ RegionDataKind.Automations => "Edit Automation",
+ _ => "Delete Selection",
+ });
}
// 剪切选区内指定类型 = 复制 + 删除(同一 kind)。
@@ -1587,7 +1601,7 @@ public void PasteAt(double pos)
any = true;
}
if (any)
- Part.Commit();
+ Part.Commit("Paste");
}
public void Cut()
@@ -1613,15 +1627,15 @@ public void Delete()
{
case PianoTool.Note:
Part.DeleteAllSelectedNotes();
- Part.Commit();
+ Part.Commit("Delete Notes");
break;
case PianoTool.Vibrato:
Part.DeleteAllSelectedVibratos();
- Part.Commit();
+ Part.Commit("Delete Vibratos");
break;
case PianoTool.Anchor:
Part.Pitch.DeleteAllSelectedAnchors();
- Part.Commit();
+ Part.Commit("Erase Pitch");
break;
default:
break;
@@ -1646,7 +1660,7 @@ public void ChangeKey(int offset)
note.Pitch.Set(note.Pitch.Value + offset);
}
Part.EndMergeDirty();
- Part.Commit();
+ Part.Commit("Transpose Notes");
}
public void OctaveUp()
@@ -1715,7 +1729,7 @@ void OnLyricInputComplete()
if (!string.IsNullOrEmpty(newLyric) && newLyric != mInputLyricNote.Lyric.Value)
{
mInputLyricNote.Lyric.Set(newLyric);
- mInputLyricNote.Commit();
+ mInputLyricNote.Commit("Change Lyric", newLyric);
}
mLyricInput.IsVisible = false;
@@ -1810,7 +1824,7 @@ void OnPhonemeInputComplete()
}));
}
}
- note.Commit();
+ note.Commit("Edit Properties");
}
Rect PhonemeInputRect()
diff --git a/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollViewOperation.cs b/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollViewOperation.cs
index a5d45ab8..6bdeded0 100644
--- a/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollViewOperation.cs
+++ b/TuneLab/UI/MainWindow/Editor/PianoWindow/PianoScrollView/PianoScrollViewOperation.cs
@@ -204,7 +204,7 @@ bool DetectWaveformPrimaryButton()
part.MoveNote(coupled, () => coupled.Dur.Set(end - coupled.Pos.Value));
part.RemoveNote(sn);
part.EndMergeDirty();
- part.Commit();
+ part.Commit("Merge");
}
else
{
@@ -278,7 +278,7 @@ bool DetectWaveformPrimaryButton()
var menuItem = new MenuItem().SetName(pronunciation).SetAction(() =>
{
note.Pronunciation.Set(pronunciation);
- note.Commit();
+ note.Commit("Edit Properties");
});
menu.Items.Add(menuItem);
}
@@ -294,7 +294,7 @@ bool DetectWaveformPrimaryButton()
if (!alt) pos = GetQuantizedTick(pos);
var note = Part.CreateNote(new NoteInfo() { Pos = pos - Part.Pos.Value, Dur = QuantizedCellTicks(), Pitch = pitch, Lyric = Part.SoundSource.DefaultLyric });
Part.InsertNote(note);
- mNoteEndResizeOperation.Down(TickAxis.Tick2X(note.GlobalEndPos()), note);
+ mNoteEndResizeOperation.Down(TickAxis.Tick2X(note.GlobalEndPos()), note, created: true);
}
else
{
@@ -327,7 +327,7 @@ bool DetectWaveformPrimaryButton()
var menuItem = new MenuItem().SetName("Split".Tr(TC.Menu)).SetAction(() =>
{
note.SplitAt(splitPos);
- Part.Commit();
+ Part.Commit("Split");
});
menu.Items.Add(menuItem);
}
@@ -376,7 +376,7 @@ bool DetectWaveformPrimaryButton()
Part.InsertNote(note);
}
Part.EndMergeDirty();
- Part.Commit();
+ Part.Commit("Split by Phonemes");
});
menu.Items.Add(menuItem);
}
@@ -398,7 +398,7 @@ bool DetectWaveformPrimaryButton()
}
Part.EndMergeDirty();
if (changed)
- Part.Commit();
+ Part.Commit("Lock Phonemes");
else
Part.Discard();
});
@@ -421,7 +421,7 @@ bool DetectWaveformPrimaryButton()
}
Part.EndMergeDirty();
if (changed)
- Part.Commit();
+ Part.Commit("Clear Locked Phonemes");
});
menu.Items.Add(menuItem);
}
@@ -460,7 +460,7 @@ bool DetectWaveformPrimaryButton()
it = next;
}
Part.EndMergeDirty();
- Part.Commit();
+ Part.Commit("Move Lyrics Forward");
});
menu.Items.Add(menuItem);
}
@@ -478,7 +478,7 @@ bool DetectWaveformPrimaryButton()
}
note.Lyric.Set("-");
Part.EndMergeDirty();
- Part.Commit();
+ Part.Commit("Move Lyrics Backward");
});
menu.Items.Add(menuItem);
}
@@ -490,7 +490,7 @@ bool DetectWaveformPrimaryButton()
var menuItem = new MenuItem().SetName("Remove Overlaps".Tr(TC.Menu)).SetAction(() =>
{
if (Part.RemoveOverlaps(Part.Notes.AllSelectedItems()))
- Part.Commit();
+ Part.Commit("Remove Overlaps");
});
menu.Items.Add(menuItem);
}
@@ -1649,7 +1649,7 @@ public void Up()
{
PianoScrollView.Part.Pitch.AddLine(line.Simplify(5, 2), Settings.ParameterBoundaryExtension);
}
- PianoScrollView.Part.Pitch.Commit();
+ PianoScrollView.Part.Pitch.Commit("Draw Pitch");
mPointLines.Clear();
}
@@ -1716,7 +1716,7 @@ public void Up()
PianoScrollView.Part.Pitch.DiscardTo(mHead);
PianoScrollView.Part.Pitch.Clear(mStart, mEnd);
PianoScrollView.Part.EndMergeDirty();
- PianoScrollView.Part.Pitch.Commit();
+ PianoScrollView.Part.Pitch.Commit("Erase Pitch");
}
double mStart;
@@ -1775,7 +1775,7 @@ public void Up()
PianoScrollView.Part.DiscardTo(mHead);
PianoScrollView.Part.LockPitch(mStart, mEnd, Settings.ParameterBoundaryExtension);
PianoScrollView.Part.EndMergeDirty();
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Draw Pitch");
}
double mStart;
@@ -1943,7 +1943,7 @@ public void Up()
PianoScrollView.Part.EndMergeDirty();
if (mMoved)
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Move Notes");
}
else
{
@@ -2098,7 +2098,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Resize Notes");
}
mNote = null;
mCoupledPrev = null;
@@ -2116,7 +2116,7 @@ public void Up()
class NoteEndResizeOperation(PianoScrollView pianoScrollView) : Operation(pianoScrollView)
{
// freeform(波形带入口):吸附反转——默认自由、Alt 吸附网格;note 矩形入口保持默认吸附、Alt 自由。
- public void Down(double x, INote note, bool freeform = false)
+ public void Down(double x, INote note, bool freeform = false, bool created = false)
{
if (PianoScrollView.Part == null)
return;
@@ -2126,6 +2126,7 @@ public void Down(double x, INote note, bool freeform = false)
mHead = PianoScrollView.Part.Head;
mNote = note;
mFreeform = freeform;
+ mCreated = created;
double end = PianoScrollView.TickAxis.Tick2X(mNote.GlobalEndPos());
mOffset = x - end;
}
@@ -2199,7 +2200,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit(mCreated ? "Add Note" : "Resize Notes");
}
mNote = null;
}
@@ -2207,6 +2208,7 @@ public void Up()
double mOffset;
INote? mNote;
bool mFreeform;
+ bool mCreated;
Head mHead;
}
@@ -2246,6 +2248,7 @@ public void Down(double x, Vibrato vibrato)
PianoScrollView.Part.BeginMergeDirty();
mHead = PianoScrollView.Part.Head;
mVibrato = vibrato;
+ mCreated = false;
double start = PianoScrollView.TickAxis.Tick2X(mVibrato.GlobalStartPos());
mOffset = x - start;
mAmplitudeDownPitch = null;
@@ -2261,6 +2264,7 @@ public void DownForCreate(double x, double y, Vibrato vibrato)
if (mVibrato == null)
return;
+ mCreated = true;
mAmplitudeDownPitch = PianoScrollView.PitchAxis.Y2Pitch(y);
mBaseAmplitude = mVibrato.Amplitude;
}
@@ -2321,7 +2325,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit(mCreated ? "Add Vibrato" : "Edit Vibrato");
}
mVibrato = null;
mAmplitudeDownPitch = null;
@@ -2334,6 +2338,7 @@ public void Up()
double mBaseAmplitude;
VibratoItem? mItem;
Vibrato? mVibrato;
+ bool mCreated;
Head mHead;
}
@@ -2400,7 +2405,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Edit Vibrato");
}
mVibrato = null;
mItem = null;
@@ -2465,7 +2470,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Edit Vibrato");
}
mVibratos = null;
PianoScrollView.mOperatingVibratoItem = null;
@@ -2531,7 +2536,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Edit Vibrato");
}
mVibratos = null;
PianoScrollView.mOperatingVibratoItem = null;
@@ -2618,7 +2623,7 @@ public void Up()
}
else
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Edit Vibrato");
}
mVibratos = null;
PianoScrollView.mOperatingVibratoItem = null;
@@ -2681,7 +2686,7 @@ public void Up()
}
else
{
- mPart.Commit();
+ mPart.Commit("Edit Vibrato");
}
mVibratos = null;
mPart = null;
@@ -2744,7 +2749,7 @@ public void Up()
}
else
{
- mPart.Commit();
+ mPart.Commit("Edit Vibrato");
}
mVibratos = null;
mPart = null;
@@ -2829,7 +2834,7 @@ public void Up()
PianoScrollView.Part.EndMergeDirty();
if (mMoved)
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Edit Vibrato");
}
else
{
@@ -2922,7 +2927,7 @@ public void Up()
PianoScrollView.Part.Pitch.DiscardTo(mHead);
PianoScrollView.Part.Pitch.DeletePoints(mStart, mEnd);
PianoScrollView.Part.EndMergeDirty();
- PianoScrollView.Part.Pitch.Commit();
+ PianoScrollView.Part.Pitch.Commit("Erase Pitch");
}
double mStart;
@@ -2987,7 +2992,7 @@ public void Up()
PianoScrollView.Part.EndMergeDirty();
if (mMoved)
{
- PianoScrollView.Part.Commit();
+ PianoScrollView.Part.Commit("Draw Pitch");
}
else
{
@@ -3105,7 +3110,7 @@ public void Up()
}
else
{
- mNote.Part.Commit();
+ mNote.Part.Commit("Edit Properties");
}
mNote = null;
diff --git a/TuneLab/UI/MainWindow/Editor/ScriptToolMenu.cs b/TuneLab/UI/MainWindow/Editor/ScriptToolMenu.cs
index c1b36913..a17a6231 100644
--- a/TuneLab/UI/MainWindow/Editor/ScriptToolMenu.cs
+++ b/TuneLab/UI/MainWindow/Editor/ScriptToolMenu.cs
@@ -226,7 +226,7 @@ static void Run(ScriptToolInfo tool, Control anchor)
}
ScriptRunResult result;
- try { result = ScriptRunner.Run(project, sCurrentPart, sQuantization, () => TranslationManager.CurrentLanguage.Value, sSelection, sPianoSelection, ScriptLimits.Interactive, code, CancellationToken.None); }
+ try { result = ScriptRunner.Run(project, sCurrentPart, sQuantization, () => TranslationManager.CurrentLanguage.Value, sSelection, sPianoSelection, ScriptLimits.Interactive, code, CancellationToken.None, tool.DisplayName); }
catch (Exception ex)
{
_ = anchor.ShowMessage("Script".Tr(TC.Menu), "Host error:".Tr(TC.Dialog) + " " + ex.Message);
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/History/HistorySideBarContentProvider.cs b/TuneLab/UI/MainWindow/Editor/SideBar/History/HistorySideBarContentProvider.cs
new file mode 100644
index 00000000..2c172783
--- /dev/null
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/History/HistorySideBarContentProvider.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Threading;
+using TuneLab.Data;
+using TuneLab.Foundation;
+using TuneLab.GUI;
+using TuneLab.GUI.Components;
+using TuneLab.I18N;
+using TuneLab.Utils;
+
+namespace TuneLab.UI;
+
+internal sealed class HistorySideBarContentProvider
+{
+ public IImage Icon => Assets.History.GetImage(Style.LIGHT_WHITE);
+ public string Name => "History".Tr(TC.Menu);
+ public Control Root => mRoot;
+
+ public HistorySideBarContentProvider(ProjectDocument document)
+ {
+ mDocument = document;
+ mRoot.Content = mRowsPanel;
+ mScrollBars = new OverlayScrollBars(mRoot, horizontal: false, vertical: true);
+ mDocument.StatusChanged += OnDocumentStatusChanged;
+ RebuildRows();
+ }
+
+ public void SetActive(bool active)
+ {
+ mActive = active;
+ if (!active)
+ return;
+
+ RefreshRowTexts();
+ RefreshRowStates();
+ ScrollCurrentRowIntoView();
+ }
+
+ void OnDocumentStatusChanged()
+ {
+ if (mRenderedEntries.Count == mDocument.History.Count &&
+ mRenderedPosition == mDocument.HistoryPosition)
+ {
+ // Pending preview commands also raise StatusChanged, but they do not
+ // change the visible committed history or its cursor.
+ return;
+ }
+
+ if (HistoryChanged())
+ {
+ RebuildRows();
+ }
+ else if (mRenderedPosition != mDocument.HistoryPosition)
+ {
+ RefreshRowStates();
+ }
+ else
+ {
+ return;
+ }
+
+ ScrollCurrentRowIntoView();
+ }
+
+ bool HistoryChanged()
+ {
+ if (mRenderedEntries.Count != mDocument.History.Count)
+ return true;
+
+ for (int i = 0; i < mRenderedEntries.Count; i++)
+ {
+ if (!ReferenceEquals(mRenderedEntries[i], mDocument.History[i]))
+ return true;
+ }
+
+ return false;
+ }
+
+ void RebuildRows()
+ {
+ mRowsPanel.Children.Clear();
+ mRows.Clear();
+ mRenderedEntries.Clear();
+
+ AddRow(0, "Opened Project".Tr(TC.Menu));
+ for (int i = 0; i < mDocument.History.Count; i++)
+ {
+ var entry = mDocument.History[i];
+ mRenderedEntries.Add(entry);
+ AddRow(i + 1, RowText(entry));
+ }
+
+ RefreshRowStates();
+ }
+
+ void AddRow(int position, string text)
+ {
+ var row = new HistoryRow(position, text, MoveToHistory);
+ mRows.Add(row);
+ mRowsPanel.Children.Add(row);
+ }
+
+ void RefreshRowTexts()
+ {
+ if (HistoryChanged())
+ {
+ RebuildRows();
+ return;
+ }
+
+ mRows[0].Text = "Opened Project".Tr(TC.Menu);
+ for (int i = 0; i < mRenderedEntries.Count; i++)
+ {
+ mRows[i + 1].Text = RowText(mRenderedEntries[i]);
+ }
+ }
+
+ void RefreshRowStates()
+ {
+ int current = mDocument.HistoryPosition;
+ for (int i = 0; i < mRows.Count; i++)
+ {
+ mRows[i].SetState(selected: i == current, forward: i > current);
+ }
+
+ mRenderedPosition = current;
+ }
+
+ string RowText(HistoryEntry entry)
+ {
+ string description = entry.Description.Tr(TC.Menu);
+ return string.IsNullOrEmpty(entry.Detail)
+ ? description
+ : description + ": " + entry.Detail;
+ }
+
+ void MoveToHistory(int position)
+ {
+ if (mDocument.MoveToHistory(position))
+ return;
+
+ // A click on the current row, or a blocked jump during an uncommitted
+ // preview, raises no StatusChanged. Keep the visual state on the real cursor.
+ RefreshRowStates();
+ ScrollCurrentRowIntoView();
+ }
+
+ void ScrollCurrentRowIntoView()
+ {
+ if (!mActive || mScrollPending)
+ return;
+
+ mScrollPending = true;
+ Dispatcher.UIThread.Post(() =>
+ {
+ mScrollPending = false;
+ if (!mActive || mRows.Count == 0)
+ return;
+
+ int position = Math.Clamp(mDocument.HistoryPosition, 0, mRows.Count - 1);
+ var row = mRows[position];
+ double rowTop = row.Bounds.Y;
+ double rowHeight = row.Bounds.Height > 0 ? row.Bounds.Height : HistoryRow.RowHeight;
+ if (rowTop == 0 && position > 0)
+ rowTop = position * HistoryRow.RowHeight;
+
+ double viewportHeight = mRoot.Viewport.Height;
+ if (viewportHeight <= 0)
+ return;
+
+ double offset = mRoot.Offset.Y;
+ double target = offset;
+ if (rowTop < offset)
+ target = rowTop;
+ else if (rowTop + rowHeight > offset + viewportHeight)
+ target = rowTop + rowHeight - viewportHeight;
+
+ if (target != offset)
+ mRoot.Offset = new Vector(mRoot.Offset.X, Math.Max(0, target));
+ }, DispatcherPriority.Background);
+ }
+
+ sealed class HistoryRow : Border
+ {
+ public const double RowHeight = 42;
+
+ public string Text
+ {
+ get => mText.Text ?? string.Empty;
+ set
+ {
+ mText.Text = value;
+ ToolTip.SetTip(this, value);
+ }
+ }
+
+ public HistoryRow(int position, string text, Action activate)
+ {
+ mPosition = position;
+ mActivate = activate;
+ Height = RowHeight;
+ BorderBrush = Style.LINE.ToBrush();
+ BorderThickness = new Thickness(0, 0, 0, 1);
+ Cursor = new Cursor(StandardCursorType.Hand);
+
+ // The whole row is one navigation target. Its decorative children must
+ // not become separate pointer targets, otherwise pressing the text can
+ // split the press/release route and prevent the row click from firing.
+ var content = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("3,*"),
+ IsHitTestVisible = false,
+ };
+ mSelectionStrip = new Border();
+ content.Children.Add(mSelectionStrip);
+
+ mText = new TextBlock
+ {
+ FontSize = 13,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ Margin = new Thickness(13, 0, 16, 0),
+ };
+ Grid.SetColumn(mText, 1);
+ content.Children.Add(mText);
+ Child = content;
+ Text = text;
+ RefreshVisual();
+ }
+
+ public void SetState(bool selected, bool forward)
+ {
+ mSelected = selected;
+ mForward = forward;
+ RefreshVisual();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ RefreshVisual();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ RefreshVisual();
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+
+ mPressed = true;
+ e.Pointer.Capture(this);
+ e.Handled = true;
+ RefreshVisual();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (!mPressed)
+ return;
+
+ bool activate = IsPointerOver;
+ mPressed = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ RefreshVisual();
+ if (activate)
+ mActivate(mPosition);
+ }
+
+ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e)
+ {
+ base.OnPointerCaptureLost(e);
+ mPressed = false;
+ RefreshVisual();
+ }
+
+ void RefreshVisual()
+ {
+ Background = mSelected
+ ? Style.HIGH_LIGHT.Opacity(0.18).ToBrush()
+ : IsPointerOver || mPressed
+ ? Colors.White.Opacity(0.05).ToBrush()
+ : Brushes.Transparent;
+ mSelectionStrip.Background = mSelected ? Style.HIGH_LIGHT.ToBrush() : Brushes.Transparent;
+ mText.Foreground = (mSelected
+ ? Style.TEXT_LIGHT
+ : mForward
+ ? Style.LIGHT_WHITE.Opacity(0.42)
+ : Style.TEXT_NORMAL).ToBrush();
+ }
+
+ readonly int mPosition;
+ readonly Action mActivate;
+ readonly Border mSelectionStrip;
+ readonly TextBlock mText;
+ bool mSelected;
+ bool mForward;
+ bool mPressed;
+ }
+
+ readonly ProjectDocument mDocument;
+ readonly ScrollViewer mRoot = new()
+ {
+ Background = Style.INTERFACE.ToBrush(),
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
+ HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Stretch,
+ };
+ readonly StackPanel mRowsPanel = new();
+ readonly List mRows = new();
+ readonly List mRenderedEntries = new();
+ readonly OverlayScrollBars mScrollBars;
+ int mRenderedPosition = -1;
+ bool mActive;
+ bool mScrollPending;
+}
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/AutomationDefaultRow.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/AutomationDefaultRow.cs
index 77390906..0e53456a 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/AutomationDefaultRow.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/AutomationDefaultRow.cs
@@ -28,6 +28,7 @@ public AutomationDefaultRow(IReadOnlyList parts, AutomationKey key, s
{
mParts = parts;
mKey = key;
+ mDetail = keyName;
mConfig = config;
Orientation = Orientation.Vertical;
@@ -111,7 +112,7 @@ void OnValueCommitted()
foreach (var part in mParts)
part.EndMergeDirty();
if (mParts.Count > 0)
- mParts[0].Commit();
+ mParts[0].Commit("Edit Automation", mDetail);
}
// 退出通知 merge(幂等):仅在已进入时 EndMergeNotify,避免无配对多发 / merge 计数泄漏(含编辑中途被释放的兜底)。
@@ -132,6 +133,7 @@ public void Dispose()
readonly IReadOnlyList mParts;
readonly AutomationKey mKey;
+ readonly string mDetail;
readonly AutomationConfig mConfig;
readonly SliderController mSlider;
Head mHead;
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/EffectsController.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/EffectsController.cs
index 52b3f48c..e04e3940 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/EffectsController.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/EffectsController.cs
@@ -136,7 +136,7 @@ void WithBatch(Action mutate)
try
{
mutate();
- mParts[0].Commit(); // 全 part 共享同一文档撤销栈:一次提交归一个撤销单元
+ mParts[0].Commit("Edit Effects"); // 全 part 共享同一文档撤销栈:一次提交归一个撤销单元
}
finally
{
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/NotePropertySideBarContentProvider.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/NotePropertySideBarContentProvider.cs
index 79c7ba6e..5219c3f6 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/NotePropertySideBarContentProvider.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/NotePropertySideBarContentProvider.cs
@@ -456,7 +456,7 @@ Control BuildDoubleField(string tooltip, IReadOnlyList<(PhonemeNoteInfo Note, in
IDataProperty data = props.Count == 1
? props[0]
: new MultipleDataProperty(props, config.DefaultValue, v => PropertyValue.Create(v));
- box.BindDataProperty(data, mPhonemeSub);
+ box.BindDataProperty(data, mPhonemeSub, detail: tooltip);
return box;
}
@@ -521,7 +521,7 @@ void Refresh()
if (head == editHead)
mPart.Discard();
else
- mPart.Commit();
+ mPart.Commit("Edit Properties", tooltip);
// 复位抑制(编辑期被扣下的脏位自动补排),并显式标结构脏:提交后锁定成立,本 slot 转全钉死绑定路径。
mPhonemeScheduler.Suspended = false;
mPhonemeScheduler.InvalidateStructure();
@@ -568,7 +568,7 @@ Avalonia.Controls.ContextMenu BuildSlotContextMenu(IReadOnlyList<(PhonemeNoteInf
list.Insert(local + 1, Phoneme.Create(info));
}
mPart.EndMergeDirty();
- mPart.Commit();
+ mPart.Commit("Split Phoneme");
}));
// 删除:删该位音素;删空则该 note 回到合成音素口径(空钉死列表 ≡ 合成)。
@@ -588,7 +588,7 @@ Avalonia.Controls.ContextMenu BuildSlotContextMenu(IReadOnlyList<(PhonemeNoteInf
}
}
mPart.EndMergeDirty();
- mPart.Commit();
+ mPart.Commit("Delete Phoneme");
}));
return items;
@@ -607,7 +607,7 @@ void CommitSymbol(IReadOnlyList<(PhonemeNoteInfo Note, int Index)> members, stri
if (idx < note.PhonemeCount)
note.Phonemes[idx].Symbol.Set(symbol);
}
- mPart.Commit();
+ mPart.Commit("Edit Properties");
}
void PinAndApply(IReadOnlyList<(INote Note, int Index, DataPropertyObject Buffer)> members)
@@ -620,7 +620,7 @@ void PinAndApply(IReadOnlyList<(INote Note, int Index, DataPropertyObject Buffer
if (idx < note.PhonemeCount)
note.Phonemes[idx].Properties.SetInfo(buf.GetInfo());
}
- mPart.Commit();
+ mPart.Commit("Edit Properties");
}
readonly StackPanel mNoteContent = new() { Orientation = Orientation.Vertical };
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartPropertySideBarContentProvider.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartPropertySideBarContentProvider.cs
index 0bee4b6b..682ba812 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartPropertySideBarContentProvider.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartPropertySideBarContentProvider.cs
@@ -391,7 +391,7 @@ void ApplyPresetToAll(string? presetName)
}
foreach (var part in mParts)
part.EndMergeDirty();
- mParts[0].Commit();
+ mParts[0].Commit("Edit Properties", presetName);
}
// 单 part 的应用(config 按该 part 自身音源现算:apply 可能正在改音源,须 per-part 单元素 context)。
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartVoiceController.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartVoiceController.cs
index 702391c7..f817a14e 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartVoiceController.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/Properties/PartVoiceController.cs
@@ -305,7 +305,8 @@ void ApplyToAll(SourceKind kind, string type, string id)
RecentSoundSourceManager.PushVoice(type, id);
else
RecentSoundSourceManager.PushInstrument(type, id);
- mParts[0].Commit();
+ TryGetSourceName(kind, type, id, out var detail);
+ mParts[0].Commit(kind == SourceKind.Voice ? "Set Voice" : "Set Instrument", string.IsNullOrEmpty(detail) ? null : detail);
}
readonly ComboBoxController mVoiceController = new();
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs b/TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs
index 4fb3b2e0..45b3822a 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs
@@ -12,6 +12,7 @@ internal enum SideBarTab
Extensions,
PartProperties,
NoteProperties,
+ History,
Export,
Agent,
Script,
diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/SideTabBar.cs b/TuneLab/UI/MainWindow/Editor/SideBar/SideTabBar.cs
index 4ae351cb..9b1893dd 100644
--- a/TuneLab/UI/MainWindow/Editor/SideBar/SideTabBar.cs
+++ b/TuneLab/UI/MainWindow/Editor/SideBar/SideTabBar.cs
@@ -42,6 +42,7 @@ void OnTabChanged()
AddTab(SideBarTab.PartProperties, "Part".Tr(this), Assets.Part);
AddTab(SideBarTab.NoteProperties, "Note".Tr(this), Assets.Note);
+ AddTab(SideBarTab.History, "History".Tr(TC.Menu), Assets.History);
AddTab(SideBarTab.Agent, "Agent".Tr(this), Assets.Agent);
AddTab(SideBarTab.Script, "Script".Tr(this), Assets.Script);
AddTab(SideBarTab.Extensions, "Extensions".Tr(this), Assets.Extensions);
diff --git a/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineView.cs b/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineView.cs
index 6b213012..c3aff596 100644
--- a/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineView.cs
+++ b/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineView.cs
@@ -281,7 +281,7 @@ void OnBpmInputComplete()
if (newBpm != mInputBpmTempo.Bpm)
{
Timeline.TempoManager.SetBpm(mInputBpmTempo, newBpm);
- mInputBpmTempo.Commit();
+ mInputBpmTempo.Commit("Edit Tempo");
}
mBpmInput.IsVisible = false;
@@ -307,7 +307,7 @@ void OnMeterInputComplete()
if (numerator != mInputMeterTimeSignature.Numerator || denominator != mInputMeterTimeSignature.Denominator)
{
Timeline.TimeSignatureManager.SetMeter(mInputMeterTimeSignature, numerator, denominator);
- mInputMeterTimeSignature.Commit();
+ mInputMeterTimeSignature.Commit("Edit Time Signature");
}
mMeterInput.IsVisible = false;
diff --git a/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineViewOperation.cs b/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineViewOperation.cs
index f811f521..8ec75ed4 100644
--- a/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineViewOperation.cs
+++ b/TuneLab/UI/MainWindow/Editor/TimelineView/TimelineViewOperation.cs
@@ -89,7 +89,7 @@ protected override void OnMouseDown(MouseDownEventArgs e)
var menuItem = new MenuItem().SetName("Delete Tempo".Tr(TC.Menu)).SetAction(() =>
{
Timeline.TempoManager.RemoveTempoAt(tempoItem.TempoIndex);
- Timeline.TempoManager.Project.Commit();
+ Timeline.TempoManager.Project.Commit("Delete Tempo");
});
menu.Items.Add(menuItem);
}
@@ -110,7 +110,7 @@ protected override void OnMouseDown(MouseDownEventArgs e)
var menuItem = new MenuItem().SetName("Delete Time Signature".Tr(TC.Menu)).SetAction(() =>
{
Timeline.TimeSignatureManager.RemoveTimeSignatureAt(timeSignatureItem.TimeSignatureIndex);
- Timeline.TimeSignatureManager.Project.Commit();
+ Timeline.TimeSignatureManager.Project.Commit("Delete Time Signature");
});
menu.Items.Add(menuItem);
}
@@ -127,7 +127,7 @@ protected override void OnMouseDown(MouseDownEventArgs e)
var meterStatus = Timeline.TimeSignatureManager.GetMeterStatus(pos);
var timesignature = meterStatus.TimeSignature;
Timeline.TimeSignatureManager.AddTimeSignature((int)meterStatus.BarIndex, timesignature.Numerator, timesignature.Denominator);
- Timeline.TimeSignatureManager.Project.Commit();
+ Timeline.TimeSignatureManager.Project.Commit("Add Time Signature");
});
menu.Items.Add(menuItem);
}
@@ -136,7 +136,7 @@ protected override void OnMouseDown(MouseDownEventArgs e)
{
var bpm = Timeline.TempoManager.GetBpmAt(pos);
Timeline.TempoManager.AddTempo(pos, bpm);
- Timeline.TempoManager.Project.Commit();
+ Timeline.TempoManager.Project.Commit("Add Tempo");
});
menu.Items.Add(menuItem);
}
@@ -388,7 +388,7 @@ public void Up()
}
else
{
- mTempoItem.TempoManager.Commit();
+ mTempoItem.TempoManager.Commit("Edit Tempo");
}
TimelineView.InvalidateVisual();
@@ -450,7 +450,7 @@ public void Up()
}
else
{
- mTimeSignatureItem.TimeSignatureManager.Commit();
+ mTimeSignatureItem.TimeSignatureManager.Commit("Edit Time Signature");
}
TimelineView.InvalidateVisual();
diff --git a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHead.cs b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHead.cs
index 68b6e1d7..7d61229a 100644
--- a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHead.cs
+++ b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHead.cs
@@ -36,7 +36,7 @@ internal class TrackHead : DockPanel
{
public TrackHead()
{
- mName.Bind(mTrackHolder.Select(track => track.Name), s);
+ mName.Bind(mTrackHolder.Select(track => track.Name), s, "Rename Track");
mGainSlider.SetRange(-24, 6);
mGainSlider.Select((double value) => value <= mGainSlider.MinValue ? double.NegativeInfinity : value).Bind(mTrackHolder.Select(track => track.Gain), s);
mPanSlider.SetRange(-1, 1);
@@ -146,7 +146,7 @@ public TrackHead()
project.RemoveTrackAt(index);
project.InsertTrack(index - 1, track);
- project.Commit();
+ project.Commit("Move Track", track.Name.Value);
});
menu.Items.Add(menuItem);
menu.Opening += (s, e) =>
@@ -177,7 +177,7 @@ public TrackHead()
project.RemoveTrackAt(index);
project.InsertTrack(index + 1, track);
- project.Commit();
+ project.Commit("Move Track", track.Name.Value);
});
menu.Items.Add(menuItem);
menu.Opening += (s, e) =>
@@ -218,7 +218,7 @@ public TrackHead()
return;
Track.Color.Set(((Color)colorItem.Tag).ToString());
- Track.Color.Commit();
+ Track.Color.Commit("Set Track Color", Track.Name.Value);
});
menuItem.Items.Add(colorItem);
}
@@ -233,7 +233,7 @@ public TrackHead()
return;
track.AsRefer.Set(!track.AsRefer.GetInfo());
- track.AsRefer.Commit();
+ track.AsRefer.Commit(track.AsRefer.GetInfo() ? "Visible as Refer" : "Hidden as Refer", track.Name.Value);
});
menu.Items.Add(menuItem);
menu.Opening += (s, e) =>
@@ -251,8 +251,9 @@ public TrackHead()
return;
var project = Track.Project;
+ var trackName = Track.Name.Value;
project.RemoveTrack(Track);
- project.Commit();
+ project.Commit("Delete Track", trackName);
});
menu.Items.Add(menuItem);
}
@@ -419,7 +420,7 @@ private void MoveToIndex(int newIndex)
{
project.RemoveTrackAt(index);
project.InsertTrack(newIndex, track);
- project.Commit();
+ project.Commit("Move Track", track.Name.Value);
}
}
diff --git a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHeadList.cs b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHeadList.cs
index 8ccfcb3f..9d52656b 100644
--- a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHeadList.cs
+++ b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackHeadList/TrackHeadList.cs
@@ -158,7 +158,7 @@ public void EndTrackHeadDrag()
project.RemoveTrack(tr);
for (int k = 0; k < tracks.Count; k++)
project.InsertTrack(targets[k], tracks[k]);
- project.Commit();
+ project.Commit(tracks.Count == 1 ? "Move Track" : "Move Tracks", tracks.Count == 1 ? tracks[0].Name.Value : null);
}
public void CancelTrackHeadDrag()
@@ -229,7 +229,7 @@ protected override void OnMouseUp(MouseUpEventArgs e)
return;
project.NewTrack();
- project.Commit();
+ project.Commit("Add Track", project.Tracks[project.Tracks.Count - 1].Name.Value);
}
public override void Render(DrawingContext context)
diff --git a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollView.cs b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollView.cs
index 9be49d77..b958a8c8 100644
--- a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollView.cs
+++ b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollView.cs
@@ -385,7 +385,7 @@ public void PasteAt(double pos, int? targetTrackIndex)
track.InsertPart(part);
}
}
- Project.Commit();
+ Project.Commit("Paste");
}
public bool CanPaste => !mPartClipboard.IsEmpty();
@@ -401,15 +401,19 @@ public void DeleteAllSelectedParts()
if (Project == null)
return;
+ int deletedCount = 0;
+ string? detail = null;
foreach (var track in Project.Tracks)
{
var selectedParts = track.Parts.AllSelectedItems();
foreach (var part in selectedParts)
{
+ deletedCount++;
+ detail = deletedCount == 1 ? part.Name.Value : null;
track.RemovePart(part);
}
}
- Project.Commit();
+ Project.Commit(deletedCount == 1 ? "Delete Part" : "Delete Parts", detail);
}
public void DeleteTrackAt(int trackIndex)
@@ -417,8 +421,9 @@ public void DeleteTrackAt(int trackIndex)
if (Project == null)
return;
+ var trackName = Project.Tracks[trackIndex].Name.Value;
Project.RemoveTrackAt(trackIndex);
- Project.Commit();
+ Project.Commit("Delete Track", trackName);
}
public async void ImportAudioAt(double pos, int trackIndex)
@@ -463,7 +468,7 @@ public async void ImportAudioAt(double pos, int trackIndex)
{
track.Name.Set(name);
}
- project.Commit();
+ project.Commit("Import Audio", name);
}
@@ -639,7 +644,7 @@ double SyncTick(double src)
srcTrackInfo.Parts=parts;
dstProject.AddTrack(srcTrackInfo);
}
- dstProject.Commit();
+ dstProject.Commit("Import Track", Path.GetFileName(path));
}
public void EnterInputPartName(IPart part, int trackIndex)
@@ -672,7 +677,7 @@ void OnNameInputComplete()
if (!string.IsNullOrEmpty(newLyric) && newLyric != mInputNamePart.Name.Value)
{
mInputNamePart.Name.Set(newLyric);
- mInputNamePart.Commit();
+ mInputNamePart.Commit("Rename Part", newLyric);
}
mNameInput.IsVisible = false;
@@ -847,7 +852,7 @@ public void MergeRegionPerTrack(RegionSelection selection)
foreach (var leftover in leftovers)
track.InsertPart(track.CreatePart(leftover));
}
- Project.Commit();
+ Project.Commit("Merge");
}
// 把任意 part 裁到绝对 tick 区间 [start, end] 得到只含该段的 PartInfo(闸刀切分/复制的共用原语):
@@ -946,7 +951,7 @@ public void DeleteRegion(RegionSelection selection)
track.InsertPart(track.CreatePart(leftover));
}
}
- Project.Commit();
+ Project.Commit("Delete Selection");
}
// 剪切选区(闸刀)= 复制裁到选区的片段 + 删除选区内片段(保留区外)。
diff --git a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollViewOperation.cs b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollViewOperation.cs
index ccb316d8..1479fb30 100644
--- a/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollViewOperation.cs
+++ b/TuneLab/UI/MainWindow/Editor/TrackWindow/TrackScrollView/TrackScrollViewOperation.cs
@@ -93,7 +93,7 @@ protected override async void OnMouseDown(MouseDownEventArgs e)
if (File.Exists(path))
{
audioPart.Path.Set(path);
- audioPart.Commit();
+ audioPart.Commit("Import Audio", Path.GetFileName(path));
}
}
else
@@ -138,7 +138,7 @@ protected override async void OnMouseDown(MouseDownEventArgs e)
var pos = GetQuantizedTick(TickAxis.X2Tick(e.Position.X));
var part = track.CreatePart(new MidiPartInfo() { Name = "Part".Tr(TC.Document) + "_" + (track.Project.PartsCount() + 1), Pos = pos, EndOffset = QuantizedCellTicks(), SoundSource = RecentSoundSourceManager.DefaultVoiceSoundSource() });
track.InsertPart(part);
- mPartEndResizeOperation.Down(TickAxis.Tick2X(part.EndPos), part, track);
+ mPartEndResizeOperation.Down(TickAxis.Tick2X(part.EndPos), part, track, created: true);
}
else
{
@@ -232,7 +232,7 @@ protected override async void OnMouseDown(MouseDownEventArgs e)
track.RemovePart(part);
track.InsertPart(track.CreatePart(leftInfo));
track.InsertPart(track.CreatePart(rightInfo));
- track.Commit();
+ track.Commit("Split", part.Name.Value);
});
menu.Items.Add(menuItem);
}
@@ -257,7 +257,7 @@ protected override async void OnMouseDown(MouseDownEventArgs e)
var newPartInfo = IMidiPartExtension.MergePartInfos(oldPartInfos);
foreach(var oldPart in oldParts) track.RemovePart(oldPart);
track.InsertPart(track.CreatePart(newPartInfo));
- track.Commit();
+ track.Commit("Merge");
});
menu.Items.Add(menuItem);
}
@@ -277,7 +277,8 @@ void ApplyVoice(string type, string id)
}
}
RecentSoundSourceManager.PushVoice(type, id);
- Project.Commit();
+ var detail = VoicesManager.TryGetVoiceInfo(type, id, out var info) ? info.Name : id;
+ Project.Commit("Set Voice", detail);
}
// 最近栏:列最近使用的 voice,选项写「引擎名 - voice 名」,身份失效(卸载/改 id)的项跳过。
@@ -358,7 +359,8 @@ void ApplyInstrument(string type, string id)
}
}
RecentSoundSourceManager.PushInstrument(type, id);
- Project.Commit();
+ var detail = InstrumentsManager.TryGetInstrumentInfo(type, id, out var info) ? info.Name : id;
+ Project.Commit("Set Instrument", detail);
}
// 最近栏:列最近使用的 instrument,选项写「引擎名 - instrument 名」,身份失效(卸载/改 id)的项跳过。
@@ -434,7 +436,7 @@ void AddInstrumentNodes(ItemCollection target, string type, IReadOnlyList group.parts.Count);
+ TrackScrollView.Project.Commit(movedCount == 1 ? "Move Part" : "Move Parts", movedCount == 1 ? mPart.Name.Value : null);
}
else
{
@@ -1165,11 +1168,12 @@ struct PartsWithTrackIndex(int trackIndex, IReadOnlyCollection parts)
class PartEndResizeOperation(TrackScrollView trackScrollView) : Operation(trackScrollView)
{
- public void Down(double x, IPart part, ITrack track)
+ public void Down(double x, IPart part, ITrack track, bool created = false)
{
State = State.PartEndResizing;
mPart = part;
mTrack = track;
+ mDescription = created ? "Add Part" : "Resize Part";
double end = TrackScrollView.TickAxis.Tick2X(mPart.EndPos());
mOffset = x - end;
mHead = mPart.Head;
@@ -1201,13 +1205,14 @@ public void Up()
if (mPart == null)
return;
- mPart.Commit();
+ mPart.Commit(mDescription, mPart.Name.Value);
mPart = null;
mTrack = null;
}
Head mHead;
double mOffset;
+ string mDescription = "Resize Part";
IPart? mPart;
ITrack? mTrack;
}
@@ -1254,7 +1259,7 @@ public void Up()
if (mPart == null)
return;
- mPart.Commit();
+ mPart.Commit("Resize Part", mPart.Name.Value);
mPart = null;
mTrack = null;
}
@@ -1336,7 +1341,8 @@ public void Drop()
track.InsertPart(part);
trackIndex++;
}
- TrackScrollView.Project.Commit();
+ var detail = mPreImportAudioInfos.Count == 1 ? mPreImportAudioInfos[0].name : null;
+ TrackScrollView.Project.Commit("Import Audio", detail);
mPreImportAudioInfos.Clear();
}
diff --git a/TuneLab/UI/MainWindow/MainWindow.axaml.cs b/TuneLab/UI/MainWindow/MainWindow.axaml.cs
index 9823773d..c2f9069d 100644
--- a/TuneLab/UI/MainWindow/MainWindow.axaml.cs
+++ b/TuneLab/UI/MainWindow/MainWindow.axaml.cs
@@ -134,7 +134,7 @@ protected override async void OnOpened(EventArgs e)
return;
mEditor.Project.SetInfo(info);
- mEditor.Project.Commit();
+ mEditor.Project.Commit("Recover Project", fileName);
foreach (var part in mEditor.Project.Tracks.SelectMany(track => track.Parts))
{
if (part is MidiPart midiPart)
diff --git a/docs/PLANS.md b/docs/PLANS.md
new file mode 100644
index 00000000..15d9583b
--- /dev/null
+++ b/docs/PLANS.md
@@ -0,0 +1,150 @@
+# Codex Execution Plans (ExecPlans):
+
+This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context.
+
+## How to use ExecPlans and PLANS.md
+
+When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
+
+When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently.
+
+When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work.
+
+When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation.
+
+## Requirements
+
+NON-NEGOTIABLE REQUIREMENTS:
+
+* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
+* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
+* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
+* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
+* Every ExecPlan must define every term of art in plain language or do not use it.
+
+Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe.
+
+The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan.
+
+## Formatting
+
+Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists.
+
+When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
+
+Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first.
+
+## Guidelines
+
+Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself.
+
+Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details.
+
+Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior).
+
+Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable.
+
+Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go.
+
+Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results.
+
+Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs.
+
+## Milestones
+
+Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
+
+Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
+
+## Living plans and design decisions
+
+* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section.
+* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional.
+* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal).
+* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you.
+* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned.
+
+# Prototyping milestones and parallel implementations
+
+It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype.
+
+Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation.
+
+## Skeleton of a Good ExecPlan
+
+ #
+
+ This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
+
+ If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md.
+
+ ## Purpose / Big Picture
+
+ Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable.
+
+ ## Progress
+
+ Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work.
+
+ - [x] (2025-10-01 13:00Z) Example completed step.
+ - [ ] Example incomplete step.
+ - [ ] Example partially completed step (completed: X; remaining: Y).
+
+ Use timestamps to measure rates of progress.
+
+ ## Surprises & Discoveries
+
+ Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence.
+
+ - Observation: …
+ Evidence: …
+
+ ## Decision Log
+
+ Record every decision made while working on the plan in the format:
+
+ - Decision: …
+ Rationale: …
+ Date/Author: …
+
+ ## Outcomes & Retrospective
+
+ Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose.
+
+ ## Context and Orientation
+
+ Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans.
+
+ ## Plan of Work
+
+ Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
+
+ ## Concrete Steps
+
+ State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds.
+
+ ## Validation and Acceptance
+
+ Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run and expect passed; the new test fails before the change and passes after>".
+
+ ## Idempotence and Recovery
+
+ If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion.
+
+ ## Artifacts and Notes
+
+ Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success.
+
+ ## Interfaces and Dependencies
+
+ Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.:
+
+ In crates/foo/planner.rs, define:
+
+ pub trait Planner {
+ fn plan(&self, observed: &Observed) -> Vec;
+ }
+
+If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED.
+
+When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything.
\ No newline at end of file
diff --git a/docs/undo-redo-history-execplan.md b/docs/undo-redo-history-execplan.md
new file mode 100644
index 00000000..d3486fcb
--- /dev/null
+++ b/docs/undo-redo-history-execplan.md
@@ -0,0 +1,497 @@
+# Add a navigable undo and redo history
+
+This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. At every stopping point, update `Progress`, and when this plan changes, append a short revision note at the bottom explaining what changed and why.
+
+This document must be maintained in accordance with `docs/PLANS.md`. It is intentionally self-contained: a contributor should be able to implement the feature using only the current working tree and this file.
+
+## Purpose / Big Picture
+
+TuneLab already lets a user undo or redo one committed edit at a time, but it does not show the edits that have occurred since the project was opened and it cannot jump directly to an earlier or later point. After this work, the right sidebar will contain a History page. It will show the opened-project baseline followed by every committed project edit, identify the current state, and let the user click any retained state to move there. If the user moves backward and then makes a new edit, the superseded forward entries disappear; history remains a single line and does not retain alternate branches.
+
+The feature applies to project edits that already participate in TuneLab's undo system, such as adding, deleting, moving, resizing, renaming, drawing parameters, changing properties, changing tempo, and running an editing script. View-only interactions such as selection, playhead movement, scrolling, zoom, playback, and opening a sidebar are not history entries. The export options currently stored as ordinary `Project` properties are also outside this plan because they do not participate in the existing command system. History lasts only for the current open project and is not serialized into `.tlp` files.
+
+The user can see the feature working by making several different edits, opening History, clicking an earlier row, clicking a later row, and then branching from an earlier row with a new edit. The visible project data must follow the selected row, ordinary Ctrl+Z/Ctrl+Y must remain synchronized with the row selection, and the abandoned forward rows must vanish after the branch.
+
+## Progress
+
+- [x] (2026-07-22 02:41Z) Inspected the current document command stack, project lifecycle, undo/redo UI integration, sidebar architecture, and user-facing `Commit()` call sites.
+- [x] (2026-07-22 02:41Z) Chose a linear command-history design and recorded the initial scope and decisions in this plan.
+- [x] (2026-07-23 06:17Z) Replaced the two committed/redo stacks with an immutable-entry history list and cursor, added direct history navigation and branch truncation, and preserved one-step undo/redo, discard, and merge-notification behavior.
+- [x] (2026-07-23 06:25Z) Gave the baseline, pending-command boundaries, committed entries, undo/redo destinations, cleared documents, and newly branched states monotonically allocated, collision-free `Head` values.
+- [x] (2026-07-23 06:31Z) Added 12 focused data-layer tests for history creation, undo/redo and direct navigation, branching, clearing, invalid and blocked movement, discard behavior, notification batching, replay failure recovery, and state identity.
+- [x] (2026-07-23 06:39Z) User ran the Milestone 1 focused test command: 21 passed, 0 failed, 0 skipped in 31 ms; no fixes were required.
+- [x] (2026-07-23 06:44Z) Added `Commit(string description, string? detail = null)` to the hosting document API, normalized blank descriptions to `Edit Project`, and forwarded it through every direct `IDataObject` adapter.
+- [x] (2026-07-23 07:49Z) Annotated all current user-facing project commit sites with canonical English action names, added useful script/track/part/property details where available, and distinguished creation from resize plus singular from batch edits where the interaction exposes that information.
+- [x] (2026-07-23 07:49Z) Completed the static review and remaining parameterless-call audit: the 103 baseline project-edit calls are named, the four remaining parameterless calls under `TuneLab`/`TuneLab.GUI` are one compatibility forwarder and three independent settings-document commits, `git diff --check` reports no whitespace errors, and neither frozen ABI assembly nor any `PublicAPI` file is changed.
+- [x] (2026-07-23 08:01Z) User reported that the Milestone 2 solution build, focused history tests, full `TuneLab.Tests`, and legacy compatibility tests all passed, and that the representative edit/undo smoke test showed no obvious abnormal behavior; no follow-up fix was required.
+- [x] (2026-07-23 08:18Z) Implemented and integrated the full-height right-sidebar History page with an always-present baseline, translated action labels plus verbatim details, current-row selection, weaker forward rows, direct navigation, automatic current-row scrolling, branch reconciliation, and project-reset behavior.
+- [x] (2026-07-23 08:32Z) User reran the Milestone 3 build after the namespace fixes and reported that it passed.
+- [x] (2026-07-23 08:36Z) User rebuilt after the row hit-test fix and reported no remaining problem in the History sidebar smoke test; Milestone 3 build and live interaction validation are complete.
+- [x] (2026-07-23 10:29Z) Added or reused all 64 History page, baseline, fallback, and canonical operation translations in every bundled translation file; the 15 non-English files each gained their 37 missing keys, the existing empty English file gained explicit source-language mappings, and a static audit found no missing or duplicate `[Menu]` keys.
+- [x] (2026-07-23 11:13Z) Fixed `IDataValueController` so focus/blur and unchanged slider interactions detect data changes before closing their merge, discard the empty begin/end command pair, restore the saved-state and Undo/Redo status notification, and leave the next Ctrl+Z targeting the preceding real edit; added two focused regression tests.
+- [x] (2026-07-23 11:31Z) Completed array-property history detail forwarding by carrying the detail supplied to `ArrayControllerBase.Bind()` through each `ElementRow` into scalar and nested `ElementWidget` bindings, so edits inside a labeled array retain the available parent-field context just as add/delete operations do.
+- [x] Ask the user to run the required build and test commands manually, wait for the results, and resolve all reported failures.
+- [x] Ask the user to perform the manual acceptance scenarios, wait for feedback, and record the observed result here.
+
+## Surprises & Discoveries
+
+- Observation: The original two-stack implementation already had the requested linear branch semantics. The list-and-cursor implementation can preserve them by truncating forward entries without replaying commands.
+ Evidence: `TuneLab.Hosting.Foundation/Document/DataDocument.cs` still groups pending low-level commands into one `CompositeCommand`, and `Commit()` now removes entries after `HistoryPosition` before appending the new branch entry.
+
+- Observation: Opening or creating a project already establishes the correct lifetime boundary for this feature.
+ Evidence: `TuneLab/Data/ProjectDocument.cs` calls `Clear()` before attaching the newly created or deserialized `Project` in `SetProject()`.
+
+- Observation: The original `Head` was a stack-depth value rather than a unique state identity. A saved state at depth N could be confused with a different state created by undoing to N-1 and committing a new edit.
+ Evidence: The refactored `DataDocument.Head` now returns a stored token from a checked monotonic allocator, and `ProjectDocument.IsSaved` can continue comparing only `mLastSavedHead == Head` without branch-depth collisions.
+
+- Observation: Low-level commands do not contain enough semantic information to produce useful user-facing descriptions. A `ModifyCommand` knows a before and after value but not whether the edit means “Move Notes”, “Change Lyric”, or “Set Voice”.
+ Evidence: `TuneLab.Hosting.Foundation/Document/ICommand.cs` exposes only `Undo()` and `Redo()`, and the concrete commands in `DataProperty.cs`, `DataList.cs`, and related files are deliberately generic.
+
+- Observation: The baseline contained exactly 107 parameterless application `Commit()` call sites. After annotation, only four remain under `TuneLab` and `TuneLab.GUI`; none commit project data.
+ Evidence: `git grep -n -E "\.Commit\(\)" HEAD -- TuneLab TuneLab.GUI` reports 107 matches. `rg -n --glob '*.cs' "\.Commit\s*\(\s*\)" TuneLab TuneLab.GUI` now reports only `ForwardingDataObject.Commit()` plus the isolated extension-settings and Agent provider/settings document commits. The other 103 baseline sites now supply a canonical description directly or through the central script/property binding path.
+
+- Observation: Retained commands already keep deleted objects alive for as long as they remain undoable, so a visible unlimited history does not introduce a new retention model. It does make the existing unbounded memory behavior an explicit product promise for the duration of an open project.
+ Evidence: list removal commands capture the removed object, and committed `CompositeCommand` instances remain in `DataDocument.History` until the project changes or forward history is discarded.
+
+- Observation: Validating a requested discard boundary before undoing is required once Heads become opaque identities, and it also fixes a pre-existing stale-Head failure mode.
+ Evidence: the original `DiscardTo()` loop undid all pending commands when passed an unreachable Head. It now searches the saved before-state boundaries first and returns false without executing a command when the target is absent.
+
+- Observation: The existing merge-notification counter can safely provide an outer, command-free scope for a multi-entry history jump.
+ Evidence: `DataObject.ChangeNotifyFlag()` propagates nested counter changes through the current child tree, while the existing begin/end merge commands remain balanced inside that outer scope. `DataDocument.MoveToHistory()` therefore enters the direct scope only for jumps longer than one entry and closes it before publishing the single `StatusChanged` event.
+
+- Observation: `DataDocument.StatusChanged` also fires for each low-level uncommitted preview command, even though neither the committed history nor its cursor changed.
+ Evidence: `DataDocument.Push()` publishes `StatusChanged`, while `HistorySideBarContentProvider.OnDocumentStatusChanged()` now compares the rendered history count and cursor first and returns without scanning or rebuilding rows when both are unchanged.
+
+- Observation: Importing both `TuneLab.GUI` and `Avalonia.Layout` makes the unqualified `HorizontalAlignment` name ambiguous, and an inherited control property can also hide the `VerticalAlignment` type name inside a control subclass.
+ Evidence: The first user-run Milestone 3 build reported CS0104 and CS0176 in `HistorySideBarContentProvider.cs`; the fix fully qualifies both Avalonia alignment values and imports `Avalonia.Controls.Primitives` for `ScrollBarVisibility`.
+
+- Observation: A History row's visual children must not participate in pointer hit testing when the row container owns the complete press/capture/release gesture.
+ Evidence: User smoke testing found that clicking the blank part of a row navigated correctly while clicking its `TextBlock` did not. Marking the decorative row grid `IsHitTestVisible = false` makes every point in the row target the same `HistoryRow` input handler.
+
+- Observation: The 15 non-English translation files already shared the same 27 reusable canonical `[Menu]` keys and therefore each lacked the same 37 History-specific keys; `en-US.toml` was intentionally empty because untranslated English source text is the runtime fallback.
+ Evidence: The localization audit compared the 64 canonical History keys against each `[Menu]` table before editing, then repeated the comparison after editing and reported `required=64/64, duplicates=0` for all 16 files.
+
+- Observation: A merge boundary is itself represented by commands, so comparing `Head` only after `EndMergeNotify()` cannot distinguish a real value edit from an empty focus/blur cycle.
+ Evidence: `IDataValueController` previously captured its preview boundary after `BeginMergeNotify()`, then called `EndMergeNotify()` before comparing Heads. The end command always allocated another Head, causing an empty begin/end pair to be committed as `Edit Properties`.
+
+- Observation: The array controller already stored the parent-field detail and every scalar or nested widget implementation could consume it, but the row-construction boundary silently dropped it.
+ Evidence: `ArrayControllerBase.ReconcileRows()` created `ElementRow` without `mDetail`, and `ElementRow` called `ElementWidget.Create()` without its optional detail argument. Passing the value across those two calls activates the existing scalar, nested array/list, and extensible-object forwarding paths without changing their commit behavior.
+
+## Decision Log
+
+- Decision: Evolve the existing command log instead of taking a serialized project snapshot after every operation.
+ Rationale: Existing commands already preserve object identity, issue the correct data events, and define the desired undo unit at `Commit()`. Full snapshots would duplicate large projects, force project-object replacement, reset transient editing relationships, and make every edit more expensive.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Represent history as an ordered list plus a cursor, where cursor 0 is the opened-project baseline and cursor N is the state after the first N entries.
+ Rationale: This directly models both undo and redo entries, makes arbitrary navigation simple, and implements branch replacement by removing entries at and after the cursor before appending a new commit.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Keep history session-local and linear. Do not serialize it and do not preserve abandoned branches.
+ Rationale: This is the requested behavior and matches the current clearing of redo commands on a new commit.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Scope entries to committed project-data edits, not every UI interaction.
+ Rationale: TuneLab's undo contract is rooted in `IDataObject`; selection, playhead, scroll, zoom, and playback are intentionally transient. Expanding history to those states would be a different feature and would make Ctrl+Z behavior surprising.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Preserve the parameterless `Commit()` API as a compatibility fallback and add `Commit(string description, string? detail = null)` for named history entries.
+ Rationale: Many generic data components depend on `IDataObject.Commit()`. The overload permits incremental annotation without breaking existing callers. Product-facing project edits must use the named overload by the end of this plan; the fallback description exists for defensive completeness.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Store canonical English description keys in the hosting document layer and translate them only in the TuneLab UI.
+ Rationale: `TuneLab.Hosting.Foundation` must not depend on the application-level translation system. Canonical strings such as `Move Notes` can reuse the existing `[Menu]` translations, while an optional raw detail can identify a script, track, part, parameter, or property where useful.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Populate the existing empty `en-US.toml` with explicit identity mappings for the 64 History-related `[Menu]` keys while leaving unrelated English strings on the normal source-text fallback path.
+ Rationale: The file is a bundled supported-language resource and this milestone requires every bundled translation file to contain the complete History vocabulary. Mapping each key to itself preserves the current English UI exactly while making the coverage audit uniform across all languages.
+ Date/Author: 2026-07-23 / Codex
+
+- Decision: Give each state a monotonically allocated `Head` within its `DataDocument`, including intermediate uncommitted states, and never derive a `Head` from list depth.
+ Rationale: A state token must distinguish two different branches at the same depth. This also preserves the existing use of `Head` and `DiscardTo()` by drag and text-edit operations.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Reserve the default `Head` value zero as an unissued sentinel, allocate real document states starting at one, and use checked integer increment.
+ Rationale: Detached or empty UI helpers sometimes hold `default(Head)`, so keeping it distinct from every real document state prevents accidental matches. Checked overflow fails explicitly instead of silently reusing a prior token; an `int` remains ample for a single application session.
+ Date/Author: 2026-07-23 / Codex
+
+- Decision: Put History in the existing right sidebar as a full-height page.
+ Rationale: The sidebar already has a tab rail, cached pages, and full-height content support. A full-height list needs its own scrolling and selection behavior, like the Agent and Script pages, rather than being wrapped in the property-card `ListView` used by simple providers.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Render History as stable clickable rows inside the existing full-height scrolling infrastructure instead of maintaining a second selectable collection model.
+ Rationale: Row state can be derived directly from `HistoryPosition`, so programmatic refresh never produces a selection-change callback. A click has exactly one navigation path through `MoveToHistory()`, and a rejected jump can restyle the rows from the actual cursor without committing or discarding another control's preview.
+ Date/Author: 2026-07-23 / Codex
+
+- Decision: A multi-step history jump emits `DataDocument.StatusChanged` once and coalesces settled data notifications across the replay when feasible with the existing merge-notification model.
+ Rationale: Updating the title, menus, history list, property panels, and synthesis invalidation once per traversed entry would make long jumps unnecessarily expensive. The original commands still execute in order; only redundant observer refresh is batched.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Do not modify `TuneLab.SDK`, `TuneLab.Foundation`, or either frozen `PublicAPI.Shipped.txt` file.
+ Rationale: The document implementation lives in `TuneLab.Hosting.Foundation`, despite using the `TuneLab.Foundation` namespace. The plugin ABI assemblies are frozen and are unrelated to this host-only UI feature.
+ Date/Author: 2026-07-22 / Codex
+
+- Decision: Generic value bindings decide whether data changed before closing their merge, retain both the pre-merge and post-begin Heads, and remove an empty merge after it has been balanced.
+ Rationale: The post-begin Head remains the correct rollback boundary for live previews, while the pre-merge Head is the only boundary that removes both notification commands. When the document was pushable at edit start, `Discard()` is safe because the balanced pair is the complete pending sequence and it also publishes the final status needed to clear the window's modified marker and restore Undo/Redo availability. If older pending commands existed, targeted `DiscardTo()` preserves them.
+ Date/Author: 2026-07-23 / Codex
+
+## Outcomes & Retrospective
+
+Milestone 1 is implemented and validated. `DataDocument` now retains a linear read-only history, exposes its cursor, supports direct backward and forward navigation, truncates abandoned forward entries on a new commit, and coalesces settled data notifications during multi-entry jumps. Every baseline and edit boundary receives a never-reused Head; undo, redo, discard, clear, and branching restore or allocate the correct token. Twelve new focused tests plus the selected existing merge and linked-list undo tests passed in the user-run validation: 21 passed, 0 failed, 0 skipped. Milestone 2 is also implemented and validated: callers can commit a canonical description and optional detail through any hosting `IDataObject`, parameterless and blank-description commits retain the `Edit Project` fallback, and all 103 baseline project-edit call sites use named history entries. The user reported that the solution build, focused history tests including the two description cases, full application tests, and legacy compatibility tests all passed; representative creation, editing, multi-selection, and undo/redo smoke checks showed no obvious abnormal behavior. Milestone 3 is implemented and validated: the right sidebar has a cached full-height History page driven only by the document history and cursor, with clickable baseline and edit rows, current/forward styling, automatic scrolling, and reset/branch reconciliation. The initial build namespace errors and text-only click defect were fixed, and the user reported that the repeated build and History interaction smoke test showed no remaining problem. The Milestone 4 localization increment is implemented and statically audited across all 16 bundled languages; the final user-run regression commands plus cross-language, branching, script, project-reset, and long-jump acceptance scenarios remain.
+
+The first post-localization review correction is implemented but not yet user-validated. Empty generic property edits no longer create a History row or change the retained saved Head, and the final status notification restores the visible saved/Undo state. Two new focused tests cover both a pure focus/blur cycle and a slider-style same-value change event. Array-property detail propagation remains the next source correction before final validation.
+
+## Context and Orientation
+
+TuneLab stores editable project data in a tree of `DataObject` instances. A leaf mutation creates an `ICommand`, immediately runs its `Redo()` method, and pushes it upward through the parent tree. The root is `DataDocument` in `TuneLab.Hosting.Foundation/Document/DataDocument.cs`. Commands made during one mouse gesture, text commit, property edit, or script execution accumulate in `mUncommitedCommands`; calling `Commit()` wraps them in a `CompositeCommand`. In this plan, a “history entry” means one such committed composite, not every internal property assignment made during the gesture.
+
+`TuneLab.Hosting.Foundation/Document/DataObject.cs` implements the common delegation methods. `TuneLab.Hosting.Foundation/Document/IDataObject.cs` defines the interface used by application data types such as `IProject`, `ITrack`, `IPart`, and `INote`. Several adapters implement the interface by forwarding to another data object: `IDataObject.Wrapper` in the same interface file, `MultipleDataProperty`, `MultipleDataPropertyObject`, and `MultipleDataPropertyArray` under `TuneLab.Hosting.Foundation/Property/`, plus `ForwardingDataObject` near the bottom of `TuneLab.GUI/GUI/Controllers/ArrayController.cs`. Any new `Commit` overload must be forwarded by all of them or the solution will not compile.
+
+`Head`, defined in `TuneLab.Hosting.Foundation/Document/Head.cs`, is an opaque state token. Interactive operations capture a `Head`, repeatedly call `DiscardTo(capturedHead)` to undo only their uncommitted preview, recalculate the preview, and finally call `Commit()`. `ProjectDocument`, in `TuneLab/Data/ProjectDocument.cs`, also captures a `Head` when saving and uses equality to decide whether the window title should show the project as modified. The implementation must therefore preserve both uses while ensuring distinct branches never receive equal tokens.
+
+`Editor`, in `TuneLab/UI/MainWindow/Editor/Editor.cs`, owns one `ProjectDocument` for its lifetime. It wires `StatusChanged` to the enabled state of the Undo and Redo menu items, exposes the current Ctrl+Z/Ctrl+Y actions, owns the right `SideBar`, and switches sidebar content according to `SideBarTab`. `SideTabBar.cs` creates the visible tab buttons, `SideBarTab.cs` defines their identities, `SideBar.cs` caches their content, and `TuneLab.GUI/GUI/Assets.cs` contains inline SVG icons. The new History page should follow this architecture rather than introduce another window.
+
+Translations live in `TuneLab/Resources/Translations/*.toml`. English source text is the fallback language. Most edit verbs already exist under `[Menu]`, so history descriptions should use `description.Tr(TC.Menu)` and should reuse those keys. Add missing operation names to the `[Menu]` section of all 16 translation files, along with `History`, `Opened Project`, and `Edit Project`. A detail is user or plugin data and must be displayed verbatim after the translated action name; do not attempt to use it as a translation key.
+
+The two plugin ABI assemblies, `TuneLab.SDK` and `TuneLab.Foundation`, are guarded by public API analyzers. This feature must not touch them. `TuneLab.Hosting.Foundation` is a host-internal assembly in architectural terms and is the correct place to evolve `DataDocument`, `DataObject`, and `IDataObject`.
+
+The repository root `AGENTS.md` requires the user, not the coding agent, to perform builds and tests. An agent executing this plan must make the source changes, provide the exact commands below to the user, wait for the returned results, and then fix any reported failures. It must not run `dotnet build` or `dotnet test` itself.
+
+## Plan of Work
+
+### Milestone 1: Make document history addressable and state identities collision-free
+
+At the end of this milestone, `DataDocument` will still support ordinary undo and redo, but it will also expose the complete linear history and a cursor, move to any cursor position, discard forward entries on a branch, and never confuse two states merely because they have the same depth. Automated data-layer tests will demonstrate these behaviors without depending on the Avalonia UI.
+
+Create `TuneLab.Hosting.Foundation/Document/HistoryEntry.cs`. Define a public sealed `HistoryEntry` whose public surface is immutable and contains `Head State`, `string Description`, and `string? Detail`. Keep the replay command and the head before the edit internal so consumers cannot execute or mutate commands. Its internal constructor should receive the before head, after head, description, detail, and `ICommand`.
+
+Refactor `TuneLab.Hosting.Foundation/Document/DataDocument.cs` from committed and redo stacks to `List mHistory` plus `int mHistoryPosition`. Position 0 represents the baseline; position `mHistory.Count` represents the newest retained state. Expose a read-only view as `IReadOnlyList History` and expose `int HistoryPosition`. `Undoable()` becomes true when there are no uncommitted commands and the position is greater than zero. `Redoable()` becomes true when there are no uncommitted commands and the position is less than the history count.
+
+Add `public bool MoveToHistory(int position)`. Reject positions outside the inclusive range `0..History.Count`, and reject movement while uncommitted commands exist. Validate these conditions before changing data so an invalid request cannot partially modify the project. Moving backward must undo entries from `HistoryPosition - 1` down to the target. Moving forward must redo entries starting at `HistoryPosition` up to the target. Change the cursor only after each individual command succeeds. Emit `StatusChanged` once after the complete jump, including in a `finally` path if a later command throws after earlier entries succeeded, so observers see the valid cursor actually reached. Re-throw command exceptions rather than hiding data-layer failures.
+
+Implement `Undo()` and `Redo()` through single-step internal helpers shared with `MoveToHistory()` so there is one source of truth. Preserve the current public return behavior: no available step returns false, and a successful step returns true. A normal one-step call still emits one `StatusChanged` event.
+
+Replace count-derived heads with monotonic state tokens. Keep the public shape of `Head` unless implementation proves a wider counter is necessary; an `int` counter is adequate for one application session. Store each uncommitted command together with the head immediately before and immediately after it. `Push()` allocates a new head after the command has been applied. `DiscardTo(head)` first verifies that the target is the current head or one of the before-head boundaries in the uncommitted sequence, then undoes back to it while restoring the saved before heads. It must return false and change nothing for an unreachable head. A committed `HistoryEntry` records the head before the first pending command and the current head after the last pending command. Undo restores the former and redo restores the latter. `Clear()` removes pending and committed history and assigns a fresh baseline head; it must not reset the monotonic allocator in a way that can collide with a head previously issued by that document.
+
+When committing while `HistoryPosition < History.Count`, remove the entries from `HistoryPosition` to the end before appending the new entry. Removing the entries is sufficient; no command execution occurs because those forward commands are already undone. This is the required “do not preserve overwritten old records” behavior.
+
+For multi-step replay notification batching, add the smallest host-internal mechanism to `DataObject.cs` that enters and exits the existing notification merge state without pushing `BeginMergeNotifyCommand` or `EndMergeNotifyCommand` into the history. It may be a protected disposable scope used only by `DataDocument.MoveToHistory()`. The original entry commands, including their own balanced begin/end merge commands, must still execute normally inside the outer scope. Use the scope only when traversing more than one entry. Add a test that subscribes to settled `Modified` notifications and proves a multi-entry jump reports the final state without a settled notification for every intermediate entry. If implementation research demonstrates that a direct outer scope violates existing merge invariants, record the evidence in `Surprises & Discoveries`, omit data-notification batching, and retain the mandatory single `StatusChanged` emission; correctness takes priority over this optimization.
+
+Add `tests/TuneLab.Tests/DataDocumentHistoryTests.cs`. Cover at least: commits append entries and advance the cursor; undo and redo move the cursor and data; moving directly backward and forward produces the right value; committing after undo removes forward entries; clear produces an empty history at position zero; invalid positions do nothing; movement with uncommitted commands does nothing; `DiscardTo()` still supports interactive preview; a branch at an old depth receives a different head from the abandoned state; and status/modified notification counts match the chosen batching behavior. Use small `DataStruct` or `DataList` objects attached to a `DataDocument` so the tests isolate the document mechanism.
+
+After the edits, the executing agent must ask the user to run the focused test command listed in `Concrete Steps` and wait. The milestone is accepted when the user reports that the new history tests and existing document/merge tests pass.
+
+### Milestone 2: Carry semantic descriptions from editing actions into history
+
+At the end of this milestone, every user-facing project edit will create a useful history label rather than an anonymous numbered row. Existing callers that do not yet provide a name will continue to work with a translated fallback.
+
+In `IDataObject.cs`, add `bool Commit(string description, string? detail = null)` alongside the existing `bool Commit()`. Add the matching virtual overload to `DataObject.cs`, delegating to its parent. Implement and forward it in `IDataObject.Wrapper`, `MultipleDataProperty`, `MultipleDataPropertyObject`, `MultipleDataPropertyArray`, and `ForwardingDataObject`. Search the whole solution for every direct `IDataObject` implementer before considering this complete. `DataDocument.Commit()` should delegate to the named overload using canonical fallback key `Edit Project`; the named overload creates the `HistoryEntry`. Normalize blank descriptions to the same fallback so the sidebar never renders an empty action.
+
+Audit the current project-facing `Commit()` calls with:
+
+ rg -n "\.Commit\(\)" TuneLab TuneLab.GUI -g '*.cs'
+
+Change each user edit to the named overload at the point where its meaning is clearest. Do not name low-level property mutations individually because several mutations may belong to one commit. Reuse a manageable vocabulary rather than creating a unique sentence for every call site. The vocabulary should cover at least tracks, parts, notes, pitch or automation, vibrato, tempo and time signatures, effects and properties, and scripts. Representative canonical keys are `Add Track`, `Delete Track`, `Move Track`, `Rename Track`, `Set Track Color`, `Add Part`, `Delete Part`, `Move Part`, `Resize Part`, `Split`, `Merge`, `Import Audio`, `Import Track`, `Set Voice`, `Set Instrument`, `Add Note`, `Delete Notes`, `Move Notes`, `Resize Notes`, `Change Lyric`, `Transpose Notes`, `Draw Pitch`, `Erase Pitch`, `Edit Automation`, `Add Vibrato`, `Edit Vibrato`, `Add Tempo`, `Edit Tempo`, `Delete Tempo`, `Add Time Signature`, `Edit Time Signature`, `Delete Time Signature`, `Edit Properties`, `Edit Effects`, and `Run Script`.
+
+Use `detail` only when it adds stable context without making replay depend on live objects. Suitable details include the track or part name captured at commit time, a property display label, or a script name. Do not store references to UI controls or call translation functions in the document layer. Do not encode volatile data such as the current selection into a history entry.
+
+Generic bindings in `TuneLab.GUI/GUI/Controllers/IDataValueController.cs`, `ExtensibleObjectController.cs`, and `ArrayController.cs` may use coarse labels such as `Edit Properties`, `Add Property`, `Delete Property`, `Add List Item`, and `Delete List Item`. Where their construction sites already possess a localized field label, extend the binding/controller constructor with an optional raw detail and pass it through; do not redesign the entire property configuration API solely to improve a label.
+
+Change `TuneLab/Scripting/ScriptContext.cs` so a successful script edit commits as `Run Script`. If a stable script name is available from a script-tool invocation, thread it through as the detail; interactive pasted code and agent-generated code may omit detail. Preserve the existing promise that an entire script run is one undoable history entry and that failed scripts roll back without adding an entry.
+
+Repeat the `rg` audit after annotation. Parameterless calls may remain in compatibility forwarders, isolated settings documents, tests specifically exercising fallback behavior, or truly non-project data, but every remaining match must be reviewed and explained in `Artifacts and Notes`. Add test assertions that named and fallback commits expose the expected immutable descriptions.
+
+Ask the user to run the focused tests again and wait for results. This milestone is accepted when named entries survive undo/redo unchanged, branching removes the correct described entries, script edits produce one entry, and no existing edit loses undoability.
+
+### Milestone 3: Add the History sidebar page
+
+At the end of this milestone, a user can inspect and navigate the linear history without invoking Ctrl+Z repeatedly.
+
+Create `TuneLab/UI/MainWindow/Editor/SideBar/History/HistorySideBarContentProvider.cs`. It should own a full-height Avalonia control containing a single scrolling history list. The first visual row is the opened-project baseline and targets position 0. Each `HistoryEntry` produces one row targeting its one-based position. The row at `ProjectDocument.HistoryPosition` is the selected/current row. Rows after the cursor remain visible as redoable forward history but use a weaker foreground or opacity. Rows before and at the cursor use normal foreground. The view must scroll the current row into view after ordinary undo, redo, a direct jump, or a new commit when the History page is visible.
+
+The provider receives the editor's existing `ProjectDocument`, subscribes to `StatusChanged`, and rebuilds or incrementally reconciles the list on change. Correctness and stable selection come first; with session-scale lists, a simple rebuild is acceptable initially. Guard against selection feedback: programmatic selection during refresh must not call `MoveToHistory()` again. A user click calls `MoveToHistory(targetPosition)`. If movement returns false because an edit has uncommitted preview commands, restore the visual selection to the actual cursor. Do not automatically commit or discard another control's in-progress edit.
+
+Render the label with `entry.Description.Tr(TC.Menu)`. If `Detail` is non-empty, append `: ` plus the verbatim detail. Render the baseline using translated `Opened Project`. The page title is translated `History`. The fallback `Edit Project` must also be translated. Do not display raw command type names.
+
+Add `History` to `TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs`. Add an inline 24-by-24 history SVG to `TuneLab.GUI/GUI/Assets.cs`; use a clock or counter-clockwise arrow with short list marks, visually consistent with the existing monochrome sidebar icons. Add the tab in `SideTabBar.cs`. In `Editor.cs`, construct the provider, add the `SideBarTab.History` switch case, and call `SetFullContent` with the provider's root. The existing `mDocument.StatusChanged` subscription that enables Undo/Redo must remain intact.
+
+Do not add a second history model in the UI. `DataDocument.History` and `HistoryPosition` are the sole source of truth, so Ctrl+Z, Ctrl+Y, menu actions, script edits, property edits, and row clicks always remain synchronized.
+
+Ask the user to perform a manual build and launch using the commands in `Concrete Steps`, then wait for feedback. This milestone is accepted when the tab opens, rows appear after commits, Ctrl+Z/Ctrl+Y move the highlight, clicking rows changes project data, and switching projects resets the list to the baseline.
+
+### Milestone 4: Complete localization, regression validation, and long-jump behavior
+
+At the end of this milestone, the feature will be ready for normal use across supported languages and across TuneLab's main edit surfaces.
+
+Update every file under `TuneLab/Resources/Translations/*.toml`. Add missing keys to `[Menu]`, reusing existing translations when a matching menu action already exists. All files must contain translations for `History`, `Opened Project`, `Edit Project`, and every new canonical action key introduced by Milestone 2. Keep TOML syntax valid and do not create duplicate keys in the same table. English source text remains the fallback and therefore requires no separate `en-US.toml` if the repository does not contain one.
+
+Before final validation, close the two generic-property review gaps. In `TuneLab.GUI/GUI/Controllers/IDataValueController.cs`, compare the current Head with the post-begin preview Head before calling `EndMergeNotify()`. If no value command remains, balance the merge and remove the empty boundary commands back to the pre-merge Head without disturbing any older pending edit; when the document was initially pushable, use the normal discard path so observers receive the restored saved and Undo/Redo status. In `TuneLab.GUI/GUI/Controllers/ArrayController.cs`, carry the optional detail supplied to `ArrayControllerBase.Bind()` through `ElementRow` and every scalar or nested `ElementWidget` binding. Add focused coverage for the no-op value-binding behavior.
+
+Review long jumps for responsiveness with a project containing at least 100 lightweight history entries. A direct move from newest to baseline and back should complete synchronously without the history list or title refreshing once per entry. Data commands must still execute in exact order. Do not add background replay: the project data tree is owned by the UI/data thread, and moving command execution to a worker would create races with rendering and synthesis.
+
+Ask the user to run the full solution build and both repository test projects manually, then wait for the results. An SDK surface is not changed, so sample plugins under `tests/plugins/*` do not need rebuilding. Resolve all compiler errors, test failures, and manual acceptance defects before marking the plan complete.
+
+## Concrete Steps
+
+All paths and commands in this section assume the working directory is the repository root:
+
+ D:\Code\TuneLab
+
+The coding agent may use read-only searches and edit files, but, as required by `AGENTS.md`, it must not execute build or test commands. At each validation point it must present the relevant command to the user, ask the user to run it manually, wait for the reported output, and record the result in `Progress` and `Artifacts and Notes`.
+
+Before editing, inspect the current state and locate all document adapters and commits:
+
+ git status --short
+ rg -n "class DataDocument|interface IDataObject|class .*: .*IDataObject|bool Commit\(" TuneLab.Hosting.Foundation TuneLab.GUI TuneLab -g '*.cs'
+ rg -n "\.Commit\(\)" TuneLab TuneLab.GUI -g '*.cs'
+
+After Milestone 1, ask the user to run:
+
+ dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "FullyQualifiedName~DataDocumentHistoryTests|FullyQualifiedName~DataObjectMergeNotifyTests|FullyQualifiedName~SortedDataLinkedListUndoTests"
+
+Expect a successful test summary with zero failed tests. Record the actual total because the number will depend on the final test methods; do not hard-code a guessed count in this plan.
+
+After Milestone 2, inspect remaining anonymous commits and record why each is allowed:
+
+ rg -n "\.Commit\(\)" TuneLab TuneLab.GUI -g '*.cs'
+
+Because this milestone changes many UI call sites and generic property bindings, ask the user to run these commands manually from the repository root, one at a time:
+
+ dotnet build TuneLab.sln -c Debug
+ dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "FullyQualifiedName~DataDocumentHistoryTests|FullyQualifiedName~DataObjectMergeNotifyTests|FullyQualifiedName~SortedDataLinkedListUndoTests"
+ dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj
+ dotnet test legacy/compat/TuneLab.Hosting.Compat.Legacy.Tests/TuneLab.Hosting.Compat.Legacy.Tests.csproj
+
+Expect the focused command in the current tree to report 25 passing tests: the previously validated 21, the two named-description tests, and the two no-op value-binding regressions. All commands must report zero failures. Then launch the already-built application by the user's normal method or with `dotnet run --project TuneLab/TuneLab.csproj -c Debug --no-build` and perform the Milestone 2 smoke scenarios below. If the scripting code receives dedicated automated tests during implementation, include their class name in the focused filter.
+
+After Milestone 3, ask the user to build manually:
+
+ dotnet build TuneLab.sln -c Debug
+
+Expect `Build succeeded.` with zero errors. Warnings that existed before the change may be noted, but new warnings introduced by this feature must be fixed. After a successful user build, ask the user to launch the already-built application by their normal method or, from the repository root, with:
+
+ dotnet run --project TuneLab/TuneLab.csproj -c Debug --no-build
+
+After Milestone 4, ask the user to run all required validation commands manually, one at a time, and wait for each result:
+
+ dotnet build TuneLab.sln -c Debug
+ dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj
+ dotnet test legacy/compat/TuneLab.Hosting.Compat.Legacy.Tests/TuneLab.Hosting.Compat.Legacy.Tests.csproj
+
+The last test project guards legacy compatibility even though this feature should not edit the frozen legacy source. No sample plugin rebuild, pack, or install cycle is required because this plan does not change `TuneLab.SDK` or `TuneLab.Foundation`.
+
+Useful read-only checks during implementation are:
+
+ git diff --check
+ git diff --stat
+ git status --short
+
+Do not discard unrelated user changes in a dirty worktree. Review diffs file by file and limit edits to this feature.
+
+## Validation and Acceptance
+
+Automated acceptance requires all new `DataDocumentHistoryTests` to pass, all existing `TuneLab.Tests` to pass, the legacy compatibility tests to pass, and the solution to build with no new warnings. The new tests must prove behavior, not merely inspect private fields.
+
+For the generic binding regression, focus and blur an unchanged text property, then press and release a slider without moving it or changing its quantized value. Neither interaction may add a History row, add or remove the window's modified marker, or consume the next Ctrl+Z; that Ctrl+Z must still undo the preceding real edit.
+
+Before the History sidebar exists, Milestone 2 smoke acceptance verifies that naming changes did not alter undo units. In a disposable project, create a note by dragging its end, create a part by dragging its end, rename and move a track or part, change a property and tempo, edit vibrato or automation, and run an editing script if one is available. After each representative edit, one Ctrl+Z must revert exactly that edit and one Ctrl+Y must restore it. Creating a new note or part must still undo the entire creation in one step, not leave a zero-length object behind. Also try a multi-selection move or delete so the new singular/plural description branching executes without changing the edit result. Report any exception, disabled undo/redo state, unexpected extra undo step, or data that does not round-trip.
+
+Manual acceptance starts with a newly opened or newly created project. Open the History sidebar and verify that it contains only `Opened Project`. Add a note, move it, change its lyric, and draw a pitch or automation edit. Verify that four appropriately named rows appear in the same order and that the newest row is selected.
+
+Click the state after adding the note. The note must return to its original position, lyric, and parameters, later rows must remain visible but visually weaker, and the clicked state must be selected. Click the newest row. The move, lyric, and parameter edit must return exactly, proving forward replay.
+
+Click an earlier row again and then perform a different new edit. The previously visible forward rows must be removed immediately and replaced by the new entry. Ctrl+Y must now do nothing. This proves that abandoned branches are not retained.
+
+Use Ctrl+Z and Ctrl+Y and the Edit menu while History is open. Each action must change project data and move the sidebar selection by exactly one row. The menu enabled states must match whether the cursor has a previous or next entry.
+
+Save a project at depth N, undo once, and make a different edit so the new branch is also at depth N. The window must still show the project as modified. Save again and verify the modified indicator clears. This specifically accepts the collision-free `Head` change.
+
+Run an interactive editing script that changes several objects. It must add exactly one `Run Script` row. Run a script that throws after making a partial edit. The project must return to its pre-script state and no history row may be added.
+
+Start a drag or text edit that has created uncommitted preview commands and attempt to activate History before the edit commits. The history jump must not absorb, commit, or discard the preview. Depending on normal focus behavior the editor may first finish its own interaction; in either case there must never be a history jump while `Pushable()` is false.
+
+Create at least 100 small entries, then jump from the newest state to `Opened Project` and back. The application must remain responsive after each synchronous jump, settle on the correct state, and not start synthesis or rebuild the History page once per intermediate entry. Record an approximate observed duration and hardware context in `Artifacts and Notes`; no strict millisecond threshold is required, but a multi-second freeze for 100 lightweight entries is a defect.
+
+Open or create another project. The prior project's rows must disappear and the new project must begin with only `Opened Project`. Close and reopen a saved project and verify that history is not persisted across sessions.
+
+Switch to at least Chinese and one non-Chinese bundled translation during manual verification. The page title, baseline, fallback, and operation verbs must be translated, while user-provided names in details remain unchanged.
+
+## Idempotence and Recovery
+
+The implementation changes only source code and in-memory behavior; it introduces no file-format migration, database migration, cache conversion, or destructive repository operation. Repeating searches, builds, tests, and manual scenarios is safe. Opening a project always starts a fresh history and must not alter the project file until the user explicitly saves.
+
+`MoveToHistory()` must validate its target and the absence of uncommitted commands before executing anything. If an individual command unexpectedly throws during a multi-step move, update `HistoryPosition` only after each successful command, restore `Head` together with that successful step, emit `StatusChanged` in `finally`, and rethrow. The document will then expose the last valid state it actually reached instead of claiming the original target or an impossible cursor. Record any such failure and its reproducer in `Surprises & Discoveries` before fixing the command that threw.
+
+If notification batching proves unsafe, remove only the direct batching scope and keep ordered replay plus one `StatusChanged`; do not fall back to project snapshots. If the sidebar is broken while the core tests pass, temporarily omit its tab registration while repairing the provider rather than reverting the tested history model. Do not use `git reset --hard` or overwrite unrelated working-tree changes.
+
+Because forward entries contain commands for objects that may currently be detached, removing abandoned entries should simply drop their references. Do not call `Undo()` or `Redo()` while truncating already-undone forward history. Garbage collection will reclaim objects no longer referenced elsewhere.
+
+## Artifacts and Notes
+
+The initial source inventory is:
+
+ DataDocument storage: TuneLab.Hosting.Foundation/Document/DataDocument.cs
+ State token: TuneLab.Hosting.Foundation/Document/Head.cs
+ Command interface: TuneLab.Hosting.Foundation/Document/ICommand.cs
+ Composite undo unit: TuneLab.Hosting.Foundation/Document/CompositeCommand.cs
+ Delegation API: TuneLab.Hosting.Foundation/Document/DataObject.cs
+ TuneLab.Hosting.Foundation/Document/IDataObject.cs
+ Project lifecycle: TuneLab/Data/ProjectDocument.cs
+ Editor integration: TuneLab/UI/MainWindow/Editor/Editor.cs
+ Sidebar integration: TuneLab/UI/MainWindow/Editor/SideBar/SideBar.cs
+ TuneLab/UI/MainWindow/Editor/SideBar/SideTabBar.cs
+ TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs
+ Icons: TuneLab.GUI/GUI/Assets.cs
+ Translations: TuneLab/Resources/Translations/*.toml
+
+The intended history transition is:
+
+ Baseline -> Add Note -> Move Notes -> Change Lyric
+ ^ cursor after two undo operations
+
+After a new `Draw Pitch` commit from that cursor, the retained line is:
+
+ Baseline -> Add Note -> Draw Pitch
+
+`Move Notes` and `Change Lyric` are dropped because their commands were forward history when the new edit committed.
+
+The first implementation increment added:
+
+ TuneLab.Hosting.Foundation/Document/HistoryEntry.cs
+ TuneLab.Hosting.Foundation/Document/DataDocument.cs: list/cursor storage, direct navigation, and branch truncation
+ TuneLab.Hosting.Foundation/Document/DataObject.cs: protected command-free merge-notification scope
+
+The second implementation increment replaced depth-derived Heads with per-document monotonic allocation. Each pending command stores its before and after state, each committed entry keeps the first before-state and final after-state, undo/redo restore those saved values, and `Clear()` allocates a fresh baseline without resetting the allocator.
+
+`tests/TuneLab.Tests/DataDocumentHistoryTests.cs` now contains 16 focused tests. The original 12 cover fallback entry creation, cursor and Head movement through undo/redo, direct jumps, forward-history truncation, fresh baselines after clear, invalid and pending-command rejection, full and targeted discard, same-depth branch identity, settled-notification batching, and the cursor/status result when a later command in a multi-step jump throws. Two later tests verify that named descriptions and details survive undo/redo unchanged and that blank descriptions normalize to `Edit Project`. The newest two drive a fake `IDataValueController` through a focus/blur cycle and a slider-style same-value `ValueChanged` event; both assert that the existing immutable history entry, cursor, saved Head, observer-visible saved marker, and next real undo step are preserved. These four later tests have not yet been included together in a user-run focused command.
+
+The named-commit forwarding audit found and updated every direct hosting implementation:
+
+ TuneLab.Hosting.Foundation/Document/DataObject.cs
+ TuneLab.Hosting.Foundation/Document/IDataObject.cs: IDataObject.Wrapper
+ TuneLab.Hosting.Foundation/Property/MultipleDataProperty.cs
+ TuneLab.Hosting.Foundation/Property/MultipleDataPropertyObject.cs
+ TuneLab.Hosting.Foundation/Property/MultipleDataPropertyArray.cs
+ TuneLab.GUI/GUI/Controllers/ArrayController.cs: ForwardingDataObject
+
+All other current `IDataObject` classes inherit `DataObject` or one of these wrappers. `TuneLab.Hosting.Foundation/Utils/SaveFile.cs` also has a method named `Commit()`, but it is unrelated to `IDataObject` and therefore intentionally has no history-description overload. Frozen legacy SDK sources were not changed.
+
+The final parameterless application-call audit is:
+
+ TuneLab.GUI/GUI/Controllers/ArrayController.cs
+ ForwardingDataObject.Commit(): retained compatibility forwarding overload; not a business commit site.
+ TuneLab/UI/Settings/SettingsWindow.axaml.cs
+ Initial snapshot for one isolated extension-settings DataDocument; never reaches the project document.
+ TuneLab/UI/MainWindow/Editor/SideBar/Agent/AgentSideBarContentProvider.cs
+ Initial provider selection and loaded provider settings in two isolated DataDocuments; neither reaches project history.
+
+Repository-wide remaining matches are declarations/forwarders that preserve the fallback API, tests that intentionally exercise it, frozen legacy sources, or unrelated `SaveFile` and synthesis `IAudioSegment` APIs. Static review also ran `git diff --check` successfully; its only output was the repository's existing LF-to-CRLF conversion warning. `git diff --name-only -- TuneLab.SDK TuneLab.Foundation` and the cached equivalent were empty, and no `PublicAPI` file is modified. Per `AGENTS.md`, no build or test command was executed by the coding agent.
+
+The user-run Milestone 1 validation result was:
+
+ Command: dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "FullyQualifiedName~DataDocumentHistoryTests|FullyQualifiedName~DataObjectMergeNotifyTests|FullyQualifiedName~SortedDataLinkedListUndoTests"
+ Result: Passed 21, failed 0, skipped 0, total 21
+ Duration: 31 ms (VSTest 17.11.1 x64, .NET 8.0)
+
+The user-run Milestone 2 validation result was:
+
+ Commands: dotnet build TuneLab.sln -c Debug
+ focused DataDocumentHistory/DataObjectMergeNotify/SortedDataLinkedListUndo tests
+ full tests/TuneLab.Tests/TuneLab.Tests.csproj
+ legacy compatibility test project
+ Result: User reported that every command passed; no failure transcript or follow-up fix was needed.
+ Smoke: Representative user editing and undo/redo checks showed no obvious abnormal behavior.
+
+The Milestone 3 source increment added:
+
+ TuneLab/UI/MainWindow/Editor/SideBar/History/HistorySideBarContentProvider.cs
+ Full-height scrolling rows, baseline/current/forward rendering, direct navigation,
+ current-row scrolling, and no-op filtering for uncommitted preview notifications.
+ TuneLab/UI/MainWindow/Editor/SideBar/SideBarTab.cs
+ TuneLab/UI/MainWindow/Editor/SideBar/SideTabBar.cs
+ TuneLab/UI/MainWindow/Editor/Editor.cs
+ History tab registration, provider lifetime, and cached full-page hosting.
+ TuneLab.GUI/GUI/Assets.cs
+ Monochrome 24-by-24 history icon.
+
+Static review after this increment found no whitespace errors, no changes in `TuneLab.SDK` or `TuneLab.Foundation`, and no modified `PublicAPI` file. Per `AGENTS.md`, the coding agent did not build or launch the application; Milestone 3 remains pending user validation.
+
+The first user-run Milestone 3 build found four compile errors in `HistorySideBarContentProvider.cs`: two missing `ScrollBarVisibility` references, one ambiguous `HorizontalAlignment`, and one `VerticalAlignment.Center` lookup hidden by the inherited property. The source now imports `Avalonia.Controls.Primitives` and fully qualifies the Avalonia alignment values. The user repeated the build and reported success. During the subsequent live smoke test, clicking blank row space navigated but clicking directly on text did not; the decorative content grid now ignores hit testing so the containing row receives the entire click gesture. The user rebuilt and reported no remaining problem in the repeated History sidebar smoke test.
+
+The localization increment covers 64 canonical History keys in every file under `TuneLab/Resources/Translations/*.toml`. The 15 non-English files reused 27 existing `[Menu]` entries and each added the same 37 missing entries; the previously empty `en-US.toml` now contains 64 identity mappings. A read-only key audit reported `required=64/64, duplicates=0` for every file. `git diff --numstat` showed only those intended additions, and `git diff --check` reported no whitespace errors; its output was limited to the repository's existing LF-to-CRLF conversion warnings. Per `AGENTS.md`, no build or test command was executed by the coding agent.
+
+The no-op generic-binding correction changed only `TuneLab.GUI/GUI/Controllers/IDataValueController.cs` and the focused history test file. Static review confirmed that the binding records both merge boundaries, checks for value commands before closing, balances and discards an empty pair, and uses `Discard()` only when `Pushable()` proved there were no older pending commands. `git diff --check` reported no whitespace errors beyond the existing LF-to-CRLF warnings. Per `AGENTS.md`, the coding agent did not execute the new tests.
+
+The array-detail correction completed the already-prepared binding path in `TuneLab.GUI/GUI/Controllers/ArrayController.cs`: `ReconcileRows()` now supplies the controller's stored parent detail to `ElementRow`, and the row supplies it to `ElementWidget.Create()`. Scalar elements therefore use that detail in `BindDataProperty`, while nested array, list, and extensible-object elements pass it to their child controllers. Object elements continue to use their own labeled child fields as the more specific detail. Per `AGENTS.md`, the coding agent did not build or test this correction.
+
+Add concise test transcripts, the final reviewed list of remaining parameterless `Commit()` calls, user-reported build/test summaries, and the long-jump observation here as implementation proceeds. Do not paste full build logs.
+
+## Interfaces and Dependencies
+
+At the end of Milestone 1, `TuneLab.Hosting.Foundation/Document/HistoryEntry.cs` must provide an immutable public view equivalent to:
+
+ public sealed class HistoryEntry
+ {
+ public Head State { get; }
+ public string Description { get; }
+ public string? Detail { get; }
+
+ internal Head BeforeState { get; }
+ internal ICommand Command { get; }
+ }
+
+Exact private field names may differ. The command and before-state members must not be public.
+
+`DataDocument` must provide:
+
+ public IReadOnlyList History { get; }
+ public int HistoryPosition { get; }
+ public bool MoveToHistory(int position);
+ public bool Undoable();
+ public bool Redoable();
+
+At the end of Milestone 2, `IDataObject` and `DataObject` must additionally provide:
+
+ bool Commit(string description, string? detail = null);
+
+The existing parameterless signature remains:
+
+ bool Commit();
+
+`DataDocument.Commit()` uses `Edit Project` when no description is supplied. A named commit stores the canonical English key and optional detail without translating either in `TuneLab.Hosting.Foundation`.
+
+At the end of Milestone 3, `HistorySideBarContentProvider` must expose the icon, translated page name, and root `Control` needed by `Editor` to call `SideBar.SetFullContent`. It must read history only through `ProjectDocument.History`, `ProjectDocument.HistoryPosition`, and `ProjectDocument.StatusChanged`, and navigate only through `ProjectDocument.MoveToHistory()`.
+
+Use only the .NET and Avalonia dependencies already referenced by the solution. Do not add a package. Do not change the `.tlp` serialization schema. Do not edit the frozen plugin ABI or `PublicAPI.Shipped.txt` files.
+
+Revision note (2026-07-22, Codex): Created the initial ExecPlan after static analysis of the current command stack, commit sites, project lifecycle, sidebar architecture, and repository build constraints. The plan chooses a linear cursor over the existing commands, includes the discovered `Head` collision fix, and separates core, description, UI, and validation milestones so each can be verified independently.
+
+Revision note (2026-07-23, Codex): Completed the first implementation Progress by replacing the committed/redo stacks with a linear history and cursor, adding direct navigation, branch truncation, and command-free notification batching. Updated the living sections to record the implemented source state and to make explicit that collision-free `Head` allocation and automated validation remain separate next steps.
+
+Revision note (2026-07-23, Codex): Completed the collision-free state-identity Progress by recording before/after Heads around pending commands, restoring entry Heads during replay, allocating fresh baseline and branch tokens, and validating `DiscardTo()` targets before mutation. Updated stale pre-refactor observations and recorded the reserved-zero and checked-allocation decision.
+
+Revision note (2026-07-23, Codex): Added the Milestone 1 focused test suite with 12 scenarios spanning normal history behavior, notification batching, state identity, invalid operations, interactive discard, and replay failure recovery. Added an explicit user-run validation Progress because repository policy forbids the coding agent from executing the test command.
+
+Revision note (2026-07-23, Codex): Recorded the user's successful Milestone 1 focused validation (21 passed, 0 failed, 0 skipped in 31 ms) and updated the retrospective to mark the addressable-history and collision-free-state milestone as validated.
+
+Revision note (2026-07-23, Codex): Completed the first Milestone 2 Progress by adding the named `Commit` overload, preserving parameterless and blank-description fallback behavior, forwarding the overload through all five direct adapter locations, and adding named/fallback history assertions. Recorded the adapter audit and left business call-site annotation as the next Progress.
+
+Revision note (2026-07-23, Codex): Completed and statically reviewed the Milestone 2 business call-site annotation. Recorded the exact 107-to-4 parameterless-call audit, classified every remaining application call, tightened misleading or singular batch action names, confirmed the frozen ABI and PublicAPI files are untouched, and left user-run build/tests and smoke validation as the only Milestone 2 work still pending.
+
+Revision note (2026-07-23, Codex): Recorded the user's successful Milestone 2 validation: solution build, focused and full application tests, legacy compatibility tests, and representative edit/undo smoke checks all passed with no obvious abnormal behavior. Marked the named history-entry milestone complete and left the sidebar, localization, and final end-to-end history acceptance work pending.
+
+Revision note (2026-07-23, Codex): Completed the Milestone 3 source Progress by adding the cached full-height History provider, sidebar tab and icon, document-driven row reconciliation, direct navigation, current/forward styling, reset behavior, and current-row scrolling. Recorded the preview-notification filtering discovery and left the required user-run build and live sidebar smoke test as the next Progress.
+
+Revision note (2026-07-23, Codex): Recorded and fixed the first Milestone 3 user-build failure by importing the Avalonia primitives namespace and fully qualifying alignment enum values that conflicted with TuneLab GUI names or inherited control properties. Left validation open pending the user's repeated build and launch result.
+
+Revision note (2026-07-23, Codex): Recorded the successful repeated Milestone 3 build and the first live UI defect: text inside a History row intercepted the row's click path. Disabled hit testing on the row's decorative child grid so its entire surface has one navigation target, and left live validation pending a user rebuild and retest.
+
+Revision note (2026-07-23, Codex): Recorded the user's successful rebuild and History sidebar retest after the row hit-test fix. Marked Milestone 3 interaction validation complete and left localization as the next Progress.
+
+Revision note (2026-07-23, Codex): Completed the localization Progress by adding the complete 64-key History vocabulary to all 16 bundled translation files, including explicit identity mappings in the existing empty English resource. Recorded the uniform 37-key non-English gap, the English coverage decision, and the successful missing/duplicate-key plus whitespace audits; full user-run regression and manual acceptance remain next.
+
+Revision note (2026-07-23, Codex): Recorded the first two findings from the comprehensive staged-change review as explicit pending Progress items: preventing merge-only no-op history entries in generic value bindings and completing array-property detail propagation. These corrections must be completed before the final build, regression, and manual acceptance steps.
+
+Revision note (2026-07-23, Codex): Completed the first staged-review correction by making generic value bindings test for real value commands before closing their merge, removing empty begin/end pairs back to the pre-edit Head, and restoring the final saved/Undo status notification without discarding older pending work. Added focus/blur and same-value slider regressions, updated the focused-test expectation to 25, and left array-property detail forwarding as the next Progress.
+
+Revision note (2026-07-23, Codex): Completed the second staged-review correction by carrying a labeled array's stored detail across the `ElementRow` construction boundary into the existing scalar and nested widget binding paths. Recorded why object-element child fields retain their own more specific labels and left user-run build, regression tests, and manual acceptance as the remaining Progress.
diff --git a/tests/TuneLab.Tests/DataDocumentHistoryTests.cs b/tests/TuneLab.Tests/DataDocumentHistoryTests.cs
new file mode 100644
index 00000000..0a5c8f12
--- /dev/null
+++ b/tests/TuneLab.Tests/DataDocumentHistoryTests.cs
@@ -0,0 +1,426 @@
+using System;
+using TuneLab.Foundation;
+using TuneLab.GUI.Controllers;
+using Xunit;
+
+namespace TuneLab.Tests;
+
+public class DataDocumentHistoryTests
+{
+ [Fact]
+ public void Commit_AppendsHistoryAndKeepsFinalPendingHead()
+ {
+ var (document, value) = CreateDocument();
+ var baseline = document.Head;
+ int statusChanged = 0;
+ document.StatusChanged += () => statusChanged++;
+
+ value.Set(10);
+ var committedState = document.Head;
+
+ Assert.NotEqual(baseline, committedState);
+ Assert.True(document.Commit());
+ Assert.Single(document.History);
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(committedState, document.Head);
+ Assert.Equal(committedState, document.History[0].State);
+ Assert.Equal("Edit Project", document.History[0].Description);
+ Assert.Null(document.History[0].Detail);
+ Assert.True(document.Undoable());
+ Assert.False(document.Redoable());
+ Assert.Equal(2, statusChanged);
+ }
+
+ [Fact]
+ public void NamedCommit_StoresDescriptionAndDetailAcrossUndoRedo()
+ {
+ var (document, value) = CreateDocument();
+ value.Set(1);
+
+ Assert.True(value.Commit("Move Notes", "Verse 1"));
+ var entry = Assert.Single(document.History);
+ Assert.Equal("Move Notes", entry.Description);
+ Assert.Equal("Verse 1", entry.Detail);
+
+ Assert.True(document.Undo());
+ Assert.True(document.Redo());
+
+ Assert.Same(entry, Assert.Single(document.History));
+ Assert.Equal("Move Notes", entry.Description);
+ Assert.Equal("Verse 1", entry.Detail);
+ }
+
+ [Fact]
+ public void BlankDescription_UsesFallbackAndPreservesDetail()
+ {
+ var (document, value) = CreateDocument();
+ value.Set(1);
+
+ Assert.True(document.Commit(" \t\r\n", "Gain"));
+
+ var entry = Assert.Single(document.History);
+ Assert.Equal("Edit Project", entry.Description);
+ Assert.Equal("Gain", entry.Detail);
+ }
+
+ [Fact]
+ public void DataValueBinding_FocusBlurWithoutChangePreservesHistoryAndSavedState()
+ {
+ AssertUnchangedDataValueEdit(raiseValueChanged: false);
+ }
+
+ [Fact]
+ public void DataValueBinding_UnchangedSliderEventPreservesHistoryAndNextUndo()
+ {
+ AssertUnchangedDataValueEdit(raiseValueChanged: true);
+ }
+
+ [Fact]
+ public void UndoRedo_MoveCursorDataAndHead()
+ {
+ var (document, value) = CreateDocument();
+ var baseline = document.Head;
+ CommitValue(document, value, 1);
+ var firstState = document.Head;
+ CommitValue(document, value, 2);
+ var secondState = document.Head;
+
+ Assert.True(document.Undo());
+ Assert.Equal(1, value.Value);
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(firstState, document.Head);
+ Assert.True(document.Undoable());
+ Assert.True(document.Redoable());
+
+ Assert.True(document.Undo());
+ Assert.Equal(0, value.Value);
+ Assert.Equal(0, document.HistoryPosition);
+ Assert.Equal(baseline, document.Head);
+ Assert.False(document.Undoable());
+ Assert.True(document.Redoable());
+
+ Assert.True(document.Redo());
+ Assert.Equal(1, value.Value);
+ Assert.Equal(firstState, document.Head);
+
+ Assert.True(document.Redo());
+ Assert.Equal(2, value.Value);
+ Assert.Equal(2, document.HistoryPosition);
+ Assert.Equal(secondState, document.Head);
+ Assert.True(document.Undoable());
+ Assert.False(document.Redoable());
+ }
+
+ [Fact]
+ public void MoveToHistory_JumpsBackwardAndForward()
+ {
+ var (document, value) = CreateDocument();
+ CommitValue(document, value, 1);
+ CommitValue(document, value, 2);
+ CommitValue(document, value, 3);
+
+ Assert.True(document.MoveToHistory(1));
+ Assert.Equal(1, value.Value);
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(document.History[0].State, document.Head);
+
+ Assert.True(document.MoveToHistory(3));
+ Assert.Equal(3, value.Value);
+ Assert.Equal(3, document.HistoryPosition);
+ Assert.Equal(document.History[2].State, document.Head);
+ }
+
+ [Fact]
+ public void CommitAfterUndo_TruncatesForwardHistory()
+ {
+ var (document, value) = CreateDocument();
+ CommitValue(document, value, 1);
+ CommitValue(document, value, 2);
+ CommitValue(document, value, 3);
+ var abandonedSecondState = document.History[1].State;
+ var abandonedThirdState = document.History[2].State;
+
+ Assert.True(document.MoveToHistory(1));
+ value.Set(10);
+ var branchState = document.Head;
+ Assert.True(document.Commit());
+
+ Assert.Equal(10, value.Value);
+ Assert.Equal(2, document.History.Count);
+ Assert.Equal(2, document.HistoryPosition);
+ Assert.Equal(branchState, document.History[1].State);
+ Assert.DoesNotContain(document.History, entry => entry.State == abandonedSecondState);
+ Assert.DoesNotContain(document.History, entry => entry.State == abandonedThirdState);
+ Assert.False(document.Redoable());
+ Assert.False(document.Redo());
+ }
+
+ [Fact]
+ public void Clear_ResetsHistoryAndAllocatesFreshBaseline()
+ {
+ var (document, value) = CreateDocument();
+ var originalBaseline = document.Head;
+ value.Set(1);
+ var pendingState = document.Head;
+ Assert.True(document.Commit());
+ var committedState = document.Head;
+
+ document.Clear();
+
+ Assert.Empty(document.History);
+ Assert.Equal(0, document.HistoryPosition);
+ Assert.Equal(1, value.Value);
+ Assert.NotEqual(default(Head), document.Head);
+ Assert.NotEqual(originalBaseline, document.Head);
+ Assert.NotEqual(pendingState, document.Head);
+ Assert.NotEqual(committedState, document.Head);
+ Assert.False(document.Undoable());
+ Assert.False(document.Redoable());
+ }
+
+ [Fact]
+ public void MoveToHistory_InvalidPositionsDoNothing()
+ {
+ var (document, value) = CreateDocument();
+ CommitValue(document, value, 1);
+ var head = document.Head;
+ int statusChanged = 0;
+ document.StatusChanged += () => statusChanged++;
+
+ Assert.False(document.MoveToHistory(-1));
+ Assert.False(document.MoveToHistory(document.History.Count + 1));
+ Assert.False(document.MoveToHistory(document.HistoryPosition));
+
+ Assert.Equal(1, value.Value);
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(head, document.Head);
+ Assert.Equal(0, statusChanged);
+ }
+
+ [Fact]
+ public void MoveToHistory_WithUncommittedCommandsDoesNothing()
+ {
+ var (document, value) = CreateDocument();
+ CommitValue(document, value, 1);
+ value.Set(2);
+ var pendingHead = document.Head;
+ int statusChanged = 0;
+ document.StatusChanged += () => statusChanged++;
+
+ Assert.False(document.MoveToHistory(0));
+ Assert.False(document.Undo());
+ Assert.False(document.Redo());
+
+ Assert.Equal(2, value.Value);
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(pendingHead, document.Head);
+ Assert.False(document.Undoable());
+ Assert.False(document.Redoable());
+ Assert.Equal(0, statusChanged);
+ }
+
+ [Fact]
+ public void Discard_RestoresDataAndPendingBoundaryHead()
+ {
+ var (document, value) = CreateDocument();
+ var baseline = document.Head;
+
+ value.Set(1);
+ value.Set(2);
+
+ Assert.True(document.Discard());
+ Assert.Equal(0, value.Value);
+ Assert.Equal(baseline, document.Head);
+ Assert.Empty(document.History);
+ Assert.True(document.Pushable());
+ Assert.False(document.Discard());
+ }
+
+ [Fact]
+ public void DiscardTo_RestoresReachablePreviewAndRejectsStaleHead()
+ {
+ var (document, value) = CreateDocument();
+ var baseline = document.Head;
+
+ value.BeginMergeNotify();
+ var previewStart = document.Head;
+ value.Set(1);
+ var stalePreviewState = document.Head;
+
+ Assert.True(document.DiscardTo(previewStart));
+ Assert.Equal(0, value.Value);
+ Assert.Equal(previewStart, document.Head);
+ Assert.False(document.DiscardTo(previewStart));
+
+ value.Set(2);
+ var currentPreviewState = document.Head;
+ Assert.False(document.DiscardTo(stalePreviewState));
+ Assert.Equal(2, value.Value);
+ Assert.Equal(currentPreviewState, document.Head);
+
+ Assert.True(document.DiscardTo(previewStart));
+ value.Set(3);
+ value.EndMergeNotify();
+ Assert.True(document.Commit());
+
+ Assert.Equal(3, value.Value);
+ Assert.True(document.Undo());
+ Assert.Equal(0, value.Value);
+ Assert.Equal(baseline, document.Head);
+ }
+
+ [Fact]
+ public void BranchAtSameDepthGetsDifferentHead()
+ {
+ var (document, value) = CreateDocument();
+ CommitValue(document, value, 1);
+ CommitValue(document, value, 2);
+ var abandonedStateAtDepthTwo = document.Head;
+
+ Assert.True(document.Undo());
+ value.Set(20);
+ var branchStateAtDepthTwo = document.Head;
+ Assert.True(document.Commit());
+
+ Assert.Equal(2, document.HistoryPosition);
+ Assert.Equal(20, value.Value);
+ Assert.NotEqual(abandonedStateAtDepthTwo, branchStateAtDepthTwo);
+ Assert.Equal(branchStateAtDepthTwo, document.Head);
+ Assert.Equal(branchStateAtDepthTwo, document.History[1].State);
+ }
+
+ [Fact]
+ public void MoveToHistory_MultiStepBatchesStatusAndSettledNotifications()
+ {
+ var (document, value) = CreateDocument();
+ CommitValue(document, value, 1);
+ CommitValue(document, value, 2);
+ CommitValue(document, value, 3);
+ int statusChanged = 0;
+ int settledModified = 0;
+ document.StatusChanged += () => statusChanged++;
+ value.Modified.Subscribe(() => settledModified++);
+
+ Assert.True(document.MoveToHistory(0));
+ Assert.Equal(0, value.Value);
+ Assert.Equal(1, statusChanged);
+ Assert.Equal(1, settledModified);
+
+ statusChanged = 0;
+ settledModified = 0;
+
+ Assert.True(document.MoveToHistory(3));
+ Assert.Equal(3, value.Value);
+ Assert.Equal(1, statusChanged);
+ Assert.Equal(1, settledModified);
+ }
+
+ [Fact]
+ public void MoveToHistory_CommandFailureLeavesLastReachedStateAndNotifiesOnce()
+ {
+ var document = new TestDocument();
+ int value = 0;
+ document.Apply(new DelegateCommand(
+ () => throw new InvalidOperationException("undo failed"),
+ () => value = 1));
+ Assert.True(document.Commit());
+ var firstState = document.Head;
+
+ document.Apply(new DelegateCommand(
+ () => value = 1,
+ () => value = 2));
+ Assert.True(document.Commit());
+ int statusChanged = 0;
+ document.StatusChanged += () => statusChanged++;
+
+ var exception = Assert.Throws(() => document.MoveToHistory(0));
+
+ Assert.Equal("undo failed", exception.Message);
+ Assert.Equal(1, value);
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(firstState, document.Head);
+ Assert.Equal(1, statusChanged);
+ }
+
+ static (DataDocument Document, DataStruct Value) CreateDocument()
+ {
+ var document = new DataDocument();
+ var value = new DataStruct(document);
+ return (document, value);
+ }
+
+ static void CommitValue(DataDocument document, DataStruct value, int nextValue)
+ {
+ value.Set(nextValue);
+ Assert.True(document.Commit());
+ }
+
+ static void AssertUnchangedDataValueEdit(bool raiseValueChanged)
+ {
+ var (document, value) = CreateDocument();
+ value.Set(1);
+ Assert.True(document.Commit("Edit Properties", "Value"));
+ var savedHead = document.Head;
+ var existingEntry = Assert.Single(document.History);
+ bool savedStateMarker = true;
+ document.StatusChanged += () => savedStateMarker = document.Head == savedHead;
+
+ var controller = new TestDataValueController();
+ using var bindings = new DisposableManager();
+ controller.BindDataProperty(value, bindings);
+
+ controller.BeginEdit();
+ if (raiseValueChanged)
+ controller.ChangeValue(value.Value);
+ controller.CommitEdit();
+
+ Assert.Same(existingEntry, Assert.Single(document.History));
+ Assert.Equal(1, document.HistoryPosition);
+ Assert.Equal(savedHead, document.Head);
+ Assert.True(savedStateMarker);
+ Assert.True(document.Undoable());
+
+ Assert.True(document.Undo());
+ Assert.Equal(0, value.Value);
+ Assert.Equal(0, document.HistoryPosition);
+ }
+
+ sealed class TestDocument : DataDocument
+ {
+ public void Apply(ICommand command)
+ {
+ command.Redo();
+ Push(command);
+ }
+ }
+
+ sealed class DelegateCommand(Action undo, Action redo) : ICommand
+ {
+ public void Undo() => undo();
+ public void Redo() => redo();
+ }
+
+ sealed class TestDataValueController : IDataValueController where T : notnull
+ {
+ public IActionEvent ValueWillChange => mValueWillChange;
+ public IActionEvent ValueChanged => mValueChanged;
+ public IActionEvent ValueCommitted => mValueCommitted;
+ public T Value { get; private set; } = default!;
+
+ public void Display(T value) => Value = value;
+
+ public void BeginEdit() => mValueWillChange.Invoke();
+
+ public void ChangeValue(T value)
+ {
+ Value = value;
+ mValueChanged.Invoke();
+ }
+
+ public void CommitEdit() => mValueCommitted.Invoke();
+
+ readonly ActionEvent mValueWillChange = new();
+ readonly ActionEvent mValueChanged = new();
+ readonly ActionEvent mValueCommitted = new();
+ }
+}