343 lines
9.9 KiB
Go
343 lines
9.9 KiB
Go
package document
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
|
|
"aego/asset"
|
|
"aego/core/ids"
|
|
"aego/engine/i18n"
|
|
)
|
|
|
|
// AssetRequest is resolved by the asset index: Selected identifies a frame, Asset its source sheet with shared metadata/history; generated artifacts may lack SourcePath.
|
|
type AssetRequest struct {
|
|
Asset, Selected ids.AssetID
|
|
Path, Subasset, Role string
|
|
Kind asset.ArtifactKind
|
|
ReadOnly bool
|
|
}
|
|
|
|
// AssetDocument is the identity shared by Inspector, search and asset views; the manager owns its lifetime, and View is presentation-only, never saved to source, metadata, material, scene or prefab.
|
|
type AssetDocument struct {
|
|
ID ID
|
|
Asset, Selected ids.AssetID
|
|
Path, Subasset, Role string
|
|
Kind asset.ArtifactKind
|
|
Handler string
|
|
Name i18n.Text
|
|
Icon string
|
|
Capabilities Capabilities
|
|
Source, Metadata *ScriptDocument
|
|
Scene *SceneDocument
|
|
View any
|
|
Note string
|
|
// Transient documents have no indexed source yet. Path is a display name,
|
|
// not a filesystem path; their binding must provide an explicit first Save.
|
|
Transient bool
|
|
WorkingPreview MetadataPreviewState
|
|
// Bindings adapt an already existing structured editor without duplicating
|
|
// its mutable document model or introducing another tab manager.
|
|
Binding AssetBinding
|
|
}
|
|
|
|
type MetadataPreviewState struct {
|
|
Pending bool
|
|
Revision uint64
|
|
Applied uint64
|
|
Error string
|
|
}
|
|
|
|
// EditAction routes shared Edit commands to the focused structured document.
|
|
// A missing action must not fall through to a scene behind that document.
|
|
type EditAction uint8
|
|
|
|
const (
|
|
ActionCopy EditAction = iota
|
|
ActionCut
|
|
ActionPaste
|
|
ActionDelete
|
|
ActionSelectAll
|
|
ActionDuplicate
|
|
ActionSaveAs
|
|
)
|
|
|
|
type AssetBinding struct {
|
|
// Model owns structured source state; AssetDocument.View remains presentation-only.
|
|
Model any
|
|
Actions map[EditAction]func() error
|
|
CanUndo func() bool
|
|
CanRedo func() bool
|
|
Close func()
|
|
Recovery func() ([]byte, error)
|
|
// PendingRecovery is a previously saved recovery awaiting an explicit
|
|
// user decision. Discarding the current document must not erase it.
|
|
PendingRecovery func() bool
|
|
PreviewRevision func() uint64
|
|
PreviewSource func() []byte
|
|
// PreviewSnapshot captures immutable bytes; PreviewCurrent guards publication.
|
|
PreviewSnapshot func() ([]byte, uint64, error)
|
|
PreviewCurrent func(uint64) bool
|
|
Dirty func() bool
|
|
// BeforeSave must not write; it protects structured inputs before metadata
|
|
// or source buffers are saved. SaveUnchanged supports an explicit first Save
|
|
// for an in-memory source without falsely marking the document dirty.
|
|
BeforeSave func() error
|
|
SaveUnchanged bool
|
|
Save func() error
|
|
Undo func() error
|
|
Redo func() error
|
|
Discard func()
|
|
}
|
|
|
|
// ErrSavePending means a user decision is required and no save has completed.
|
|
// In particular, Save All / Quit must not treat a queued path prompt as success.
|
|
var ErrSavePending = errors.New("save is waiting for a user decision")
|
|
|
|
func (d *AssetDocument) Dirty() bool {
|
|
return (d.Source != nil && d.Source.Dirty()) || (d.Metadata != nil && d.Metadata.Dirty()) ||
|
|
(d.Scene != nil && d.Scene.Dirty()) || (d.Binding.Dirty != nil && d.Binding.Dirty())
|
|
}
|
|
|
|
func (d *AssetDocument) Caption() string {
|
|
label := path.Base(d.Path)
|
|
if d.Dirty() {
|
|
label += " " + DirtyMark
|
|
}
|
|
return label
|
|
}
|
|
|
|
func (m *Manager) Handlers() *HandlerRegistry { return &m.handlers }
|
|
func (m *Manager) AssetDocuments() []*AssetDocument { return m.assets }
|
|
|
|
func (m *Manager) AssetDocument(id ids.AssetID) *AssetDocument {
|
|
for _, d := range m.assets {
|
|
if d.Asset == id {
|
|
return d
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validProjectPath(p string) bool {
|
|
return p != "" && p != "." && !strings.HasPrefix(p, "/") && p != ".." && !strings.HasPrefix(p, "../") && !strings.Contains(p, ":")
|
|
}
|
|
|
|
// NewTransientAsset registers an in-memory document in the normal manager so
|
|
// dirty state, history, close and recovery are not bypassed. It creates no
|
|
// source or metadata file. The caller owns the binding and its Save workflow.
|
|
func (m *Manager) NewTransientAsset(id ids.AssetID, title, handler string, kind asset.ArtifactKind, capabilities Capabilities) (*AssetDocument, error) {
|
|
if m == nil || id.IsZero() || title == "" || handler == "" || !capabilities.Has(View|EditSource) || m.AssetDocument(id) != nil {
|
|
return nil, fmt.Errorf("invalid or already open in-memory asset document")
|
|
}
|
|
m.next++
|
|
d := &AssetDocument{ID: m.next, Asset: id, Selected: id, Path: title, Handler: handler, Kind: kind,
|
|
Capabilities: capabilities, Transient: true, Icon: "file.art"}
|
|
m.assets = append(m.assets, d)
|
|
return d, nil
|
|
}
|
|
|
|
// AcquireAsset loads the shared model without activating its presentation.
|
|
// Inspectors use it as well as OpenAsset; neither creates a private file cache.
|
|
func (m *Manager) AcquireAsset(req AssetRequest) (*AssetDocument, error) {
|
|
if req.Asset.IsZero() {
|
|
return nil, fmt.Errorf("asset document needs a stable AssetID")
|
|
}
|
|
if req.Selected.IsZero() {
|
|
req.Selected = req.Asset
|
|
}
|
|
if req.Path != "" {
|
|
req.Path = Clean(req.Path)
|
|
}
|
|
if !req.ReadOnly && !validProjectPath(req.Path) {
|
|
return nil, fmt.Errorf("asset source path must stay inside the project")
|
|
}
|
|
h := m.handlers.Resolve(req.Kind, req.Path, req.Role)
|
|
d := m.AssetDocument(req.Asset)
|
|
fresh := d == nil
|
|
if fresh {
|
|
d = &AssetDocument{Asset: req.Asset}
|
|
} else if d.Transient {
|
|
return nil, fmt.Errorf("asset identity belongs to an open in-memory document")
|
|
} else if d.Path != req.Path && !req.ReadOnly {
|
|
// AssetID is authoritative after external moves/index refreshes: preserve dirty buffers and disk baselines at new paths without letting another open file steal their history.
|
|
for _, move := range []struct {
|
|
doc *ScriptDocument
|
|
path string
|
|
}{{d.Source, req.Path}, {d.Metadata, req.Path + ".meta"}} {
|
|
if move.doc == nil {
|
|
continue
|
|
}
|
|
if other := m.Script(move.path); other != nil && other != move.doc {
|
|
return nil, fmt.Errorf("asset moved to a path that already has an open document: %s", move.path)
|
|
}
|
|
}
|
|
m.Renamed(d.Path, req.Path)
|
|
}
|
|
d.Path, d.Selected, d.Subasset, d.Role, d.Kind = req.Path, req.Selected, req.Subasset, req.Role, req.Kind
|
|
d.Handler, d.Capabilities = h.ID, h.Capabilities
|
|
d.Name, d.Icon = h.Name, h.Icon
|
|
if req.ReadOnly {
|
|
d.Capabilities &^= EditSource | EditMetadata
|
|
if asset.IsBuiltin(req.Asset) {
|
|
d.Capabilities &^= RevealSource
|
|
}
|
|
}
|
|
if h.TextSource && !req.ReadOnly && d.Source == nil {
|
|
s, err := m.OpenScript(req.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.Source = s
|
|
if !h.Capabilities.Has(EditSource) {
|
|
s.ReadOnly = true
|
|
}
|
|
}
|
|
if d.Capabilities.Has(EditMetadata) && d.Metadata == nil {
|
|
s, err := m.OpenScript(req.Path + ".meta")
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
d.Capabilities &^= EditMetadata
|
|
d.Note = "The metadata file is missing. Repair the asset metadata before editing it."
|
|
} else {
|
|
d.Metadata = s
|
|
s.ExplicitSave = true
|
|
}
|
|
}
|
|
if fresh {
|
|
m.next++
|
|
d.ID = m.next
|
|
if d.Source != nil {
|
|
d.ID = d.Source.ID
|
|
}
|
|
m.assets = append(m.assets, d)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (d *AssetDocument) CanUndo() bool {
|
|
if d.Binding.CanUndo != nil {
|
|
return d.Binding.CanUndo()
|
|
}
|
|
if d.Metadata != nil {
|
|
return d.Metadata.Code.CanUndo()
|
|
}
|
|
if d.Source != nil {
|
|
return d.Source.Code.CanUndo()
|
|
}
|
|
if d.Scene != nil {
|
|
return d.Scene.History.CanUndo()
|
|
}
|
|
return d.Binding.Undo != nil
|
|
}
|
|
|
|
func (d *AssetDocument) CanRedo() bool {
|
|
if d.Binding.CanRedo != nil {
|
|
return d.Binding.CanRedo()
|
|
}
|
|
if d.Metadata != nil {
|
|
return d.Metadata.Code.CanRedo()
|
|
}
|
|
if d.Source != nil {
|
|
return d.Source.Code.CanRedo()
|
|
}
|
|
if d.Scene != nil {
|
|
return d.Scene.History.CanRedo()
|
|
}
|
|
return d.Binding.Redo != nil
|
|
}
|
|
|
|
func (m *Manager) OpenAsset(req AssetRequest) (*AssetDocument, error) {
|
|
d, err := m.AcquireAsset(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h := m.handlers.Resolve(req.Kind, req.Path, req.Role)
|
|
if h.Open != nil {
|
|
if err = h.Open(d); err != nil {
|
|
return d, err
|
|
}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (m *Manager) SaveAsset(d *AssetDocument) error {
|
|
if d == nil {
|
|
return fmt.Errorf("no asset document")
|
|
}
|
|
if d.Binding.BeforeSave != nil {
|
|
if err := d.Binding.BeforeSave(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, s := range []*ScriptDocument{d.Source, d.Metadata} {
|
|
if s != nil && s.Dirty() {
|
|
if err := m.SaveScript(s); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if d.Scene != nil && d.Scene.Dirty() {
|
|
if err := m.Save(d.Scene.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if d.Binding.Save != nil && (d.Binding.SaveUnchanged || d.Binding.Dirty != nil && d.Binding.Dirty()) {
|
|
return d.Binding.Save()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) CloseAsset(id ids.AssetID, force bool) error {
|
|
for i, d := range m.assets {
|
|
if d.Asset != id {
|
|
continue
|
|
}
|
|
if d.Dirty() && !force {
|
|
return fmt.Errorf("asset has unsaved changes: %s", d.Path)
|
|
}
|
|
if force {
|
|
for _, source := range []*ScriptDocument{d.Source, d.Metadata} {
|
|
if source != nil && source.views > 0 && source.Dirty() {
|
|
if err := m.DiscardScript(source); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if force && d.Binding.Recovery != nil && (d.Binding.PendingRecovery == nil || !d.Binding.PendingRecovery()) {
|
|
if err := m.ClearOwnedAssetRecovery(d); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if force && d.Binding.Discard != nil {
|
|
d.Binding.Discard()
|
|
}
|
|
if d.Binding.Close != nil {
|
|
d.Binding.Close()
|
|
}
|
|
m.assets = append(m.assets[:i], m.assets[i+1:]...)
|
|
for _, s := range []*ScriptDocument{d.Source, d.Metadata} {
|
|
if s != nil {
|
|
m.CloseScript(s)
|
|
}
|
|
}
|
|
m.emit(Event{Kind: EvClosed, Doc: d.ID, Path: d.Path})
|
|
return nil
|
|
}
|
|
return fmt.Errorf("no such asset document")
|
|
}
|
|
|
|
func (m *Manager) scriptOwned(d *ScriptDocument) bool {
|
|
for _, a := range m.assets {
|
|
if a.Source == d || a.Metadata == d {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|