-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathtui_flows.go
More file actions
1005 lines (924 loc) · 33.4 KB
/
Copy pathtui_flows.go
File metadata and controls
1005 lines (924 loc) · 33.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/foliagecp/easyjson"
)
// Per-entity form construction and submission.
//
// Constructors are pure — they read a snapshot of the model and return a
// formState. Submission returns a tea.Cmd that calls exactly one graphOps
// function and reports back as a mutationResultMsg.
// ── Body edit ─────────────────────────────────────────────────────────────────
// entityForVertex maps a classified vertex to the API tier its body edit uses.
// With the low-level toggle on, everything is a raw vertex.
func entityForVertex(k vertexKind, llMode bool) entityKind {
if llMode {
return entVertex
}
switch k {
case vkType:
return entType
case vkObject:
return entObject
default:
return entVertex
}
}
// openBodyEditForm builds the editor for the current vertex's body.
func openBodyEditForm(m tuiModel) (formState, bool) {
if m.fvi == nil || m.fvi.body == nil {
return formState{}, false
}
kind, typeName := m.vertexKind()
entity := entityForVertex(kind, m.llMode)
w, h := m.editorSize()
ed := newJSONEditor(*m.fvi.body, entity, w, h)
f := formState{
kind: formBodyEdit,
title: fmt.Sprintf("Edit %s body — %s", entityLabel(entity), m.currentID),
chrome: chromeCenter,
template: m.bodyRegister,
ctx: formCtx{
fromID: m.currentID,
fromKind: kind,
fromType: typeName,
origBody: *m.fvi.body,
entity: entity,
llMode: m.llMode,
},
fields: []formField{{
key: "body",
label: "body",
kind: fieldJSON,
json: ed,
}},
}
return f.validate(), true
}
// submitFormCmd runs the form's operation off the UI goroutine.
func submitFormCmd(f formState) tea.Cmd {
switch f.kind {
case formBodyEdit:
return submitBodyEditCmd(f)
case formDeleteVertex:
return submitDeleteVertexCmd(f)
case formDeleteLink:
return submitDeleteLinkCmd(f)
case formLinkCreate:
return submitLinkCreateCmd(f)
case formLinkEdit:
return submitLinkEditCmd(f)
case formVertexCreate, formTypeCreate, formObjectCreate, formSubTypeSet:
return submitCreateCmd(f)
}
return nil
}
func submitBodyEditCmd(f formState) tea.Cmd {
body, ok := f.body("body")
if !ok {
return func() tea.Msg {
return mutationResultMsg{
op: "body.update",
target: f.ctx.fromID,
res: opResult{status: opFailed, details: "body is not a JSON object"},
}
}
}
ed := f.fields[0].json
replace := ed != nil && ed.replace
id := f.ctx.fromID
entity := f.ctx.entity
return func() tea.Msg {
var (
res opResult
op string
)
switch entity {
case entType:
op = "type.update"
res = ops.typeUpdate(id, body, replace, false)
case entObject:
op = "object.update"
res = ops.objectUpdate(id, body, replace, "")
default:
op = "vertex.update"
res = ops.vertexUpdate(id, body, replace, false)
}
return mutationResultMsg{
op: op,
target: id,
res: res,
refresh: true,
}
}
}
// ── Pending link ──────────────────────────────────────────────────────────────
// pendingLink is a link-in-progress: the source has been chosen and the user is
// now navigating to the target.
//
// It is deliberately a two-step commit rather than a form that asks for both
// endpoints. Typing a target id defeats the point of having a graph browser —
// the whole reason to be in the TUI is that you find things by walking to them.
// So: Start here, walk anywhere, Commit there.
//
// It is DATA, not a mode: every navigation key keeps working while it is set,
// which is what makes "walk anywhere" true. What makes it discoverable is the
// banner it puts in the breadcrumb row for as long as it is pending.
type pendingLink struct {
fromID string
kind vertexKind
typeName string
}
// ── Delete ────────────────────────────────────────────────────────────────────
// openDeleteVertexForm builds the confirmation for deleting the current vertex.
//
// The tier depends on what the vertex is: deleting a TYPE walks every instance
// and deletes it too, which is a different order of consequence from deleting
// one object, and the confirmation should not pretend otherwise.
func openDeleteVertexForm(m tuiModel) (formState, string) {
id := m.currentID
if builtInVertices[stripDomain(id)] {
return formState{}, "✗ " + id + " is a built-in vertex and cannot be deleted"
}
kind, typeName := m.vertexKind()
if refusal := crudRefusesVertex(m.llMode, kind, id); refusal != "" {
return formState{}, "✗ " + refusal
}
entity := entityForVertex(kind, m.llMode)
f := formState{
kind: formDeleteVertex,
chrome: chromeCenter,
confirm: true,
ctx: formCtx{
fromID: id,
fromKind: kind,
fromType: typeName,
entity: entity,
llMode: m.llMode,
},
}
// The title names WHAT is being deleted, not just which id — the same
// vertex is a "type" through the CMDB API and a bare "vertex" through the
// low-level one, and those remove very different amounts of graph.
switch {
case entity == entType:
f.confirmWord = stripDomain(id)
f.title = "DELETE TYPE " + id + " — this also removes every object of this type."
case entity == entObject:
f.title = "Delete OBJECT " + id + " (of " + typeName + ")?"
case m.llMode && kind != vkPlain:
// Standing on something the CMDB knows about, with the low-level API
// armed. Say so: this removes the vertex and its edges, and leaves the
// CMDB's idea of it behind.
f.title = "Delete VERTEX " + id + " with the LOW-LEVEL API — " +
"the CMDB " + kind.label(typeName) + " record is not cleaned up."
default:
f.title = "Delete vertex " + id + "?"
}
return f, ""
}
// openDeleteLinkForm builds the confirmation for the link under the cursor.
//
// For an INCOMING link the owner is the other vertex, not the one on screen —
// linkId.from is already the owner, and the prompt spells the direction out so
// the user is not surprised about which side loses the edge.
// openDeleteLinkForm confirms deleting an edge, at a weight that matches what
// the deletion actually does.
//
// Deleting a schema link is not deleting one edge. The CMDB cascade walks every
// instance of the source type and removes the corresponding edge from each —
// which is precisely why removing one with the raw API is a corruption: the
// declaration disappears and every instance keeps a link the schema no longer
// permits. The TUI used to do exactly that while `cmdb typeslink delete`
// cascaded correctly, so the same action meant two different things depending
// on where you ran it. Now it cascades here too, behind the same typed-name
// confirmation a type delete demands.
func openDeleteLinkForm(m tuiModel, dl displayLink) formState {
owner, target := linkEndpoints(dl)
kind, _ := m.vertexKind()
tier := m.tierOfSubjectLink(dl)
f := formState{
kind: formDeleteLink,
chrome: chromeCenter,
confirm: true,
ctx: formCtx{
fromID: owner,
toID: target,
fromKind: kind,
linkName: dl.info.id.name,
linkType: dl.info.tp,
tier: tier,
llMode: m.llMode,
},
}
arrow := " —" + orDash(dl.info.tp) + "▶ "
switch tier {
case tierTypesLink:
f.confirmWord = stripDomain(owner)
f.title = "DELETE TYPES-LINK " + stripDomain(owner) + arrow + stripDomain(target) +
" — this also removes that link from every object of " + stripDomain(owner) + "."
case tierSubType:
f.confirmWord = stripDomain(target)
f.title = "REMOVE SUB-TYPE " + stripDomain(target) + " from " + stripDomain(owner) +
" — every object of " + stripDomain(target) + " loses what it inherited."
default:
f.title = "Delete " + tier.noun() + " " + stripDomain(owner) + arrow + stripDomain(target) + "?"
}
return f
}
func submitDeleteVertexCmd(f formState) tea.Cmd {
id := f.ctx.fromID
entity := f.ctx.entity
return func() tea.Msg {
var (
res opResult
op string
)
switch entity {
case entType:
op = "type.delete"
res = ops.typeDelete(id)
case entObject:
op = "object.delete"
res = ops.objectDelete(id)
default:
op = "vertex.delete"
res = ops.vertexDelete(id)
}
return mutationResultMsg{
op: op,
target: id,
res: res,
clearLinkIf: id,
}
}
}
func submitDeleteLinkCmd(f formState) tea.Cmd {
owner, target := f.ctx.fromID, f.ctx.toID
name := f.ctx.linkName
tier := f.ctx.tier
fromClaim, toClaim, _, isSuper := parseSuperLinkType(f.ctx.linkType)
return func() tea.Msg {
msg := mutationResultMsg{
target: stripDomain(owner) + " ──▶ " + stripDomain(target),
refresh: true,
}
switch {
case tier == tierTypesLink:
msg.op, msg.res = "typeslink.delete", ops.typesLinkDelete(owner, target)
case tier == tierSubType:
msg.op, msg.res = "type.subtype.rm", ops.subTypeRemove(owner, target)
case tier == tierSuperLink && isSuper:
msg.op, msg.res = "objectslink.super.delete",
ops.superLinkDelete(owner, target, fromClaim, toClaim)
case tier == tierObjectsLink:
msg.op, msg.res = "objectslink.delete", ops.objectsLinkDelete(owner, target)
default:
msg.op, msg.res = "link.delete", ops.linkDelete(owner, name)
}
return msg
}
}
// ── Link creation ─────────────────────────────────────────────────────────────
// openLinkCreateForm builds the link form from the anchor to the current
// vertex. The chosen API tier is in the title so the user can always see which
// one is about to run.
func openLinkCreateForm(m tuiModel) (formState, string) {
if m.linking == nil {
return formState{}, "press L on a source vertex first"
}
if m.linking.fromID == m.currentID {
return formState{}, "the source and the target are the same vertex"
}
toKind, toType := m.vertexKind()
tier := linkTierFor(m.linking.kind, toKind, m.llMode)
f := formState{
kind: formLinkCreate,
title: tier.title(),
chrome: chromeCenter,
ctx: formCtx{
fromID: m.linking.fromID,
toID: m.currentID,
fromKind: m.linking.kind,
toKind: toKind,
fromType: m.linking.typeName,
toType: toType,
entity: entLink,
llMode: m.llMode,
},
}
fields := []formField{{
key: "ends",
label: "endpoints",
kind: fieldVertex,
}}
switch tier {
case tierTypesLink:
f.ctx.entity = entTypesLink
// Two types can be related in two entirely different ways, and the
// form used to silently assume one of them. A types-link is a SCHEMA
// declaration — it permits object-links between instances; a sub-type
// is INHERITANCE. Nothing about walking from one type to another says
// which the user meant, so the form asks.
fields = append(fields, formField{
key: "relation", label: "relation", kind: fieldEnum, required: true,
options: []enumOption{
{label: "types-link", value: relTypesLink,
hint: "objects of these types may then be linked"},
{label: "sub-type", value: relSubType,
hint: "the target inherits from " + stripDomain(m.linking.fromID)},
},
}, formField{
key: "olt", label: "object link type", kind: fieldID, required: true,
// Naming it after the target type is the convention the server
// itself falls back to for link names.
value: stripDomain(m.currentID),
hint: "the link type that object-links between instances will get",
showIfKey: "relation", showIfVal: relTypesLink,
})
case tierObjectsLink:
fields = append(fields, formField{
key: "name", label: "name", kind: fieldID,
value: stripDomain(m.currentID),
hint: "blank lets the server name it after the target",
}, formField{
key: "ltype", label: "link type", kind: fieldStatic,
static: "(derived from the types-link — checked on submit)",
},
// The claims. An object-link is governed by the types-link between
// the endpoints' types; naming a SUPER-type of either instead lets
// the link be governed by a schema declared further up. That is the
// whole of "supertype links", and the user never has to hear the
// term: they see "link these two, treating srv-1 as machine", and
// leaving both rows alone is the ordinary case.
formField{
key: "fromclaim", label: "from as", kind: fieldID,
value: f.ctx.fromType,
hint: "a super-type of " + orDash(f.ctx.fromType) + " to link under, if not itself",
}, formField{
key: "toclaim", label: "to as", kind: fieldID,
value: toType,
hint: "a super-type of " + orDash(toType) + " to link under, if not itself",
})
default:
fields = append(fields, formField{
key: "name", label: "name", kind: fieldID, required: true,
value: stripDomain(m.currentID),
}, formField{
key: "type", label: "type", kind: fieldID, required: true,
value: lastLinkTypeOn(m, m.linking.fromID),
}, formField{
key: "force", label: "force", kind: fieldBool,
hint: "overwrite an existing link, bypassing the uniqueness checks",
})
}
// A sub-type declaration carries neither; showing them would invite input
// the operation has nowhere to put.
tagsAndBody := []formField{
{key: "tags", label: "tags", kind: fieldTags},
{key: "body", label: "body", kind: fieldText, hint: "inline JSON, or leave blank"},
}
if tier == tierTypesLink {
for i := range tagsAndBody {
tagsAndBody[i].showIfKey, tagsAndBody[i].showIfVal = "relation", relTypesLink
}
}
fields = append(fields, tagsAndBody...)
f.fields = fields
f.cur = 1 // the endpoints row is informational; start on the first input
// Local pre-checks against what we already know, so the user is told about
// a clash before a round trip rather than by an opaque server error.
f = f.validate()
return f, ""
}
// lastLinkTypeOn guesses a link type from what the source vertex already uses,
// which is right far more often than an empty field.
func lastLinkTypeOn(m tuiModel, from string) string {
for _, dl := range m.links {
if dl.isOut && dl.info.id.from == from && dl.info.tp != "" &&
!strings.HasPrefix(dl.info.tp, "__") {
return dl.info.tp
}
}
return ""
}
// The two ways two types can be related.
const (
relTypesLink = "types-link"
relSubType = "sub-type"
)
func submitLinkCreateCmd(f formState) tea.Cmd {
from, to := f.ctx.fromID, f.ctx.toID
tags := f.tags("tags")
name := f.value("name")
linkType := f.value("type")
olt := f.value("olt")
force := f.boolean("force")
body, bodyErr := parseInlineBody(f.value("body"))
// The tier is settled when the form opens; recomputing it from the entity
// here is what left `case entObject` unreachable and hid the object-object
// case inside a nested condition in the default arm.
tier := linkTierFor(f.ctx.fromKind, f.ctx.toKind, f.ctx.llMode)
// A claim that names something other than the endpoint's own type means
// the link is governed by a schema declared further up the hierarchy.
fromClaim, toClaim := f.value("fromclaim"), f.value("toclaim")
claimed := tier == tierObjectsLink &&
((fromClaim != "" && fromClaim != f.ctx.fromType) ||
(toClaim != "" && toClaim != f.ctx.toType))
subType := f.value("relation") == relSubType
return func() tea.Msg {
if bodyErr != "" {
return mutationResultMsg{
op: "link.create", target: stripDomain(from) + " → " + stripDomain(to),
res: opResult{status: opFailed, details: bodyErr},
}
}
var (
res opResult
op string
)
switch {
case tier == tierTypesLink && subType:
op = "type.subtype.add"
res = ops.subTypeSet(from, to)
case tier == tierTypesLink:
op = "typeslink.create"
res = ops.typesLinkCreate(from, to, olt, tags, body)
case claimed:
op = "objectslink.super.create"
res = ops.superLinkCreate(from, to, orElse(fromClaim, f.ctx.fromType),
orElse(toClaim, f.ctx.toType), name, tags, body)
case tier == tierObjectsLink:
op = "objectslink.create"
res = ops.objectsLinkCreate(from, to, name, tags, body)
default:
op = "link.create"
res = ops.linkCreate(from, to, name, linkType, tags, body, force)
}
return mutationResultMsg{
op: op,
target: stripDomain(from) + " → " + stripDomain(to),
res: res,
refresh: true,
// The link exists; the banner has been promising this commit
// across every keystroke since it appeared, so it comes down.
clearPending: true,
}
}
}
// parseInlineBody turns a one-line JSON field into a body. Blank means an empty
// object, which is what every link create used to send unconditionally.
func parseInlineBody(s string) (easyjson.JSON, string) {
s = strings.TrimSpace(s)
if s == "" {
return easyjson.NewJSONObject(), ""
}
j, ok := easyjson.JSONFromString(s)
if !ok || !j.IsObject() {
return easyjson.NewJSONObject(), "body is not a JSON object: " + s
}
return j, ""
}
func orElse(s, fallback string) string {
if s != "" {
return s
}
return fallback
}
// ── Create menu ───────────────────────────────────────────────────────────────
// The governing rule for creation: YOU CREATE A THING WHERE THAT THING LIVES.
//
// Types are created from the `types` root, objects from their own type, links
// from one of their endpoints. This is not a stylistic choice — it is what
// makes the TUI's promise hold. The TUI finds things by walking the graph, so
// anything created outside its home would be unreachable by the very tool that
// made it. Gating creation on location means a new entity is always attached
// to something you can already see.
//
// Where an action is not available, the menu still SHOWS it, says where it
// does live, and pressing it takes you there. A menu that silently omits
// options teaches nothing; one that explains teaches the data model.
type menuEntry struct {
key string
label string
// unavailableAt, when set, is where this action does live. The entry is
// dimmed and choosing it navigates there instead of acting.
unavailableAt string
why string
kind formKind
}
func (e menuEntry) available() bool { return e.unavailableAt == "" && e.why == "" }
// createMenuFor lists what can be created from where the user is standing.
func createMenuFor(m tuiModel) []menuEntry {
kind, typeName := m.vertexKind()
bare := stripDomain(m.currentID)
typesRoot := hubID("types")
linkLabel := "link — start here, walk to the target, press L again"
if kind == vkType {
linkLabel = "types-link or sub-type — start here, walk to the other type, press L again"
}
if m.linking != nil {
linkLabel = "link — commit: " + stripDomain(m.linking.fromID) + " ──▶ " + bare
}
var out []menuEntry
// High-level entities need the high-level API. A create menu that offered
// "type" while the status bar said CRUD: low-level would be describing an
// operation that is not the one about to run.
if m.llMode {
return []menuEntry{
{key: "l", label: linkLabel, kind: formLinkCreate},
{key: "v", label: "raw vertex — linked from " + bare, kind: formVertexCreate},
{key: "t", label: "type", why: "types are a high-level entity" + switchHint,
kind: formTypeCreate},
{key: "o", label: "object", why: "objects are a high-level entity" + switchHint,
kind: formObjectCreate},
}
}
// Types live under the types root.
if bare == "types" {
out = append(out, menuEntry{key: "t", label: "type", kind: formTypeCreate})
} else {
out = append(out, menuEntry{
key: "t", label: "type",
unavailableAt: typesRoot,
why: "types are created from " + typesRoot,
kind: formTypeCreate,
})
}
// Objects live under their type.
switch {
case kind == vkType:
out = append(out, menuEntry{key: "o", label: "object of " + bare, kind: formObjectCreate})
case kind == vkObject && typeName != "":
out = append(out, menuEntry{
key: "o", label: "object of " + typeName,
unavailableAt: canonID(typeName),
why: "objects are created from their type",
kind: formObjectCreate,
})
default:
out = append(out, menuEntry{
key: "o", label: "object",
unavailableAt: typesRoot,
why: "objects are created from their type — pick one under " + typesRoot,
kind: formObjectCreate,
})
}
// Links live on their endpoints, so they can always be started.
out = append(out, menuEntry{key: "l", label: linkLabel, kind: formLinkCreate})
// A raw vertex is a low-level entity, so it needs the low-level API — the
// same rule that stops a type being created in low-level mode, applied in
// the other direction. When it IS available it must be attached to the
// vertex it is created from, otherwise nothing in the graph points at it
// and the browser can never reach it again.
out = append(out, menuEntry{
key: "v", label: "raw vertex",
why: "a raw vertex is a low-level entity" + switchHint,
kind: formVertexCreate,
})
return out
}
func openCreateMenu(m tuiModel) formState {
entries := createMenuFor(m)
fields := make([]formField, len(entries))
for i, e := range entries {
label := e.label
if !e.available() {
label += " ⟶ " + e.why
}
fields[i] = formField{key: e.key, label: e.key, kind: fieldStatic, static: label}
}
return formState{
kind: formCreateMenu,
title: "New… (from " + stripDomain(m.currentID) + ")",
chrome: chromeCenter,
fields: fields,
}
}
// ── Vertex / type / object / sub-type creation ────────────────────────────────
// openVertexCreateForm builds the raw-vertex form.
//
// The link back to the current vertex is NOT optional and has no toggle: a raw
// vertex nothing points at is invisible to a graph browser the moment you
// navigate away. Creating one would be handing the user a lost object.
func openVertexCreateForm(m tuiModel) formState {
dom := domainOf(m.currentID)
f := formState{
kind: formVertexCreate,
title: "New raw vertex, linked from " + stripDomain(m.currentID),
chrome: chromeCenter,
ctx: formCtx{fromID: m.currentID, entity: entVertex, domain: dom},
fields: []formField{
{key: "id", label: "id", kind: fieldID, required: true,
hint: "will create " + dom + "/<id>"},
{key: "linkname", label: "link name", kind: fieldID, required: true,
hint: "the link " + stripDomain(m.currentID) + " ──▶ <id> that keeps it reachable"},
{key: "linktype", label: "link type", kind: fieldID, required: true,
value: lastLinkTypeOn(m, m.currentID)},
},
}
return f.validate()
}
func openTypeCreateForm(m tuiModel) formState {
f := formState{
kind: formTypeCreate,
title: "New type",
chrome: chromeCenter,
ctx: formCtx{entity: entType},
fields: []formField{
{key: "name", label: "name", kind: fieldID, required: true,
// Type operations are redirected to the hub wherever the user
// is browsing, so the preview must not promise a local domain.
hint: "will create " + NatsHubDomain + "/<name>, linked under " +
NatsHubDomain + "/types"},
},
}
return f.validate()
}
// openObjectCreateForm is only reachable while standing on a type, so the type
// is settled and shown read-only rather than offered as a field to mistype.
func openObjectCreateForm(m tuiModel) formState {
dom := domainOf(m.currentID)
f := formState{
kind: formObjectCreate,
title: "New object of " + stripDomain(m.currentID),
chrome: chromeCenter,
ctx: formCtx{fromID: m.currentID, entity: entObject, domain: dom},
fields: []formField{
{key: "id", label: "id", kind: fieldID, required: true,
hint: "will create " + dom + "/<id>"},
// Shown bare because that is how the user thinks of it; the
// canonical form the cache is keyed by comes from ctx.fromID.
{key: "type", label: "type", kind: fieldStatic, static: stripDomain(m.currentID)},
},
}
return f.validate()
}
// openSubTypeForm declares another type a sub-type of the one in view.
func openSubTypeForm(m tuiModel) formState {
f := formState{
kind: formSubTypeSet,
title: "Declare a sub-type of " + stripDomain(m.currentID),
chrome: chromeCenter,
ctx: formCtx{fromID: m.currentID, entity: entType},
fields: []formField{
{key: "child", label: "child type", kind: fieldID, required: true,
hint: "an existing type that inherits from " + stripDomain(m.currentID)},
},
}
return f.validate()
}
func submitCreateCmd(f formState) tea.Cmd {
// Ids are canonicalised before they enter the model. The wire call still
// gets the bare name — the server resolves it — but `navTo` has to name
// the vertex the way everything else does, or landing on what was just
// created would be a second entry for the same thing in the history and
// the cursor file.
dom := f.ctx.domain
if dom == "" {
dom = NatsHubDomain
}
switch f.kind {
case formVertexCreate:
id := f.value("id")
canon := canonIDIn(id, dom)
from := f.ctx.fromID
linkName, linkType := f.value("linkname"), f.value("linktype")
return func() tea.Msg {
res := ops.vertexCreate(id, easyjson.NewJSONObject())
if res.status == opFailed {
return mutationResultMsg{op: "vertex.create", target: id, res: res}
}
// The vertex exists but is unreachable until this lands, so a
// failure here is reported as the failure of the whole operation
// rather than as a successful create.
linkRes := ops.linkCreate(from, id, linkName, linkType, nil, easyjson.NewJSONObject(), false)
if linkRes.status == opFailed {
linkRes.details = "vertex created but linking it failed: " + linkRes.details
return mutationResultMsg{
op: "vertex.create", target: id, res: linkRes,
refresh: true,
}
}
return mutationResultMsg{
op: "vertex.create", target: id, res: res,
navTo: canon, // land on what was just made
}
}
case formTypeCreate:
name := f.value("name")
// Type operations are redirected to the hub wherever the user browses,
// so a type is always a hub vertex regardless of where it was created.
canon := canonID(name)
return func() tea.Msg {
res := ops.typeCreate(name, easyjson.NewJSONObject())
msg := mutationResultMsg{
op: "type.create", target: name, res: res,
}
if res.status != opFailed {
msg.navTo = canon
} else {
msg.refresh = true
}
return msg
}
case formObjectCreate:
id, tp := f.value("id"), f.value("type")
canon := canonIDIn(id, dom)
return func() tea.Msg {
res := ops.objectCreate(id, tp, easyjson.NewJSONObject())
msg := mutationResultMsg{
op: "object.create", target: id, res: res,
}
if res.status != opFailed {
msg.navTo = canon
} else {
msg.refresh = true
}
return msg
}
case formSubTypeSet:
base, child := f.ctx.fromID, f.value("child")
return func() tea.Msg {
return mutationResultMsg{
op: "type.subtype.add", target: stripDomain(base) + " → " + child,
res: ops.subTypeSet(base, child),
// The inheritance recompute rewrites cached parent lists on
// arbitrary descendants, so nothing cached can be trusted.
refresh: true,
}
}
}
return nil
}
// ── Link edit ─────────────────────────────────────────────────────────────────
// entityForTier picks which body paths are machine-owned on an edge. This is
// what finally makes entTypesLink reachable: a types-link's `body.type` is the
// object-link type its instances inherit, so a REPLACE that dropped it would
// silently rewrite the schema. The protection was written long ago and no code
// path ever produced the entity that switches it on.
func entityForTier(t linkTier) entityKind {
if t == tierTypesLink {
return entTypesLink
}
return entLink
}
// openLinkEditForm edits an existing edge: its tags and its body, together.
//
// Together, because the server applies `replace` to BOTH with a single flag —
// two controls for one flag is a lie, and the previous tags-only form told it
// by hardcoding an empty body, so ticking `replace` to clear tags also wiped
// the body nobody could see.
//
// The detail must already be loaded. Seeding from the model is what produced a
// blank tags field over a link that had tags.
func openLinkEditForm(m tuiModel, dl displayLink, focusKey string) (formState, string) {
detail, loaded := m.linkDetailFor(dl)
if !loaded {
return formState{}, "reading the link…"
}
if detail.err != nil {
return formState{}, "✗ cannot read link: " + detail.err.Error()
}
owner, target := linkEndpoints(dl)
tier := m.tierOfSubjectLink(dl)
entity := entityForTier(tier)
w, h := m.editorSizeFor(len(linkEditContextRows(dl, tier)) + 3)
ed := newJSONEditor(detail.body, entity, w, h)
f := formState{
kind: formLinkEdit,
title: "Edit " + tier.noun() + " — " + stripDomain(owner) + " ──▶ " + stripDomain(target),
chrome: chromeCenter,
contextRows: linkEditContextRows(dl, tier),
template: m.bodyRegister,
ctx: formCtx{
fromID: owner,
toID: target,
linkName: dl.info.id.name,
linkType: dl.info.tp,
tier: tier,
entity: entity,
origBody: detail.body,
llMode: m.llMode,
},
fields: []formField{
{key: "tags", label: "tags", kind: fieldTags,
value: strings.Join(detail.tags, ", "),
hint: "space or comma separated"},
{key: "body", label: "body", kind: fieldJSON, json: ed},
},
}
if focusKey == "tags" {
f.cur = 0
} else {
f.cur = 1
}
return f.validate(), ""
}
// linkEditContextRows are the facts that identify the edge but cannot be
// changed: the API addresses a link BY its owner and name, and moving an
// endpoint is a delete plus a create, not an update. They are shown rather
// than omitted because without them the form does not say which of several
// same-named edges it is about to write.
func linkEditContextRows(dl displayLink, tier linkTier) []string {
owner, target := linkEndpoints(dl)
rows := []string{
"endpoints " + stripDomain(owner) + " ──▶ " + stripDomain(target),
"name " + dl.info.id.name,
"type " + orDash(dl.info.tp),
"via " + tier.noun(),
}
if from, to, rel, ok := parseSuperLinkType(dl.info.tp); ok {
rows = append(rows, "as "+from+" ──▶ "+to+" rel "+rel)
}
return rows
}
func submitLinkEditCmd(f formState) tea.Cmd {
owner, target := f.ctx.fromID, f.ctx.toID
name := f.ctx.linkName
tags := f.tags("tags")
body := bodyOrEmpty(f, "body")
ed := f.jsonField()
replace := ed != nil && ed.replace
tier := f.ctx.tier
fromClaim, toClaim, _, isSuper := parseSuperLinkType(f.ctx.linkType)
return func() tea.Msg {
var (
res opResult
op string
)
switch {
case tier == tierTypesLink:
op = "typeslink.update"
res = ops.typesLinkUpdate(owner, target, tags, body, replace)
case tier == tierSuperLink && isSuper:
op = "objectslink.super.update"
res = ops.superLinkUpdate(owner, target, fromClaim, toClaim, name, tags, body, replace)
case tier == tierObjectsLink:
op = "objectslink.update"
res = ops.objectsLinkUpdate(owner, target, tags, body, replace)
default:
op = "link.update"
res = ops.linkUpdate(owner, name, tags, body, replace)
}
return mutationResultMsg{
op: op, target: stripDomain(owner) + " ──▶ " + stripDomain(target),
res: res, refresh: true,
}
}
}
// ── Toast rendering ───────────────────────────────────────────────────────────
// toastFor renders the outcome of a mutation for the status line. Applied and
// no-op are visually distinct on purpose: the client reports nil for both, and
// showing success either way would misinform.
func toastFor(msg mutationResultMsg) string {
switch msg.res.status {
case opApplied:
return styleOk.Render("✓ " + msg.op + " " + stripDomain(msg.target))
case opNoop:
detail := msg.res.details
if detail == "" {
detail = "already in that state"
}
return styleDim.Render("∅ " + msg.op + " " + stripDomain(msg.target) + " — " + detail)
default:
return styleErr.Render("✗ " + msg.op + " " + msg.res.details)
}
}
// bodyOrEmpty is a small helper for flows that may have no body field.
func bodyOrEmpty(f formState, key string) easyjson.JSON {
if b, ok := f.body(key); ok {
return b
}
return easyjson.NewJSONObject()
}
// destinationAfterDelete decides where to stand once the current vertex is
// gone, and returns the history to keep.
//
// It goes to the deleted entity's HOME, which is the mirror of the rule that
// governs creation: you create a thing where that thing lives, so when it stops
// existing you are left where it lived. An object goes up to its type, a type
// up to the types root. That is a place that certainly still exists and that
// explains the deletion — landing there, the object is visibly no longer in the
// list.
//
// Popping the history instead — which is what this used to do — makes the
// destination depend on how the user happened to arrive. Walk to an object from
// a JPGQL result and you would be thrown back to the previous unrelated vertex;
// arrive by `:` with no history at all and you would be thrown to root.
//
// A plain vertex has no home, so for those the history is still the best guess.
func destinationAfterDelete(ctx formCtx, history []string) (string, []string) {
switch {
case ctx.entity == entType:
return hubID("types"), history
case ctx.entity == entObject && ctx.fromType != "":
return canonID(ctx.fromType), history
}