diff --git a/backend/api/apitest/apitest.go b/backend/api/apitest/apitest.go index 7888aaac0..c8c577b68 100644 --- a/backend/api/apitest/apitest.go +++ b/backend/api/apitest/apitest.go @@ -32,6 +32,12 @@ func (b *ChangeBuilder) SetCapability(cpbID string) *ChangeBuilder { return b } +// SetMessage sets the optional human-readable publish message on the request. +func (b *ChangeBuilder) SetMessage(message string) *ChangeBuilder { + b.req.Message = message + return b +} + // SetMetadata adds a SetMetadata change to the request. func (b *ChangeBuilder) SetMetadata(key, value string) *ChangeBuilder { b.req.Changes = append(b.req.Changes, &documents.DocumentChange{ diff --git a/backend/api/documents/v3alpha/dochistory.go b/backend/api/documents/v3alpha/dochistory.go index 40a84a67f..7d1eb2a50 100644 --- a/backend/api/documents/v3alpha/dochistory.go +++ b/backend/api/documents/v3alpha/dochistory.go @@ -107,6 +107,7 @@ func (srv *Server) ListDocumentChanges(ctx context.Context, in *documents.ListDo Author: change.Signer.String(), Deps: colx.SliceMap(change.Deps, cid.Cid.String), CreateTime: timestamppb.New(change.Ts), + Message: change.Message, }) } @@ -144,5 +145,6 @@ func (srv *Server) GetDocumentChange(ctx context.Context, in *documents.GetDocum Author: change.Signer.String(), Deps: colx.SliceMap(change.Deps, cid.Cid.String), CreateTime: timestamppb.New(change.Ts), + Message: change.Message, }, nil } diff --git a/backend/api/documents/v3alpha/docmodel/crdt.go b/backend/api/documents/v3alpha/docmodel/crdt.go index 8bd357ff9..d04ef6e7b 100644 --- a/backend/api/documents/v3alpha/docmodel/crdt.go +++ b/backend/api/documents/v3alpha/docmodel/crdt.go @@ -590,8 +590,10 @@ func addUnique(in []int, v int) []int { return slices.Insert(in, targetIndex, v) } -// prepareChange to be applied later. -func (e *docCRDT) prepareChange(ts time.Time, signer core.Signer, body blob.ChangeBody) (hb blob.Encoded[*blob.Change], err error) { +// prepareChange to be applied later. The message argument is an optional +// human-readable description (like a git commit message) embedded in the +// signed Change blob; pass an empty string to omit it. +func (e *docCRDT) prepareChange(ts time.Time, signer core.Signer, body blob.ChangeBody, message string) (hb blob.Encoded[*blob.Change], err error) { var genesis cid.Cid if len(e.cids) > 0 { genesis = e.cids[0] @@ -611,7 +613,7 @@ func (e *docCRDT) prepareChange(ts time.Time, signer core.Signer, body blob.Chan } slices.SortFunc(deps, func(a, b cid.Cid) int { return strings.Compare(a.KeyString(), b.KeyString()) }) - hb, err = blob.NewChange(signer, genesis, deps, depth, body, ts) + hb, err = blob.NewChange(signer, genesis, deps, depth, body, ts, message) if err != nil { return hb, err } diff --git a/backend/api/documents/v3alpha/docmodel/docmodel.go b/backend/api/documents/v3alpha/docmodel/docmodel.go index 577cc49b4..07786d9a1 100644 --- a/backend/api/documents/v3alpha/docmodel/docmodel.go +++ b/backend/api/documents/v3alpha/docmodel/docmodel.go @@ -340,22 +340,25 @@ func (dm *Document) ensureTreeMutation() (*blockTreeMutation, error) { return dm.mut, nil } -// SignChange creates a change. +// SignChange creates a change with an optional human-readable message +// (similar to a git commit message). Pass an empty string to omit the message. // After this the Document instance must be discarded. The change must be applied to a different state. -func (dm *Document) SignChange(kp *core.KeyPair) (hb blob.Encoded[*blob.Change], err error) { - return dm.SignChangeAt(kp, dm.crdt.clock.MustNow()) +func (dm *Document) SignChange(kp *core.KeyPair, message string) (hb blob.Encoded[*blob.Change], err error) { + return dm.SignChangeAt(kp, dm.crdt.clock.MustNow(), message) } // SignChangeAt creates a change at the given timestamp, ignoring the internal clock. // The timestamp must still satisfy the causality rules, i.e. be strictly greater than any previously observed timestamp. -func (dm *Document) SignChangeAt(kp *core.KeyPair, at time.Time) (hb blob.Encoded[*blob.Change], err error) { - return dm.CreateChange(kp, at) +// The message argument is optional (pass an empty string to omit). +func (dm *Document) SignChangeAt(kp *core.KeyPair, at time.Time, message string) (hb blob.Encoded[*blob.Change], err error) { + return dm.CreateChange(kp, at, message) } // CreateChange creates a Change blob with the given signer and principal. // This allows using NopSigner for client-side signing scenarios where the signer doesn't actually sign. +// The message argument is optional and embedded in the signed Change blob. // After this the Document instance must be discarded. -func (dm *Document) CreateChange(signer core.Signer, at time.Time) (hb blob.Encoded[*blob.Change], err error) { +func (dm *Document) CreateChange(signer core.Signer, at time.Time, message string) (hb blob.Encoded[*blob.Change], err error) { if dm.done { return hb, fmt.Errorf("using already committed mutation") } @@ -369,7 +372,7 @@ func (dm *Document) CreateChange(signer core.Signer, at time.Time) (hb blob.Enco at = at.Round(dm.crdt.clock.Precision) - hb, err = dm.crdt.prepareChange(at, signer, ops) + hb, err = dm.crdt.prepareChange(at, signer, ops, message) if err != nil { return hb, err } diff --git a/backend/api/documents/v3alpha/docmodel/docmodel_test.go b/backend/api/documents/v3alpha/docmodel/docmodel_test.go index 46664a51d..52b5976a6 100644 --- a/backend/api/documents/v3alpha/docmodel/docmodel_test.go +++ b/backend/api/documents/v3alpha/docmodel/docmodel_test.go @@ -20,7 +20,7 @@ func TestDocmodelSmoke(t *testing.T) { must.Do(doc.MoveBlock("b2", "", "b1")) must.Do(doc.MoveBlock("b3", "", "b2")) must.Do(doc.MoveBlock("b1.1", "b1", "")) - c1 := must.Do2(doc.SignChange(alice)) + c1 := must.Do2(doc.SignChange(alice, "")) want := &blob.Change{ Body: blob.ChangeBody{ @@ -54,7 +54,7 @@ func TestDocmodelSmoke(t *testing.T) { must.Do(doc.DeleteBlock("b1.1")) must.Do(doc.MoveBlock("b4", "", "")) must.Do(doc.DeleteBlock("b3")) - c2 := must.Do2(doc.SignChange(alice)) + c2 := must.Do2(doc.SignChange(alice, "")) { doc := must.Do2(New("mydoc", cclock.New())) @@ -94,7 +94,7 @@ func TestBug_RedundantReplaces(t *testing.T) { Text: "3", })) - c1, err := doc.SignChange(alice) + c1, err := doc.SignChange(alice, "") require.NoError(t, err) var c2 blob.Encoded[*blob.Change] @@ -120,7 +120,7 @@ func TestBug_RedundantReplaces(t *testing.T) { Text: "3", })) - c2, err = doc.SignChange(alice) + c2, err = doc.SignChange(alice, "") require.NoError(t, err) } @@ -176,7 +176,7 @@ func TestBug_BlockReordering(t *testing.T) { Text: "5", })) - c1, err := doc.SignChange(alice) + c1, err := doc.SignChange(alice, "") require.NoError(t, err) var c2 blob.Encoded[*blob.Change] @@ -219,7 +219,7 @@ func TestBug_BlockReordering(t *testing.T) { must.Do(doc.DeleteBlock("mMa")) - c2, err = doc.SignChange(alice) + c2, err = doc.SignChange(alice, "") require.NoError(t, err) } diff --git a/backend/api/documents/v3alpha/documents.go b/backend/api/documents/v3alpha/documents.go index 5b57ddebf..d5ca5520a 100644 --- a/backend/api/documents/v3alpha/documents.go +++ b/backend/api/documents/v3alpha/documents.go @@ -222,12 +222,12 @@ func (srv *Server) CreateDocumentChange(ctx context.Context, in *documents.Creat var docChange blob.Encoded[*blob.Change] if in.Timestamp != nil { - docChange, err = doc.SignChangeAt(kp, in.Timestamp.AsTime()) + docChange, err = doc.SignChangeAt(kp, in.Timestamp.AsTime(), in.Message) if err != nil { return nil, fmt.Errorf("failed to create document change with the provided timestamp: %w", err) } } else { - docChange, err = doc.SignChange(kp) + docChange, err = doc.SignChange(kp, in.Message) if err != nil { return nil, fmt.Errorf("failed to create document change: %w", err) } @@ -274,15 +274,15 @@ func (srv *Server) PrepareChange(ctx context.Context, in *documents.PrepareChang BaseVersion: in.BaseVersion, Changes: in.Changes, Capability: in.Capability, - Visibility: in.Visibility, }) if err != nil { return nil, err } // Use NopSigner to create the Change without actually signing it. - // The client will sign it themselves. - change, err := doc.CreateChange(blob.NewNopSigner(nil), time.Now()) + // The client will sign it themselves. The optional message is embedded in + // the unsigned bytes so the client doesn't need to know about it. + change, err := doc.CreateChange(blob.NewNopSigner(nil), time.Now(), in.Message) if err != nil { return nil, err } @@ -1710,7 +1710,7 @@ func (srv *Server) getRef(ctx context.Context, c cid.Cid) (hb blob.WithCID[*blob } func (srv *Server) ensureProfileGenesis(ctx context.Context, kp *core.KeyPair) error { - ebc, err := blob.NewChange(kp, cid.Undef, nil, 0, blob.ChangeBody{}, blob.ZeroUnixTime()) + ebc, err := blob.NewChange(kp, cid.Undef, nil, 0, blob.ChangeBody{}, blob.ZeroUnixTime(), "") if err != nil { return err } diff --git a/backend/blob/blob_capability_test.go b/backend/blob/blob_capability_test.go index 3c216d208..bc2169ddc 100644 --- a/backend/blob/blob_capability_test.go +++ b/backend/blob/blob_capability_test.go @@ -25,7 +25,7 @@ func TestOutOfOrderCapability(t *testing.T) { Ops: []OpMap{ must.Do2(NewOpSetKey("name", "Hello")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) ref, err := NewRef(bob, 0, change.CID, alice.Principal(), "", []cid.Cid{change.CID}, clock.MustNow(), VisibilityPublic) diff --git a/backend/blob/blob_change.go b/backend/blob/blob_change.go index cf19ed7bb..3c4786613 100644 --- a/backend/blob/blob_change.go +++ b/backend/blob/blob_change.go @@ -29,6 +29,10 @@ type Change struct { Deps []cid.Cid `refmt:"deps,omitempty"` Depth int `refmt:"depth,omitempty"` Body ChangeBody `refmt:"body,omitempty"` + // Message is an optional human-readable description of this change, + // similar to a git commit message. Set by the publisher and preserved + // permanently as part of the signed Change blob. + Message string `refmt:"message,omitempty"` } // ChangeBody is the body of a Change. @@ -44,8 +48,10 @@ type ChangeBody struct { Ops []OpMap `refmt:"ops,omitempty"` } -// NewChange creates a new Change. -func NewChange(signer core.Signer, genesis cid.Cid, deps []cid.Cid, depth int, body ChangeBody, ts time.Time) (eb Encoded[*Change], err error) { +// NewChange creates a new Change. The message argument is an optional +// human-readable description (like a git commit message) embedded into the +// signed blob; pass an empty string to omit it. +func NewChange(signer core.Signer, genesis cid.Cid, deps []cid.Cid, depth int, body ChangeBody, ts time.Time, message string) (eb Encoded[*Change], err error) { if !slices.IsSortedFunc(deps, func(a, b cid.Cid) int { return cmp.Compare(a.KeyString(), b.KeyString()) }) { @@ -62,6 +68,7 @@ func NewChange(signer core.Signer, genesis cid.Cid, deps []cid.Cid, depth int, b Deps: deps, Depth: depth, Body: body, + Message: message, } if err := Sign(signer, cc, &cc.BaseBlob.Sig); err != nil { @@ -526,7 +533,11 @@ func indexChange(ictx *indexingCtx, id int64, eb Encoded[*Change]) error { } } - if extra.Title != "" || len(extra.Metadata) > 0 { + if v.Message != "" { + extra.Message = v.Message + } + + if extra.Title != "" || len(extra.Metadata) > 0 || extra.Message != "" { sb.ExtraAttrs = extra } @@ -544,6 +555,7 @@ func indexChange(ictx *indexingCtx, id int64, eb Encoded[*Change]) error { type changeIndexedAttrs struct { Title string `json:"title"` // Deprecated. TODO(burdiyan): remove this in favor of metadata. Metadata map[string]any `json:"metadata,omitempty"` + Message string `json:"message,omitempty"` } type decodedBlob[T any] struct { diff --git a/backend/blob/blob_change_test.go b/backend/blob/blob_change_test.go index f2568f765..9371d1ef4 100644 --- a/backend/blob/blob_change_test.go +++ b/backend/blob/blob_change_test.go @@ -1,8 +1,12 @@ package blob import ( + "seed/backend/core/coretest" + "seed/backend/util/cclock" + "seed/backend/util/must" "testing" + "github.com/ipfs/go-cid" cbornode "github.com/ipfs/go-ipld-cbor" "github.com/stretchr/testify/require" ) @@ -55,3 +59,37 @@ func TestBlockEncoding_FieldInlining(t *testing.T) { var blk2 Block require.NoError(t, cbornode.DecodeInto(raw, &blk2), "round-trip decoding failed") } + +// TestChangeMessageRoundTrip verifies that the optional Message field +// survives a CBOR encode/decode cycle and is included in the indexed +// extra_attrs so the API can surface it without re-decoding the blob. +func TestChangeMessageRoundTrip(t *testing.T) { + alice := coretest.NewTester("alice").Account + clock := cclock.New() + + msg := "Initial publish: import seed sources" + c, err := NewChange(alice, cid.Undef, nil, 0, ChangeBody{ + Ops: []OpMap{ + must.Do2(NewOpSetKey("title", "Hello")), + }, + }, clock.MustNow(), msg) + require.NoError(t, err) + + var decoded Change + require.NoError(t, cbornode.DecodeInto(c.Data, &decoded)) + require.Equal(t, msg, decoded.Message, "message field must survive CBOR round-trip") + + // Also check that an empty message is omitted from the encoding, + // preserving backward compatibility for changes without a message. + c2, err := NewChange(alice, cid.Undef, nil, 0, ChangeBody{ + Ops: []OpMap{ + must.Do2(NewOpSetKey("title", "Hello")), + }, + }, clock.MustNow(), "") + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, cbornode.DecodeInto(c2.Data, &raw)) + _, hasMessage := raw["message"] + require.False(t, hasMessage, "empty message must be omitted from CBOR encoding") +} diff --git a/backend/blob/blob_ref_test.go b/backend/blob/blob_ref_test.go index 98d560935..63dac0401 100644 --- a/backend/blob/blob_ref_test.go +++ b/backend/blob/blob_ref_test.go @@ -28,7 +28,7 @@ func TestRefCausality(t *testing.T) { Ops: []OpMap{ must.Do2(NewOpSetKey("title", "Initial Document")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) time.Sleep(time.Millisecond) @@ -38,7 +38,7 @@ func TestRefCausality(t *testing.T) { Ops: []OpMap{ must.Do2(NewOpSetKey("content", "Updated content")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) time.Sleep(time.Millisecond) diff --git a/backend/blob/index_test.go b/backend/blob/index_test.go index 4a04178a8..f8919daac 100644 --- a/backend/blob/index_test.go +++ b/backend/blob/index_test.go @@ -78,7 +78,7 @@ func TestWriterCheck_DelegatedSessionKeys(t *testing.T) { Ops: []OpMap{ must.Do2(NewOpSetKey("title", "Session key edit")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) ref, err := NewRef(bobSession, 0, change.CID, alice.Principal(), "/delegated-session", []cid.Cid{change.CID}, clock.MustNow(), VisibilityPublic) @@ -120,7 +120,7 @@ func TestWriterCheck_OwnersSessionKey(t *testing.T) { Ops: []OpMap{ must.Do2(NewOpSetKey("title", "Owner session key edit")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) ref, err := NewRef(aliceSession, 0, change.CID, alice.Principal(), "/owner-session", []cid.Cid{change.CID}, clock.MustNow(), VisibilityPublic) diff --git a/backend/blob/index_visibility_test.go b/backend/blob/index_visibility_test.go index 2c1d12b2f..244f2372a 100644 --- a/backend/blob/index_visibility_test.go +++ b/backend/blob/index_visibility_test.go @@ -93,35 +93,35 @@ func TestRefVisibilityPropagationSingleRef(t *testing.T) { Ops: []OpMap{ must.Do2(NewOpSetKey("title", "Genesis")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) c2, err := NewChange(alice.Account, c1.CID, []cid.Cid{c1.CID}, 1, ChangeBody{ Ops: []OpMap{ must.Do2(NewOpSetKey("title", "First update")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) c3, err := NewChange(alice.Account, c1.CID, []cid.Cid{c2.CID}, 2, ChangeBody{ Ops: []OpMap{ must.Do2(NewOpSetKey("title", "Side branch")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) c4, err := NewChange(alice.Account, c1.CID, []cid.Cid{c2.CID}, 2, ChangeBody{ Ops: []OpMap{ must.Do2(NewOpSetKey("content", "Merge head")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) cStar, err := NewChange(alice.Account, c1.CID, []cid.Cid{c4.CID}, 3, ChangeBody{ Ops: []OpMap{ must.Do2(NewOpSetKey("content", "Out of closure")), }, - }, clock.MustNow()) + }, clock.MustNow(), "") require.NoError(t, err) ref, err := NewRef(alice.Account, 0, c1.CID, alice.Account.Principal(), "/test-doc", []cid.Cid{c4.CID, c3.CID}, clock.MustNow(), VisibilityPrivate) diff --git a/backend/daemon/http.go b/backend/daemon/http.go index 1e5ca3ec2..cf0c579e6 100644 --- a/backend/daemon/http.go +++ b/backend/daemon/http.go @@ -127,9 +127,10 @@ func initHTTP( return } -// grpcUIMu serializes standalone.Handler calls. The underlying proto printer -// has lazy-init fields on shared global descriptors that race under -race. -var grpcUIMu sync.Mutex +// grpcUIHandlerMu serializes calls to standalone.Handler, which internally +// uses protoprint.DefaultPrinter — a package-level variable that is not +// safe for concurrent use. +var grpcUIHandlerMu sync.Mutex func makeGRPCUIHandler(rpc *grpc.Server, clean *cleanup.Stack, g *errgroup.Group) (http.Handler, error) { methods, err := grpcui.AllMethodsForServer(rpc) @@ -162,10 +163,9 @@ func makeGRPCUIHandler(rpc *grpc.Server, clean *cleanup.Stack, g *errgroup.Group } clean.AddErrFunc(conn.Close) - grpcUIMu.Lock() + grpcUIHandlerMu.Lock() h := standalone.Handler(conn, "seed daemon", methods, files) - grpcUIMu.Unlock() - + grpcUIHandlerMu.Unlock() return h, nil } diff --git a/backend/genproto/documents/v3alpha/access_control.pb.go b/backend/genproto/documents/v3alpha/access_control.pb.go index 2058fe640..97f8deeab 100644 --- a/backend/genproto/documents/v3alpha/access_control.pb.go +++ b/backend/genproto/documents/v3alpha/access_control.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.11 // protoc v4.24.4 // source: documents/v3alpha/access_control.proto diff --git a/backend/genproto/documents/v3alpha/comments.pb.go b/backend/genproto/documents/v3alpha/comments.pb.go index 26443198b..1ff91c7e5 100644 --- a/backend/genproto/documents/v3alpha/comments.pb.go +++ b/backend/genproto/documents/v3alpha/comments.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.11 // protoc v4.24.4 // source: documents/v3alpha/comments.proto diff --git a/backend/genproto/documents/v3alpha/documents.pb.go b/backend/genproto/documents/v3alpha/documents.pb.go index 95a7edbd9..3171e33b9 100644 --- a/backend/genproto/documents/v3alpha/documents.pb.go +++ b/backend/genproto/documents/v3alpha/documents.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.11 // protoc v4.24.4 // source: documents/v3alpha/documents.proto @@ -440,7 +440,9 @@ type CreateDocumentChangeRequest struct { // Optional. Visibility of the document. // Can only be specified here when creating the document for the first time, // i.e. when `base_version` is empty. - Visibility ResourceVisibility `protobuf:"varint,8,opt,name=visibility,proto3,enum=com.seed.documents.v3alpha.ResourceVisibility" json:"visibility,omitempty"` + Visibility ResourceVisibility `protobuf:"varint,8,opt,name=visibility,proto3,enum=com.seed.documents.v3alpha.ResourceVisibility" json:"visibility,omitempty"` + // Optional. A human-readable message describing this publish, similar to a git commit message. + Message string `protobuf:"bytes,9,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -531,6 +533,13 @@ func (x *CreateDocumentChangeRequest) GetVisibility() ResourceVisibility { return ResourceVisibility_RESOURCE_VISIBILITY_UNSPECIFIED } +func (x *CreateDocumentChangeRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + // Request to prepare an unsigned document change for client-side signing. type PrepareChangeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -548,10 +557,9 @@ type PrepareChangeRequest struct { // Optional. ID of the capability that allows the signing key to write on behalf of the account // for this particular path. Capability string `protobuf:"bytes,5,opt,name=capability,proto3" json:"capability,omitempty"` - // Optional. Visibility of the document. - // Can only be specified here when creating the document for the first time, - // i.e. when `base_version` is empty. - Visibility ResourceVisibility `protobuf:"varint,6,opt,name=visibility,proto3,enum=com.seed.documents.v3alpha.ResourceVisibility" json:"visibility,omitempty"` + // Optional. A human-readable message describing this publish, similar to a git commit message. + // Embedded into the prepared Change blob so client-side signing preserves it. + Message string `protobuf:"bytes,7,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -621,11 +629,11 @@ func (x *PrepareChangeRequest) GetCapability() string { return "" } -func (x *PrepareChangeRequest) GetVisibility() ResourceVisibility { +func (x *PrepareChangeRequest) GetMessage() string { if x != nil { - return x.Visibility + return x.Message } - return ResourceVisibility_RESOURCE_VISIBILITY_UNSPECIFIED + return "" } // Response with prepared change data for client-side signing. @@ -2825,7 +2833,10 @@ type DocumentChangeInfo struct { // List of change IDs that this change depends on. Deps []string `protobuf:"bytes,3,rep,name=deps,proto3" json:"deps,omitempty"` // Time when the change was created (as claimed by the author). - CreateTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + CreateTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + // Optional. A human-readable message attached to the change by the publisher, + // similar to a git commit message. + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2888,6 +2899,13 @@ func (x *DocumentChangeInfo) GetCreateTime() *timestamppb.Timestamp { return nil } +func (x *DocumentChangeInfo) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + // Basic data about a document with some aggregations and metadata. // It's like Document, without the content, but with some additional info. type DocumentInfo struct { @@ -4455,7 +4473,7 @@ const file_documents_v3alpha_documents_proto_rawDesc = "" + "\x1bBatchGetDocumentInfoRequest\x12N\n" + "\brequests\x18\x01 \x03(\v22.com.seed.documents.v3alpha.GetDocumentInfoRequestR\brequests\"f\n" + "\x1cBatchGetDocumentInfoResponse\x12F\n" + - "\tdocuments\x18\x01 \x03(\v2(.com.seed.documents.v3alpha.DocumentInfoR\tdocuments\"\x88\x03\n" + + "\tdocuments\x18\x01 \x03(\v2(.com.seed.documents.v3alpha.DocumentInfoR\tdocuments\"\xa2\x03\n" + "\x1bCreateDocumentChangeRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12!\n" + @@ -4468,7 +4486,8 @@ const file_documents_v3alpha_documents_proto_rawDesc = "" + "\ttimestamp\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12N\n" + "\n" + "visibility\x18\b \x01(\x0e2..com.seed.documents.v3alpha.ResourceVisibilityR\n" + - "visibility\"\x9d\x02\n" + + "visibility\x12\x18\n" + + "\amessage\x18\t \x01(\tR\amessage\"\xe7\x01\n" + "\x14PrepareChangeRequest\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12!\n" + @@ -4476,10 +4495,8 @@ const file_documents_v3alpha_documents_proto_rawDesc = "" + "\achanges\x18\x04 \x03(\v2*.com.seed.documents.v3alpha.DocumentChangeR\achanges\x12\x1e\n" + "\n" + "capability\x18\x05 \x01(\tR\n" + - "capability\x12N\n" + - "\n" + - "visibility\x18\x06 \x01(\x0e2..com.seed.documents.v3alpha.ResourceVisibilityR\n" + - "visibility\"@\n" + + "capability\x12\x18\n" + + "\amessage\x18\a \x01(\tR\amessage\"@\n" + "\x15PrepareChangeResponse\x12'\n" + "\x0funsigned_change\x18\x01 \x01(\fR\x0eunsignedChange\"E\n" + "\x15DeleteDocumentRequest\x12\x18\n" + @@ -4636,13 +4653,14 @@ const file_documents_v3alpha_documents_proto_rawDesc = "" + "page_token\x18\x04 \x01(\tR\tpageToken\"o\n" + "\x10ListRefsResponse\x123\n" + "\x04refs\x18\x01 \x03(\v2\x1f.com.seed.documents.v3alpha.RefR\x04refs\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x8d\x01\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\xa7\x01\n" + "\x12DocumentChangeInfo\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" + "\x04deps\x18\x03 \x03(\tR\x04deps\x12;\n" + "\vcreate_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "createTime\"\xd5\x05\n" + + "createTime\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\"\xd5\x05\n" + "\fDocumentInfo\x12\x18\n" + "\aaccount\x18\x01 \x01(\tR\aaccount\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x123\n" + @@ -4897,123 +4915,122 @@ var file_documents_v3alpha_documents_proto_depIdxs = []int32{ 63, // 3: com.seed.documents.v3alpha.CreateDocumentChangeRequest.timestamp:type_name -> google.protobuf.Timestamp 0, // 4: com.seed.documents.v3alpha.CreateDocumentChangeRequest.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility 51, // 5: com.seed.documents.v3alpha.PrepareChangeRequest.changes:type_name -> com.seed.documents.v3alpha.DocumentChange - 0, // 6: com.seed.documents.v3alpha.PrepareChangeRequest.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility - 43, // 7: com.seed.documents.v3alpha.ListRootDocumentsResponse.documents:type_name -> com.seed.documents.v3alpha.DocumentInfo - 30, // 8: com.seed.documents.v3alpha.ListAccountsRequest.sort_options:type_name -> com.seed.documents.v3alpha.SortOptions - 19, // 9: com.seed.documents.v3alpha.ListAccountsResponse.accounts:type_name -> com.seed.documents.v3alpha.Account - 54, // 10: com.seed.documents.v3alpha.BatchGetAccountsResponse.accounts:type_name -> com.seed.documents.v3alpha.BatchGetAccountsResponse.AccountsEntry - 55, // 11: com.seed.documents.v3alpha.BatchGetAccountsResponse.errors:type_name -> com.seed.documents.v3alpha.BatchGetAccountsResponse.ErrorsEntry - 20, // 12: com.seed.documents.v3alpha.UpdateProfileRequest.profile:type_name -> com.seed.documents.v3alpha.Profile - 64, // 13: com.seed.documents.v3alpha.Account.metadata:type_name -> google.protobuf.Struct - 45, // 14: com.seed.documents.v3alpha.Account.activity_summary:type_name -> com.seed.documents.v3alpha.ActivitySummary - 20, // 15: com.seed.documents.v3alpha.Account.profile:type_name -> com.seed.documents.v3alpha.Profile - 43, // 16: com.seed.documents.v3alpha.Account.home_document_info:type_name -> com.seed.documents.v3alpha.DocumentInfo - 63, // 17: com.seed.documents.v3alpha.Profile.update_time:type_name -> google.protobuf.Timestamp - 28, // 18: com.seed.documents.v3alpha.UpdateContactRequest.contact:type_name -> com.seed.documents.v3alpha.Contact - 28, // 19: com.seed.documents.v3alpha.ListContactsResponse.contacts:type_name -> com.seed.documents.v3alpha.Contact - 63, // 20: com.seed.documents.v3alpha.Contact.create_time:type_name -> google.protobuf.Timestamp - 63, // 21: com.seed.documents.v3alpha.Contact.update_time:type_name -> google.protobuf.Timestamp - 64, // 22: com.seed.documents.v3alpha.Contact.metadata:type_name -> google.protobuf.Struct - 30, // 23: com.seed.documents.v3alpha.ListDirectoryRequest.sort_options:type_name -> com.seed.documents.v3alpha.SortOptions - 1, // 24: com.seed.documents.v3alpha.SortOptions.attribute:type_name -> com.seed.documents.v3alpha.SortAttribute - 43, // 25: com.seed.documents.v3alpha.ListDirectoryResponse.documents:type_name -> com.seed.documents.v3alpha.DocumentInfo - 43, // 26: com.seed.documents.v3alpha.ListDocumentsResponse.documents:type_name -> com.seed.documents.v3alpha.DocumentInfo - 42, // 27: com.seed.documents.v3alpha.ListDocumentChangesResponse.changes:type_name -> com.seed.documents.v3alpha.DocumentChangeInfo - 53, // 28: com.seed.documents.v3alpha.CreateRefRequest.target:type_name -> com.seed.documents.v3alpha.RefTarget - 63, // 29: com.seed.documents.v3alpha.CreateRefRequest.timestamp:type_name -> google.protobuf.Timestamp - 0, // 30: com.seed.documents.v3alpha.CreateRefRequest.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility - 52, // 31: com.seed.documents.v3alpha.ListRefsResponse.refs:type_name -> com.seed.documents.v3alpha.Ref - 63, // 32: com.seed.documents.v3alpha.DocumentChangeInfo.create_time:type_name -> google.protobuf.Timestamp - 64, // 33: com.seed.documents.v3alpha.DocumentInfo.metadata:type_name -> google.protobuf.Struct - 63, // 34: com.seed.documents.v3alpha.DocumentInfo.create_time:type_name -> google.protobuf.Timestamp - 63, // 35: com.seed.documents.v3alpha.DocumentInfo.update_time:type_name -> google.protobuf.Timestamp - 46, // 36: com.seed.documents.v3alpha.DocumentInfo.breadcrumbs:type_name -> com.seed.documents.v3alpha.Breadcrumb - 45, // 37: com.seed.documents.v3alpha.DocumentInfo.activity_summary:type_name -> com.seed.documents.v3alpha.ActivitySummary - 44, // 38: com.seed.documents.v3alpha.DocumentInfo.generation_info:type_name -> com.seed.documents.v3alpha.GenerationInfo - 61, // 39: com.seed.documents.v3alpha.DocumentInfo.redirect_info:type_name -> com.seed.documents.v3alpha.RefTarget.Redirect - 0, // 40: com.seed.documents.v3alpha.DocumentInfo.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility - 63, // 41: com.seed.documents.v3alpha.ActivitySummary.latest_comment_time:type_name -> google.protobuf.Timestamp - 63, // 42: com.seed.documents.v3alpha.ActivitySummary.latest_change_time:type_name -> google.protobuf.Timestamp - 64, // 43: com.seed.documents.v3alpha.Document.metadata:type_name -> google.protobuf.Struct - 48, // 44: com.seed.documents.v3alpha.Document.content:type_name -> com.seed.documents.v3alpha.BlockNode - 56, // 45: com.seed.documents.v3alpha.Document.detached_blocks:type_name -> com.seed.documents.v3alpha.Document.DetachedBlocksEntry - 63, // 46: com.seed.documents.v3alpha.Document.create_time:type_name -> google.protobuf.Timestamp - 63, // 47: com.seed.documents.v3alpha.Document.update_time:type_name -> google.protobuf.Timestamp - 44, // 48: com.seed.documents.v3alpha.Document.generation_info:type_name -> com.seed.documents.v3alpha.GenerationInfo - 0, // 49: com.seed.documents.v3alpha.Document.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility - 49, // 50: com.seed.documents.v3alpha.BlockNode.block:type_name -> com.seed.documents.v3alpha.Block - 48, // 51: com.seed.documents.v3alpha.BlockNode.children:type_name -> com.seed.documents.v3alpha.BlockNode - 64, // 52: com.seed.documents.v3alpha.Block.attributes:type_name -> google.protobuf.Struct - 50, // 53: com.seed.documents.v3alpha.Block.annotations:type_name -> com.seed.documents.v3alpha.Annotation - 64, // 54: com.seed.documents.v3alpha.Annotation.attributes:type_name -> google.protobuf.Struct - 58, // 55: com.seed.documents.v3alpha.DocumentChange.set_metadata:type_name -> com.seed.documents.v3alpha.DocumentChange.SetMetadata - 57, // 56: com.seed.documents.v3alpha.DocumentChange.move_block:type_name -> com.seed.documents.v3alpha.DocumentChange.MoveBlock - 49, // 57: com.seed.documents.v3alpha.DocumentChange.replace_block:type_name -> com.seed.documents.v3alpha.Block - 59, // 58: com.seed.documents.v3alpha.DocumentChange.set_attribute:type_name -> com.seed.documents.v3alpha.DocumentChange.SetAttribute - 53, // 59: com.seed.documents.v3alpha.Ref.target:type_name -> com.seed.documents.v3alpha.RefTarget - 63, // 60: com.seed.documents.v3alpha.Ref.timestamp:type_name -> google.protobuf.Timestamp - 44, // 61: com.seed.documents.v3alpha.Ref.generation_info:type_name -> com.seed.documents.v3alpha.GenerationInfo - 60, // 62: com.seed.documents.v3alpha.RefTarget.version:type_name -> com.seed.documents.v3alpha.RefTarget.Version - 61, // 63: com.seed.documents.v3alpha.RefTarget.redirect:type_name -> com.seed.documents.v3alpha.RefTarget.Redirect - 62, // 64: com.seed.documents.v3alpha.RefTarget.tombstone:type_name -> com.seed.documents.v3alpha.RefTarget.Tombstone - 19, // 65: com.seed.documents.v3alpha.BatchGetAccountsResponse.AccountsEntry.value:type_name -> com.seed.documents.v3alpha.Account - 48, // 66: com.seed.documents.v3alpha.Document.DetachedBlocksEntry.value:type_name -> com.seed.documents.v3alpha.BlockNode - 65, // 67: com.seed.documents.v3alpha.DocumentChange.SetAttribute.null_value:type_name -> google.protobuf.Empty - 2, // 68: com.seed.documents.v3alpha.Documents.GetDocument:input_type -> com.seed.documents.v3alpha.GetDocumentRequest - 4, // 69: com.seed.documents.v3alpha.Documents.GetDocumentInfo:input_type -> com.seed.documents.v3alpha.GetDocumentInfoRequest - 5, // 70: com.seed.documents.v3alpha.Documents.BatchGetDocumentInfo:input_type -> com.seed.documents.v3alpha.BatchGetDocumentInfoRequest - 7, // 71: com.seed.documents.v3alpha.Documents.CreateDocumentChange:input_type -> com.seed.documents.v3alpha.CreateDocumentChangeRequest - 8, // 72: com.seed.documents.v3alpha.Documents.PrepareChange:input_type -> com.seed.documents.v3alpha.PrepareChangeRequest - 10, // 73: com.seed.documents.v3alpha.Documents.DeleteDocument:input_type -> com.seed.documents.v3alpha.DeleteDocumentRequest - 13, // 74: com.seed.documents.v3alpha.Documents.ListAccounts:input_type -> com.seed.documents.v3alpha.ListAccountsRequest - 15, // 75: com.seed.documents.v3alpha.Documents.GetAccount:input_type -> com.seed.documents.v3alpha.GetAccountRequest - 16, // 76: com.seed.documents.v3alpha.Documents.BatchGetAccounts:input_type -> com.seed.documents.v3alpha.BatchGetAccountsRequest - 18, // 77: com.seed.documents.v3alpha.Documents.UpdateProfile:input_type -> com.seed.documents.v3alpha.UpdateProfileRequest - 21, // 78: com.seed.documents.v3alpha.Documents.CreateAlias:input_type -> com.seed.documents.v3alpha.CreateAliasRequest - 22, // 79: com.seed.documents.v3alpha.Documents.CreateContact:input_type -> com.seed.documents.v3alpha.CreateContactRequest - 23, // 80: com.seed.documents.v3alpha.Documents.GetContact:input_type -> com.seed.documents.v3alpha.GetContactRequest - 24, // 81: com.seed.documents.v3alpha.Documents.UpdateContact:input_type -> com.seed.documents.v3alpha.UpdateContactRequest - 25, // 82: com.seed.documents.v3alpha.Documents.DeleteContact:input_type -> com.seed.documents.v3alpha.DeleteContactRequest - 26, // 83: com.seed.documents.v3alpha.Documents.ListContacts:input_type -> com.seed.documents.v3alpha.ListContactsRequest - 29, // 84: com.seed.documents.v3alpha.Documents.ListDirectory:input_type -> com.seed.documents.v3alpha.ListDirectoryRequest - 32, // 85: com.seed.documents.v3alpha.Documents.ListDocuments:input_type -> com.seed.documents.v3alpha.ListDocumentsRequest - 11, // 86: com.seed.documents.v3alpha.Documents.ListRootDocuments:input_type -> com.seed.documents.v3alpha.ListRootDocumentsRequest - 34, // 87: com.seed.documents.v3alpha.Documents.ListDocumentChanges:input_type -> com.seed.documents.v3alpha.ListDocumentChangesRequest - 36, // 88: com.seed.documents.v3alpha.Documents.GetDocumentChange:input_type -> com.seed.documents.v3alpha.GetDocumentChangeRequest - 37, // 89: com.seed.documents.v3alpha.Documents.UpdateDocumentReadStatus:input_type -> com.seed.documents.v3alpha.UpdateDocumentReadStatusRequest - 38, // 90: com.seed.documents.v3alpha.Documents.CreateRef:input_type -> com.seed.documents.v3alpha.CreateRefRequest - 39, // 91: com.seed.documents.v3alpha.Documents.GetRef:input_type -> com.seed.documents.v3alpha.GetRefRequest - 40, // 92: com.seed.documents.v3alpha.Documents.ListRefs:input_type -> com.seed.documents.v3alpha.ListRefsRequest - 47, // 93: com.seed.documents.v3alpha.Documents.GetDocument:output_type -> com.seed.documents.v3alpha.Document - 43, // 94: com.seed.documents.v3alpha.Documents.GetDocumentInfo:output_type -> com.seed.documents.v3alpha.DocumentInfo - 6, // 95: com.seed.documents.v3alpha.Documents.BatchGetDocumentInfo:output_type -> com.seed.documents.v3alpha.BatchGetDocumentInfoResponse - 47, // 96: com.seed.documents.v3alpha.Documents.CreateDocumentChange:output_type -> com.seed.documents.v3alpha.Document - 9, // 97: com.seed.documents.v3alpha.Documents.PrepareChange:output_type -> com.seed.documents.v3alpha.PrepareChangeResponse - 65, // 98: com.seed.documents.v3alpha.Documents.DeleteDocument:output_type -> google.protobuf.Empty - 14, // 99: com.seed.documents.v3alpha.Documents.ListAccounts:output_type -> com.seed.documents.v3alpha.ListAccountsResponse - 19, // 100: com.seed.documents.v3alpha.Documents.GetAccount:output_type -> com.seed.documents.v3alpha.Account - 17, // 101: com.seed.documents.v3alpha.Documents.BatchGetAccounts:output_type -> com.seed.documents.v3alpha.BatchGetAccountsResponse - 19, // 102: com.seed.documents.v3alpha.Documents.UpdateProfile:output_type -> com.seed.documents.v3alpha.Account - 65, // 103: com.seed.documents.v3alpha.Documents.CreateAlias:output_type -> google.protobuf.Empty - 28, // 104: com.seed.documents.v3alpha.Documents.CreateContact:output_type -> com.seed.documents.v3alpha.Contact - 28, // 105: com.seed.documents.v3alpha.Documents.GetContact:output_type -> com.seed.documents.v3alpha.Contact - 28, // 106: com.seed.documents.v3alpha.Documents.UpdateContact:output_type -> com.seed.documents.v3alpha.Contact - 65, // 107: com.seed.documents.v3alpha.Documents.DeleteContact:output_type -> google.protobuf.Empty - 27, // 108: com.seed.documents.v3alpha.Documents.ListContacts:output_type -> com.seed.documents.v3alpha.ListContactsResponse - 31, // 109: com.seed.documents.v3alpha.Documents.ListDirectory:output_type -> com.seed.documents.v3alpha.ListDirectoryResponse - 33, // 110: com.seed.documents.v3alpha.Documents.ListDocuments:output_type -> com.seed.documents.v3alpha.ListDocumentsResponse - 12, // 111: com.seed.documents.v3alpha.Documents.ListRootDocuments:output_type -> com.seed.documents.v3alpha.ListRootDocumentsResponse - 35, // 112: com.seed.documents.v3alpha.Documents.ListDocumentChanges:output_type -> com.seed.documents.v3alpha.ListDocumentChangesResponse - 42, // 113: com.seed.documents.v3alpha.Documents.GetDocumentChange:output_type -> com.seed.documents.v3alpha.DocumentChangeInfo - 65, // 114: com.seed.documents.v3alpha.Documents.UpdateDocumentReadStatus:output_type -> google.protobuf.Empty - 52, // 115: com.seed.documents.v3alpha.Documents.CreateRef:output_type -> com.seed.documents.v3alpha.Ref - 52, // 116: com.seed.documents.v3alpha.Documents.GetRef:output_type -> com.seed.documents.v3alpha.Ref - 41, // 117: com.seed.documents.v3alpha.Documents.ListRefs:output_type -> com.seed.documents.v3alpha.ListRefsResponse - 93, // [93:118] is the sub-list for method output_type - 68, // [68:93] is the sub-list for method input_type - 68, // [68:68] is the sub-list for extension type_name - 68, // [68:68] is the sub-list for extension extendee - 0, // [0:68] is the sub-list for field type_name + 43, // 6: com.seed.documents.v3alpha.ListRootDocumentsResponse.documents:type_name -> com.seed.documents.v3alpha.DocumentInfo + 30, // 7: com.seed.documents.v3alpha.ListAccountsRequest.sort_options:type_name -> com.seed.documents.v3alpha.SortOptions + 19, // 8: com.seed.documents.v3alpha.ListAccountsResponse.accounts:type_name -> com.seed.documents.v3alpha.Account + 54, // 9: com.seed.documents.v3alpha.BatchGetAccountsResponse.accounts:type_name -> com.seed.documents.v3alpha.BatchGetAccountsResponse.AccountsEntry + 55, // 10: com.seed.documents.v3alpha.BatchGetAccountsResponse.errors:type_name -> com.seed.documents.v3alpha.BatchGetAccountsResponse.ErrorsEntry + 20, // 11: com.seed.documents.v3alpha.UpdateProfileRequest.profile:type_name -> com.seed.documents.v3alpha.Profile + 64, // 12: com.seed.documents.v3alpha.Account.metadata:type_name -> google.protobuf.Struct + 45, // 13: com.seed.documents.v3alpha.Account.activity_summary:type_name -> com.seed.documents.v3alpha.ActivitySummary + 20, // 14: com.seed.documents.v3alpha.Account.profile:type_name -> com.seed.documents.v3alpha.Profile + 43, // 15: com.seed.documents.v3alpha.Account.home_document_info:type_name -> com.seed.documents.v3alpha.DocumentInfo + 63, // 16: com.seed.documents.v3alpha.Profile.update_time:type_name -> google.protobuf.Timestamp + 28, // 17: com.seed.documents.v3alpha.UpdateContactRequest.contact:type_name -> com.seed.documents.v3alpha.Contact + 28, // 18: com.seed.documents.v3alpha.ListContactsResponse.contacts:type_name -> com.seed.documents.v3alpha.Contact + 63, // 19: com.seed.documents.v3alpha.Contact.create_time:type_name -> google.protobuf.Timestamp + 63, // 20: com.seed.documents.v3alpha.Contact.update_time:type_name -> google.protobuf.Timestamp + 64, // 21: com.seed.documents.v3alpha.Contact.metadata:type_name -> google.protobuf.Struct + 30, // 22: com.seed.documents.v3alpha.ListDirectoryRequest.sort_options:type_name -> com.seed.documents.v3alpha.SortOptions + 1, // 23: com.seed.documents.v3alpha.SortOptions.attribute:type_name -> com.seed.documents.v3alpha.SortAttribute + 43, // 24: com.seed.documents.v3alpha.ListDirectoryResponse.documents:type_name -> com.seed.documents.v3alpha.DocumentInfo + 43, // 25: com.seed.documents.v3alpha.ListDocumentsResponse.documents:type_name -> com.seed.documents.v3alpha.DocumentInfo + 42, // 26: com.seed.documents.v3alpha.ListDocumentChangesResponse.changes:type_name -> com.seed.documents.v3alpha.DocumentChangeInfo + 53, // 27: com.seed.documents.v3alpha.CreateRefRequest.target:type_name -> com.seed.documents.v3alpha.RefTarget + 63, // 28: com.seed.documents.v3alpha.CreateRefRequest.timestamp:type_name -> google.protobuf.Timestamp + 0, // 29: com.seed.documents.v3alpha.CreateRefRequest.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility + 52, // 30: com.seed.documents.v3alpha.ListRefsResponse.refs:type_name -> com.seed.documents.v3alpha.Ref + 63, // 31: com.seed.documents.v3alpha.DocumentChangeInfo.create_time:type_name -> google.protobuf.Timestamp + 64, // 32: com.seed.documents.v3alpha.DocumentInfo.metadata:type_name -> google.protobuf.Struct + 63, // 33: com.seed.documents.v3alpha.DocumentInfo.create_time:type_name -> google.protobuf.Timestamp + 63, // 34: com.seed.documents.v3alpha.DocumentInfo.update_time:type_name -> google.protobuf.Timestamp + 46, // 35: com.seed.documents.v3alpha.DocumentInfo.breadcrumbs:type_name -> com.seed.documents.v3alpha.Breadcrumb + 45, // 36: com.seed.documents.v3alpha.DocumentInfo.activity_summary:type_name -> com.seed.documents.v3alpha.ActivitySummary + 44, // 37: com.seed.documents.v3alpha.DocumentInfo.generation_info:type_name -> com.seed.documents.v3alpha.GenerationInfo + 61, // 38: com.seed.documents.v3alpha.DocumentInfo.redirect_info:type_name -> com.seed.documents.v3alpha.RefTarget.Redirect + 0, // 39: com.seed.documents.v3alpha.DocumentInfo.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility + 63, // 40: com.seed.documents.v3alpha.ActivitySummary.latest_comment_time:type_name -> google.protobuf.Timestamp + 63, // 41: com.seed.documents.v3alpha.ActivitySummary.latest_change_time:type_name -> google.protobuf.Timestamp + 64, // 42: com.seed.documents.v3alpha.Document.metadata:type_name -> google.protobuf.Struct + 48, // 43: com.seed.documents.v3alpha.Document.content:type_name -> com.seed.documents.v3alpha.BlockNode + 56, // 44: com.seed.documents.v3alpha.Document.detached_blocks:type_name -> com.seed.documents.v3alpha.Document.DetachedBlocksEntry + 63, // 45: com.seed.documents.v3alpha.Document.create_time:type_name -> google.protobuf.Timestamp + 63, // 46: com.seed.documents.v3alpha.Document.update_time:type_name -> google.protobuf.Timestamp + 44, // 47: com.seed.documents.v3alpha.Document.generation_info:type_name -> com.seed.documents.v3alpha.GenerationInfo + 0, // 48: com.seed.documents.v3alpha.Document.visibility:type_name -> com.seed.documents.v3alpha.ResourceVisibility + 49, // 49: com.seed.documents.v3alpha.BlockNode.block:type_name -> com.seed.documents.v3alpha.Block + 48, // 50: com.seed.documents.v3alpha.BlockNode.children:type_name -> com.seed.documents.v3alpha.BlockNode + 64, // 51: com.seed.documents.v3alpha.Block.attributes:type_name -> google.protobuf.Struct + 50, // 52: com.seed.documents.v3alpha.Block.annotations:type_name -> com.seed.documents.v3alpha.Annotation + 64, // 53: com.seed.documents.v3alpha.Annotation.attributes:type_name -> google.protobuf.Struct + 58, // 54: com.seed.documents.v3alpha.DocumentChange.set_metadata:type_name -> com.seed.documents.v3alpha.DocumentChange.SetMetadata + 57, // 55: com.seed.documents.v3alpha.DocumentChange.move_block:type_name -> com.seed.documents.v3alpha.DocumentChange.MoveBlock + 49, // 56: com.seed.documents.v3alpha.DocumentChange.replace_block:type_name -> com.seed.documents.v3alpha.Block + 59, // 57: com.seed.documents.v3alpha.DocumentChange.set_attribute:type_name -> com.seed.documents.v3alpha.DocumentChange.SetAttribute + 53, // 58: com.seed.documents.v3alpha.Ref.target:type_name -> com.seed.documents.v3alpha.RefTarget + 63, // 59: com.seed.documents.v3alpha.Ref.timestamp:type_name -> google.protobuf.Timestamp + 44, // 60: com.seed.documents.v3alpha.Ref.generation_info:type_name -> com.seed.documents.v3alpha.GenerationInfo + 60, // 61: com.seed.documents.v3alpha.RefTarget.version:type_name -> com.seed.documents.v3alpha.RefTarget.Version + 61, // 62: com.seed.documents.v3alpha.RefTarget.redirect:type_name -> com.seed.documents.v3alpha.RefTarget.Redirect + 62, // 63: com.seed.documents.v3alpha.RefTarget.tombstone:type_name -> com.seed.documents.v3alpha.RefTarget.Tombstone + 19, // 64: com.seed.documents.v3alpha.BatchGetAccountsResponse.AccountsEntry.value:type_name -> com.seed.documents.v3alpha.Account + 48, // 65: com.seed.documents.v3alpha.Document.DetachedBlocksEntry.value:type_name -> com.seed.documents.v3alpha.BlockNode + 65, // 66: com.seed.documents.v3alpha.DocumentChange.SetAttribute.null_value:type_name -> google.protobuf.Empty + 2, // 67: com.seed.documents.v3alpha.Documents.GetDocument:input_type -> com.seed.documents.v3alpha.GetDocumentRequest + 4, // 68: com.seed.documents.v3alpha.Documents.GetDocumentInfo:input_type -> com.seed.documents.v3alpha.GetDocumentInfoRequest + 5, // 69: com.seed.documents.v3alpha.Documents.BatchGetDocumentInfo:input_type -> com.seed.documents.v3alpha.BatchGetDocumentInfoRequest + 7, // 70: com.seed.documents.v3alpha.Documents.CreateDocumentChange:input_type -> com.seed.documents.v3alpha.CreateDocumentChangeRequest + 8, // 71: com.seed.documents.v3alpha.Documents.PrepareChange:input_type -> com.seed.documents.v3alpha.PrepareChangeRequest + 10, // 72: com.seed.documents.v3alpha.Documents.DeleteDocument:input_type -> com.seed.documents.v3alpha.DeleteDocumentRequest + 13, // 73: com.seed.documents.v3alpha.Documents.ListAccounts:input_type -> com.seed.documents.v3alpha.ListAccountsRequest + 15, // 74: com.seed.documents.v3alpha.Documents.GetAccount:input_type -> com.seed.documents.v3alpha.GetAccountRequest + 16, // 75: com.seed.documents.v3alpha.Documents.BatchGetAccounts:input_type -> com.seed.documents.v3alpha.BatchGetAccountsRequest + 18, // 76: com.seed.documents.v3alpha.Documents.UpdateProfile:input_type -> com.seed.documents.v3alpha.UpdateProfileRequest + 21, // 77: com.seed.documents.v3alpha.Documents.CreateAlias:input_type -> com.seed.documents.v3alpha.CreateAliasRequest + 22, // 78: com.seed.documents.v3alpha.Documents.CreateContact:input_type -> com.seed.documents.v3alpha.CreateContactRequest + 23, // 79: com.seed.documents.v3alpha.Documents.GetContact:input_type -> com.seed.documents.v3alpha.GetContactRequest + 24, // 80: com.seed.documents.v3alpha.Documents.UpdateContact:input_type -> com.seed.documents.v3alpha.UpdateContactRequest + 25, // 81: com.seed.documents.v3alpha.Documents.DeleteContact:input_type -> com.seed.documents.v3alpha.DeleteContactRequest + 26, // 82: com.seed.documents.v3alpha.Documents.ListContacts:input_type -> com.seed.documents.v3alpha.ListContactsRequest + 29, // 83: com.seed.documents.v3alpha.Documents.ListDirectory:input_type -> com.seed.documents.v3alpha.ListDirectoryRequest + 32, // 84: com.seed.documents.v3alpha.Documents.ListDocuments:input_type -> com.seed.documents.v3alpha.ListDocumentsRequest + 11, // 85: com.seed.documents.v3alpha.Documents.ListRootDocuments:input_type -> com.seed.documents.v3alpha.ListRootDocumentsRequest + 34, // 86: com.seed.documents.v3alpha.Documents.ListDocumentChanges:input_type -> com.seed.documents.v3alpha.ListDocumentChangesRequest + 36, // 87: com.seed.documents.v3alpha.Documents.GetDocumentChange:input_type -> com.seed.documents.v3alpha.GetDocumentChangeRequest + 37, // 88: com.seed.documents.v3alpha.Documents.UpdateDocumentReadStatus:input_type -> com.seed.documents.v3alpha.UpdateDocumentReadStatusRequest + 38, // 89: com.seed.documents.v3alpha.Documents.CreateRef:input_type -> com.seed.documents.v3alpha.CreateRefRequest + 39, // 90: com.seed.documents.v3alpha.Documents.GetRef:input_type -> com.seed.documents.v3alpha.GetRefRequest + 40, // 91: com.seed.documents.v3alpha.Documents.ListRefs:input_type -> com.seed.documents.v3alpha.ListRefsRequest + 47, // 92: com.seed.documents.v3alpha.Documents.GetDocument:output_type -> com.seed.documents.v3alpha.Document + 43, // 93: com.seed.documents.v3alpha.Documents.GetDocumentInfo:output_type -> com.seed.documents.v3alpha.DocumentInfo + 6, // 94: com.seed.documents.v3alpha.Documents.BatchGetDocumentInfo:output_type -> com.seed.documents.v3alpha.BatchGetDocumentInfoResponse + 47, // 95: com.seed.documents.v3alpha.Documents.CreateDocumentChange:output_type -> com.seed.documents.v3alpha.Document + 9, // 96: com.seed.documents.v3alpha.Documents.PrepareChange:output_type -> com.seed.documents.v3alpha.PrepareChangeResponse + 65, // 97: com.seed.documents.v3alpha.Documents.DeleteDocument:output_type -> google.protobuf.Empty + 14, // 98: com.seed.documents.v3alpha.Documents.ListAccounts:output_type -> com.seed.documents.v3alpha.ListAccountsResponse + 19, // 99: com.seed.documents.v3alpha.Documents.GetAccount:output_type -> com.seed.documents.v3alpha.Account + 17, // 100: com.seed.documents.v3alpha.Documents.BatchGetAccounts:output_type -> com.seed.documents.v3alpha.BatchGetAccountsResponse + 19, // 101: com.seed.documents.v3alpha.Documents.UpdateProfile:output_type -> com.seed.documents.v3alpha.Account + 65, // 102: com.seed.documents.v3alpha.Documents.CreateAlias:output_type -> google.protobuf.Empty + 28, // 103: com.seed.documents.v3alpha.Documents.CreateContact:output_type -> com.seed.documents.v3alpha.Contact + 28, // 104: com.seed.documents.v3alpha.Documents.GetContact:output_type -> com.seed.documents.v3alpha.Contact + 28, // 105: com.seed.documents.v3alpha.Documents.UpdateContact:output_type -> com.seed.documents.v3alpha.Contact + 65, // 106: com.seed.documents.v3alpha.Documents.DeleteContact:output_type -> google.protobuf.Empty + 27, // 107: com.seed.documents.v3alpha.Documents.ListContacts:output_type -> com.seed.documents.v3alpha.ListContactsResponse + 31, // 108: com.seed.documents.v3alpha.Documents.ListDirectory:output_type -> com.seed.documents.v3alpha.ListDirectoryResponse + 33, // 109: com.seed.documents.v3alpha.Documents.ListDocuments:output_type -> com.seed.documents.v3alpha.ListDocumentsResponse + 12, // 110: com.seed.documents.v3alpha.Documents.ListRootDocuments:output_type -> com.seed.documents.v3alpha.ListRootDocumentsResponse + 35, // 111: com.seed.documents.v3alpha.Documents.ListDocumentChanges:output_type -> com.seed.documents.v3alpha.ListDocumentChangesResponse + 42, // 112: com.seed.documents.v3alpha.Documents.GetDocumentChange:output_type -> com.seed.documents.v3alpha.DocumentChangeInfo + 65, // 113: com.seed.documents.v3alpha.Documents.UpdateDocumentReadStatus:output_type -> google.protobuf.Empty + 52, // 114: com.seed.documents.v3alpha.Documents.CreateRef:output_type -> com.seed.documents.v3alpha.Ref + 52, // 115: com.seed.documents.v3alpha.Documents.GetRef:output_type -> com.seed.documents.v3alpha.Ref + 41, // 116: com.seed.documents.v3alpha.Documents.ListRefs:output_type -> com.seed.documents.v3alpha.ListRefsResponse + 92, // [92:117] is the sub-list for method output_type + 67, // [67:92] is the sub-list for method input_type + 67, // [67:67] is the sub-list for extension type_name + 67, // [67:67] is the sub-list for extension extendee + 0, // [0:67] is the sub-list for field type_name } func init() { file_documents_v3alpha_documents_proto_init() } diff --git a/backend/genproto/documents/v3alpha/resources.pb.go b/backend/genproto/documents/v3alpha/resources.pb.go index 1149fe71e..62b2c23ae 100644 --- a/backend/genproto/documents/v3alpha/resources.pb.go +++ b/backend/genproto/documents/v3alpha/resources.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.11 // protoc v4.24.4 // source: documents/v3alpha/resources.proto diff --git a/docs/publish-message-decisions.md b/docs/publish-message-decisions.md new file mode 100644 index 000000000..79f8c60ce --- /dev/null +++ b/docs/publish-message-decisions.md @@ -0,0 +1,70 @@ +# Publish Messages — Implementation Decisions + +Design note documenting the decisions behind optional human-readable publish messages on Change blobs. + +## TL;DR + +Every signed Change blob now carries an optional `message` string, analogous to a git commit message. The publish UI, CLI, activity feed, and Versions panel all read the message from the Change. + +## Why the Change blob + +A Change is the unit of "what changed" in a document — like a commit in git. A Ref is a pointer/branch that names a path and points at heads. Tombstone Refs and redirect/republish Refs do not introduce new content; they only re-aim a path. There is no content delta to describe on those Refs, so a `message` field there would have no consistent meaning. + +Putting the message on the Change matches the git mental model the rest of the API already leans on: + +- Change = commit (signed, identified by content, has a message). +- Ref = branch ref (a moving pointer, not a description). + +Only content changes carry a message. Tombstones and redirects do not. + +## Storage shape + +On the Go side, `Message` is a top-level field of `blob.Change`: + +```go +type Change struct { + BaseBlob + Genesis cid.Cid `refmt:"genesis,omitempty"` + Deps []cid.Cid `refmt:"deps,omitempty"` + Depth int `refmt:"depth,omitempty"` + Body ChangeBody `refmt:"body,omitempty"` + Message string `refmt:"message,omitempty"` +} +``` + +Top-level (sibling of `Body`/`Deps`/`Depth`) rather than inside `ChangeBody`. The body is reserved for document operations; metadata about the publish event lives at the same level as `Genesis`, `Deps`, and `Depth`. + +The `omitempty` CBOR tag means changes without a message produce identical bytes to before — backward-compatible by construction. + +For SQL-side queryability, the indexer copies `Message` into `structural_blobs.extra_attrs->>'message'`, alongside `title` and `metadata`. + +## API surface + +Proto schema changes (`proto/documents/v3alpha/documents.proto`): + +- `string message = 9;` on `CreateDocumentChangeRequest`. +- `string message = 7;` on `PrepareChangeRequest` so the daemon can embed the message into unsigned Change bytes for client-side signers. +- `string message = 5;` on `DocumentChangeInfo` so `GetDocumentChange` and `ListDocumentChanges` surface it. + +Client SDK (`frontend/packages/client/src`): + +- `createChangeOps` and `createDocumentChange` accept `message?: string`. When set, it is added to the unsigned Change CBOR top-level. + +## Display surfaces + +- **Activity feed** (`activity-service.ts`, `feed.tsx`): resolves the head change CID from the Ref's version target, calls `GetDocumentChange`, reads `.message`. Rendered as italic text below the document update line. +- **Versions panel**: uses `ListDocumentChanges` / `GetDocumentChange`, which include `message`. +- **Desktop publish UI** (`publish-draft-button.tsx`): textarea labeled "Publish message (optional)" in the publish popover. +- **CLI** (`document.ts`): `--message` / `-m` flag on `document create` and `document update`. + +## Compatibility + +Forward-compatible: clients that don't send `message` produce changes without the field (`omitempty`). Existing data is unaffected. + +## Non-goals + +- **Editing message after publish.** Signed into the Change blob; rewriting changes the CID. +- **Rich text / markdown messages.** Plain text only. +- **AI-generated messages.** Orthogonal — can populate the same field later. +- **Comment threads on versions.** Single annotation, not a discussion system. +- **Diff summaries.** Separate feature; could populate this field automatically. diff --git a/docs/publish-message.md b/docs/publish-message.md new file mode 100644 index 000000000..88b2e2ec4 --- /dev/null +++ b/docs/publish-message.md @@ -0,0 +1,44 @@ +# Publish Messages + +Optional human-readable message attached to each publish, analogous to a git commit message. Stored on the Change blob (signed, tamper-proof, permanent) and surfaced in the desktop publish UI, CLI, activity feed, and document versions panel. + +## Problem + +Version history is opaque. When a user publishes a document, the only information recorded is the timestamp and the account that published. There is no way for the publisher to explain what changed or why. Collaborators browsing the activity feed or document versions panel see a flat list of updates with no context. + +## Solution + +Add an optional `message` string field to the Change blob. The message is set at publish time, included in the signed Change blob, and displayed wherever versions are shown. + +### Storage + +The message lives on the **Change blob** because a Change is the unit of "what changed" in a document — analogous to a git commit. Tombstones and redirects produce only Refs and have no content delta to describe, so they don't carry messages. + +The field uses `omitempty` so existing Changes without a message remain valid. No migration is needed. + +### Stack + +- **Proto**: `string message` field on `CreateDocumentChangeRequest`, `PrepareChangeRequest`, and `DocumentChangeInfo`. +- **Go daemon**: `Change.Message` field; threaded through `NewChange`, `prepareChange`, `SignChange`/`SignChangeAt`/`CreateChange`, `CreateDocumentChange` handler, `PrepareChange` handler, and `DocumentChangeInfo` response builders. Indexed into `extra_attrs` JSON. +- **TypeScript client SDK**: `message` parameter on `createChangeOps` / `createDocumentChange` (embeds into Change CBOR). +- **CLI**: `--message` / `-m` flag on `document create` and `document update` commands, passed into `createChangeOps`. +- **Desktop UI**: textarea in the publish popover (`publish-draft-button.tsx`). Both daemon and seed-client publish paths forward the message. +- **Web client**: `create-web-universal-client.ts` threads message through `PublishDocumentInput` into `PrepareDocumentChange` / `createDocumentChange`. +- **Activity feed**: `loadRefEvent` resolves the head change CID from the Ref's version target, then calls `GetDocumentChange` and reads `.message`. `feed.tsx` renders it as italic text below document-update events. +- **Document Versions panel**: reuses `ListDocumentChanges` / `GetDocumentChange`, which include the message field. + +## Scope + +Small feature. Fully backward compatible because the field is optional with `omitempty`. + +## Rabbit Holes + +- **Rich text messages.** Plain text is sufficient. Rich text adds complexity (rendering, sanitization, storage size) with no clear benefit for a short annotation. +- **Required vs optional.** The message must be optional. Most publishes are quick saves that do not need an explanation. Making it required would add friction to the most common workflow. + +## No Goes + +- **Comment threads on versions.** Out of scope. This is a single annotation, not a discussion system. +- **Diff summaries.** Generating a human-readable diff of what changed in the document is a separate feature. +- **AI-generated messages.** Automatic summarization of changes is interesting but orthogonal. Can be layered on later by populating the same field. +- **Editing after publish.** The message is part of the signed Change blob. Changing it after the fact would require a new Change, which changes the version identity. Not supported. diff --git a/frontend/apps/cli/src/commands/document.ts b/frontend/apps/cli/src/commands/document.ts index f085e48c9..5f1ecd6b4 100644 --- a/frontend/apps/cli/src/commands/document.ts +++ b/frontend/apps/cli/src/commands/document.ts @@ -319,6 +319,7 @@ export function registerDocumentCommands(program: Command) { .option('--grobid-url ', 'GROBID server URL for PDF extraction') .option('--dry-run', 'Preview extracted content without publishing') .option('--force', 'Overwrite existing document at the same path (creates new lineage)') + .option('-m, --message ', 'Publish message (like a git commit message)') .option('-k, --key ', 'Signing key name or account ID') .option('-a, --account ', 'Target space/account UID (publish under a different account using a capability)') .action(async (options, cmd) => { @@ -416,6 +417,7 @@ export function registerDocumentCommands(program: Command) { genesisCid: genesisBlock.cid, deps: [genesisBlock.cid], depth: 1, + message: options.message, }) const changeBlock = await createChange(unsignedBytes, signer) const generation = Number(ts) @@ -491,6 +493,7 @@ export function registerDocumentCommands(program: Command) { .option('--import-tags ', 'Import tags (comma-separated)') .option('--parent ', 'Parent block ID for new content (default: root)') .option('--delete-blocks ', 'Comma-separated block IDs to delete') + .option('-m, --message ', 'Publish message (like a git commit message)') .option('-k, --key ', 'Signing key name or account ID') .action(async (id: string, options, cmd) => { const globalOpts = cmd.optsWithGlobals() @@ -579,7 +582,13 @@ export function registerDocumentCommands(program: Command) { const signer = createSignerFromKey(key) const capability = await resolveCapability(client, docAccount, key.accountId) - const {unsignedBytes, ts} = createChangeOps({ops, genesisCid, deps: depCids, depth: newDepth}) + const {unsignedBytes, ts} = createChangeOps({ + ops, + genesisCid, + deps: depCids, + depth: newDepth, + message: options.message, + }) const changeBlock = await createChange(unsignedBytes, signer) const generation = Number(ts) const refInput = await createVersionRef( diff --git a/frontend/apps/desktop/src/components/__tests__/edit-navigation-popover.test.tsx b/frontend/apps/desktop/src/components/__tests__/edit-navigation-popover.test.tsx index 467d10b75..f97d15588 100644 --- a/frontend/apps/desktop/src/components/__tests__/edit-navigation-popover.test.tsx +++ b/frontend/apps/desktop/src/components/__tests__/edit-navigation-popover.test.tsx @@ -133,7 +133,8 @@ function cleanup(root: Root, container: HTMLDivElement) { function setInputValue(input: HTMLInputElement, value: string) { act(() => { - input.value = value + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! + nativeSetter.call(input, value) input.dispatchEvent(new Event('input', {bubbles: true})) }) } @@ -184,4 +185,94 @@ describe('EditNavPopover trigger', () => { cleanup(root, container) } }) + + it('shows only the document path in the link input for hm links', () => { + const {container, root} = renderPopover([{id: 'nav-1', type: 'Link', text: 'Docs', link: 'hm://alice/docs'}]) + + try { + // The item is not blank so expandedItemId starts null; click the toggle button to open the form. + const toggleButton = Array.from(container.querySelectorAll('button')).find( + (btn) => btn.textContent?.includes('Docs'), + ) as HTMLButtonElement | undefined + act(() => { + toggleButton?.click() + }) + + const linkInput = container.querySelector('input[aria-label="Link"]') as HTMLInputElement | null + expect(linkInput?.value).toBe('/docs') + expect(container.textContent).not.toContain('hm://alice/docs') + } finally { + cleanup(root, container) + } + }) + + it('updates link and label when a document search result is selected', async () => { + useSearchMock.mockImplementation(((query: string) => ({ + data: { + entities: + query === 'Shared' + ? [ + { + id: {uid: 'alice', path: ['shared-meaning']}, + title: 'Shared Meaning', + parentNames: ['Notes'], + icon: null, + searchQuery: query, + versionTime: '', + }, + ] + : [], + }, + })) as any) + + const {container, root} = renderPopover([{id: 'nav-1', type: 'Link', text: '', link: ''}]) + + try { + const linkInput = container.querySelector('input[aria-label="Link"]') as HTMLInputElement + const labelInput = container.querySelector('input[placeholder="My Link…"]') as HTMLInputElement + + await act(async () => { + linkInput.focus() + }) + setInputValue(linkInput, 'Shared') + + const resultButton = container.querySelector('[data-search-result="Shared Meaning"]') as HTMLButtonElement | null + expect(resultButton).not.toBeNull() + + await act(async () => { + resultButton?.click() + }) + + expect(linkInput.value).toBe('/shared-meaning') + expect(labelInput.value).toBe('Shared Meaning') + } finally { + cleanup(root, container) + } + }) + + it('commits pasted web URLs from the link input and updates the label', async () => { + resolveHypermediaUrlMock.mockResolvedValue(null) + + const {container, root} = renderPopover([{id: 'nav-1', type: 'Link', text: '', link: ''}]) + + try { + const linkInput = container.querySelector('input[aria-label="Link"]') as HTMLInputElement + const labelInput = container.querySelector('input[placeholder="My Link…"]') as HTMLInputElement + + await act(async () => { + linkInput.focus() + }) + setInputValue(linkInput, 'https://seed.example/docs') + + await act(async () => { + linkInput.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})) + }) + + expect(resolveHypermediaUrlMock).toHaveBeenCalledWith('https://seed.example/docs') + expect(linkInput.value).toBe('https://seed.example/docs') + expect(labelInput.value).toBe('https://seed.example/docs') + } finally { + cleanup(root, container) + } + }) }) diff --git a/frontend/apps/desktop/src/components/edit-navigation-popover.tsx b/frontend/apps/desktop/src/components/edit-navigation-popover.tsx index a744e338a..01227ed1c 100644 --- a/frontend/apps/desktop/src/components/edit-navigation-popover.tsx +++ b/frontend/apps/desktop/src/components/edit-navigation-popover.tsx @@ -557,14 +557,12 @@ function SearchUI({ results.length > 0 ? ((focusedIndex % results.length) + results.length) % results.length : 0 const prevActiveKeyRef = useRef(null) - useEffect(() => { const activeItem = results[normalizedFocusedIndex] const nextKey = activeItem?.key ?? null - if (nextKey !== prevActiveKeyRef.current) { - prevActiveKeyRef.current = nextKey - onActiveResultChange(activeItem ? {link: activeItem.key, title: activeItem.title || ''} : null) - } + if (nextKey === prevActiveKeyRef.current) return + prevActiveKeyRef.current = nextKey + onActiveResultChange(activeItem ? {link: activeItem.key, title: activeItem.title || ''} : null) }, [normalizedFocusedIndex, onActiveResultChange, results]) return ( diff --git a/frontend/apps/desktop/src/components/publish-draft-button.tsx b/frontend/apps/desktop/src/components/publish-draft-button.tsx index dee116879..8068a32f9 100644 --- a/frontend/apps/desktop/src/components/publish-draft-button.tsx +++ b/frontend/apps/desktop/src/components/publish-draft-button.tsx @@ -97,6 +97,9 @@ export default function PublishDraftButton() { // Inline error shown below the path input when publish fails during first publish const [publishError, setPublishError] = useState(null) + // Optional publish message (like a git commit message) + const [publishMessage, setPublishMessage] = useState('') + // Parent auto-link state for first publish type ParentPublishInfo = { parentId: UnpackedHypermediaId @@ -268,6 +271,7 @@ export default function PublishDraftButton() { draft: draft.data, destinationId, accountId, + message: publishMessage || undefined, }) const resultPath = entityQueryPathToHmIdPath(res.path) @@ -499,6 +503,23 @@ export default function PublishDraftButton() { + {/* Publish message (optional) */} +
+ +