diff --git a/.changes/unreleased/Changed-20260614-235443.yaml b/.changes/unreleased/Changed-20260614-235443.yaml new file mode 100644 index 0000000..84d63b8 --- /dev/null +++ b/.changes/unreleased/Changed-20260614-235443.yaml @@ -0,0 +1,5 @@ +kind: Changed +body: (TUI) Navigation uses nested entities with tree +time: 2026-06-14T23:54:43.959238986-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Fixed-20260615-203149.yaml b/.changes/unreleased/Fixed-20260615-203149.yaml new file mode 100644 index 0000000..cb8fbdd --- /dev/null +++ b/.changes/unreleased/Fixed-20260615-203149.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: (TUI) User can select entities in scry results list +time: 2026-06-15T20:31:49.904001962-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Fixed-20260616-030351.yaml b/.changes/unreleased/Fixed-20260616-030351.yaml new file mode 100644 index 0000000..a450308 --- /dev/null +++ b/.changes/unreleased/Fixed-20260616-030351.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: Storage errors (DB unavailable, context cancelled) no longer surface as "entity not found"; they are now propagated with their original cause +time: 2026-06-16T03:03:51Z +custom: + Issue: "" diff --git a/CONTEXT.md b/CONTEXT.md index 06d9fca..e7be191 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,6 +88,10 @@ The normalized form of a name used for matching. Rules (from `CanonicalizeString **Do not confuse `CanonicalName` with `FullPathCanonical`.** Use `FullPathDisplay` when checking depth or path structure in `EntityResult`. +### PathResolution + +The act of translating a user-supplied colon-separated path string into an `entity_id` by canonical-name lookup. PathResolution is a CLI/UI concern — the app layer's `LookupEntityByPath` method is the single authoritative entry point. Internal mutations (rename, reparent, remove, status change, tag, history) accept only `entity_id`; CLI commands that receive a path from the user perform PathResolution before constructing a request. Web and TUI callers that already hold an `entity_id` bypass PathResolution entirely. + ### Scry The `scry` command searches for entities by name (fuzzy, using Levenshtein distance). It is a **search/find** command — not an inference engine for missing items. Results are `FindResult` values with `Entity` and `Distance` fields. diff --git a/cmd/add/add_test.go b/cmd/add/add_test.go index 8d09f87..612d0b5 100644 --- a/cmd/add/add_test.go +++ b/cmd/add/add_test.go @@ -63,7 +63,7 @@ func TestRunAdd_LockedFlag(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) require.NoError(t, cmd.Execute()) - result, err := a.GetEntityByPath(ctx, "Garage") + result, err := a.LookupEntityByPath(ctx, "Garage") require.NoError(t, err) assert.True(t, result.Locked) assert.False(t, result.Discrete) @@ -79,7 +79,7 @@ func TestRunAdd_DiscreteFlag(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) require.NoError(t, cmd.Execute()) - result, err := a.GetEntityByPath(ctx, "Box of Nails") + result, err := a.LookupEntityByPath(ctx, "Box of Nails") require.NoError(t, err) assert.False(t, result.Locked) assert.True(t, result.Discrete) diff --git a/cmd/borrow/borrow_test.go b/cmd/borrow/borrow_test.go index 8c5bb64..f5b31e4 100644 --- a/cmd/borrow/borrow_test.go +++ b/cmd/borrow/borrow_test.go @@ -109,8 +109,10 @@ func TestRunBorrow_BlocksLost(t *testing.T) { }) require.NoError(t, err) + drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill") + require.NoError(t, err) _, err = a.MarkLost(t.Context(), []app.ChangeStatusRequest{ - {EntityPath: "Shelf:Drill", Status: inventory.EntityStatusMissing, ActorID: "test"}, + {EntityID: drill.EntityID, Status: inventory.EntityStatusMissing, ActorID: "test"}, }) assert.ErrorContains(t, err, "borrowed") } @@ -124,8 +126,10 @@ func TestRunBorrow_BlocksFound(t *testing.T) { }) require.NoError(t, err) + drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill") + require.NoError(t, err) _, err = a.MarkFound(t.Context(), []app.ChangeStatusRequest{ - {EntityPath: "Shelf:Drill", Status: inventory.EntityStatusOk, ActorID: "test"}, + {EntityID: drill.EntityID, Status: inventory.EntityStatusOk, ActorID: "test"}, }) assert.ErrorContains(t, err, "borrowed") } @@ -139,8 +143,10 @@ func TestRunBorrow_BlocksLoan(t *testing.T) { }) require.NoError(t, err) + drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill") + require.NoError(t, err) _, err = a.MarkLoaned(t.Context(), []app.ChangeStatusRequest{ - {EntityPath: "Shelf:Drill", Status: inventory.EntityStatusLoaned, ActorID: "test"}, + {EntityID: drill.EntityID, Status: inventory.EntityStatusLoaned, ActorID: "test"}, }) assert.ErrorContains(t, err, "borrowed") } @@ -154,8 +160,10 @@ func TestRunBorrow_BlocksRemove(t *testing.T) { }) require.NoError(t, err) + drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill") + require.NoError(t, err) err = a.RemoveEntity(t.Context(), app.RemoveEntityRequest{ - EntityPath: "Shelf:Drill", ActorID: "test", + EntityID: drill.EntityID, ActorID: "test", }) assert.ErrorContains(t, err, "borrowed") } @@ -169,8 +177,10 @@ func TestRunBorrow_ReturnSetsRemoved(t *testing.T) { }) require.NoError(t, err) + drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill") + require.NoError(t, err) _, err = a.MarkReturned(t.Context(), []app.ChangeStatusRequest{ - {EntityPath: "Shelf:Drill", ActorID: "test"}, + {EntityID: drill.EntityID, ActorID: "test"}, }) require.NoError(t, err) diff --git a/cmd/found/found.go b/cmd/found/found.go index de291df..41a116f 100644 --- a/cmd/found/found.go +++ b/cmd/found/found.go @@ -55,11 +55,15 @@ func runFound(cmd *cobra.Command, args []string, a *app.App) error { reqs := make([]app.ChangeStatusRequest, len(args)) for i, path := range args { + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } reqs[i] = app.ChangeStatusRequest{ - EntityPath: path, - Status: inventory.EntityStatusOk, - Note: noteFlag, - ActorID: cli.GetActorUserID(ctx), + EntityID: entity.EntityID, + Status: inventory.EntityStatusOk, + Note: noteFlag, + ActorID: cli.GetActorUserID(ctx), } } diff --git a/cmd/found/found_test.go b/cmd/found/found_test.go index 0d9d573..e3501c0 100644 --- a/cmd/found/found_test.go +++ b/cmd/found/found_test.go @@ -32,9 +32,13 @@ func seedForFound(t *testing.T, a *app.App) { require.NoError(t, err) } // Mark both as missing so found has something to recover - _, err := a.MarkLost(ctx, []app.ChangeStatusRequest{ - {EntityPath: "Garage:Toolbox:Wrench", Status: inventory.EntityStatusMissing, ActorID: "test"}, - {EntityPath: "Garage:Toolbox:Hammer", Status: inventory.EntityStatusMissing, ActorID: "test"}, + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) + hammer, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Hammer") + require.NoError(t, err) + _, err = a.MarkLost(ctx, []app.ChangeStatusRequest{ + {EntityID: wrench.EntityID, Status: inventory.EntityStatusMissing, ActorID: "test"}, + {EntityID: hammer.EntityID, Status: inventory.EntityStatusMissing, ActorID: "test"}, }) require.NoError(t, err) } @@ -116,10 +120,12 @@ func TestRunFound_WorksOnLockedEntity(t *testing.T) { require.NoError(t, err) // Manually put it in missing state via ChangeStatus (not locked, so allowed) + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) _, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{ - EntityPath: "Garage:Wrench", - Status: inventory.EntityStatusMissing, - ActorID: "test", + EntityID: wrench.EntityID, + Status: inventory.EntityStatusMissing, + ActorID: "test", }) require.NoError(t, err) diff --git a/cmd/history/history.go b/cmd/history/history.go index 2be9dfb..f29dbdd 100644 --- a/cmd/history/history.go +++ b/cmd/history/history.go @@ -46,9 +46,16 @@ Examples: func runHistory(cmd *cobra.Command, args []string, a *app.App) error { ctx := cmd.Context() - events, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityPath: args[0]}) + path := args[0] + + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } + + events, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityID: entity.EntityID}) if err != nil { - return fmt.Errorf("failed to get history for %q: %w", args[0], err) + return fmt.Errorf("failed to get history for %q: %w", path, err) } cfg, ok := cli.GetConfig(ctx) diff --git a/cmd/list/list_test.go b/cmd/list/list_test.go index 2299501..8242681 100644 --- a/cmd/list/list_test.go +++ b/cmd/list/list_test.go @@ -74,10 +74,12 @@ func TestRunList_ShowsStatusBadge(t *testing.T) { a := apptesting.OpenApp(t) ctx := t.Context() seedThree(t, a) - _, err := a.ChangeStatus(ctx, app.ChangeStatusRequest{ - EntityPath: "Garage:Toolbox:Wrench", - Status: inventory.EntityStatusMissing, - ActorID: "test", + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) + _, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{ + EntityID: wrench.EntityID, + Status: inventory.EntityStatusMissing, + ActorID: "test", }) require.NoError(t, err) @@ -99,10 +101,12 @@ func TestRunList_FilterPrunesUnrelatedBranches(t *testing.T) { ActorID: "test", }) require.NoError(t, err) + drill, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Drill") + require.NoError(t, err) _, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{ - EntityPath: "Garage:Toolbox:Drill", - Status: inventory.EntityStatusMissing, - ActorID: "test", + EntityID: drill.EntityID, + Status: inventory.EntityStatusMissing, + ActorID: "test", }) require.NoError(t, err) @@ -117,10 +121,12 @@ func TestRunList_FilterKeepsAncestor(t *testing.T) { a := apptesting.OpenApp(t) ctx := t.Context() seedThree(t, a) - _, err := a.ChangeStatus(ctx, app.ChangeStatusRequest{ - EntityPath: "Garage:Toolbox:Wrench", - Status: inventory.EntityStatusMissing, - ActorID: "test", + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) + _, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{ + EntityID: wrench.EntityID, + Status: inventory.EntityStatusMissing, + ActorID: "test", }) require.NoError(t, err) @@ -149,10 +155,12 @@ func TestRunList_VerboseShowsIDAndTags(t *testing.T) { a := apptesting.OpenApp(t) ctx := t.Context() seedThree(t, a) + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Toolbox:Wrench", - ActorID: "test", - Add: []string{"dewalt"}, + EntityID: wrench.EntityID, + ActorID: "test", + Add: []string{"dewalt"}, })) out := runCmd(t, a, "--verbose") @@ -165,10 +173,12 @@ func TestRunList_NoTagsWithoutVerbose(t *testing.T) { a := apptesting.OpenApp(t) ctx := t.Context() seedThree(t, a) + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Toolbox:Wrench", - ActorID: "test", - Add: []string{"dewalt"}, + EntityID: wrench.EntityID, + ActorID: "test", + Add: []string{"dewalt"}, })) out := runCmd(t, a) @@ -181,10 +191,12 @@ func TestRunList_JSONIncludesTags(t *testing.T) { a := apptesting.OpenApp(t) ctx := t.Context() seedThree(t, a) + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Toolbox:Wrench", - ActorID: "test", - Add: []string{"dewalt"}, + EntityID: wrench.EntityID, + ActorID: "test", + Add: []string{"dewalt"}, })) cmd := listcmd.NewListCmd(a) @@ -197,16 +209,16 @@ func TestRunList_JSONIncludesTags(t *testing.T) { var items []map[string]any require.NoError(t, json.Unmarshal(stdout.Bytes(), &items)) - var wrench map[string]any + var wrenchItem map[string]any for _, item := range items { if item["path"] == "Garage:Toolbox:Wrench" { - wrench = item + wrenchItem = item break } } - require.NotNil(t, wrench, "Wrench not found in JSON output") + require.NotNil(t, wrenchItem, "Wrench not found in JSON output") - tags, ok := wrench["tags"].([]any) + tags, ok := wrenchItem["tags"].([]any) require.True(t, ok, "tags field must be an array") require.Len(t, tags, 1) assert.Equal(t, "dewalt", tags[0]) diff --git a/cmd/loan/loan.go b/cmd/loan/loan.go index 4f0ac4f..393ebe3 100644 --- a/cmd/loan/loan.go +++ b/cmd/loan/loan.go @@ -58,8 +58,12 @@ func runLoan(cmd *cobra.Command, args []string, a *app.App) error { reqs := make([]app.ChangeStatusRequest, len(args)) for i, path := range args { + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } reqs[i] = app.ChangeStatusRequest{ - EntityPath: path, + EntityID: entity.EntityID, Status: inventory.EntityStatusLoaned, StatusContext: toFlag, Note: noteFlag, diff --git a/cmd/lost/lost.go b/cmd/lost/lost.go index 0f91819..a0f060a 100644 --- a/cmd/lost/lost.go +++ b/cmd/lost/lost.go @@ -55,11 +55,15 @@ func runLost(cmd *cobra.Command, args []string, a *app.App) error { reqs := make([]app.ChangeStatusRequest, len(args)) for i, path := range args { + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } reqs[i] = app.ChangeStatusRequest{ - EntityPath: path, - Status: inventory.EntityStatusMissing, - Note: noteFlag, - ActorID: cli.GetActorUserID(ctx), + EntityID: entity.EntityID, + Status: inventory.EntityStatusMissing, + Note: noteFlag, + ActorID: cli.GetActorUserID(ctx), } } diff --git a/cmd/move/move.go b/cmd/move/move.go index 3a4dacc..85acbd9 100644 --- a/cmd/move/move.go +++ b/cmd/move/move.go @@ -54,10 +54,20 @@ func runMove(cmd *cobra.Command, args []string, a *app.App) error { srcPath := args[0] destPath, _ := cmd.Flags().GetString("to") + src, err := a.LookupEntityByPath(ctx, srcPath) + if err != nil { + return fmt.Errorf("failed to find %q: %w", srcPath, err) + } + + dest, err := a.LookupEntityByPath(ctx, destPath) + if err != nil { + return fmt.Errorf("failed to find destination %q: %w", destPath, err) + } + updated, err := a.ReparentEntity(ctx, app.ReparentEntityRequest{ - EntityPath: srcPath, - NewParentPath: destPath, - ActorID: cli.GetActorUserID(ctx), + EntityID: src.EntityID, + NewParentID: dest.EntityID, + ActorID: cli.GetActorUserID(ctx), }) if err != nil { return fmt.Errorf("failed to move %q: %w", srcPath, err) diff --git a/cmd/remove/remove.go b/cmd/remove/remove.go index f732431..99f38cd 100644 --- a/cmd/remove/remove.go +++ b/cmd/remove/remove.go @@ -51,11 +51,17 @@ func runRemove(cmd *cobra.Command, args []string, a *app.App) error { path := args[0] noteFlag, _ := cmd.Flags().GetString("note") - if err := a.RemoveEntity(ctx, app.RemoveEntityRequest{ - EntityPath: path, - ActorID: cli.GetActorUserID(ctx), - Note: noteFlag, - }); err != nil { + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } + + err = a.RemoveEntity(ctx, app.RemoveEntityRequest{ + EntityID: entity.EntityID, + ActorID: cli.GetActorUserID(ctx), + Note: noteFlag, + }) + if err != nil { return fmt.Errorf("failed to remove %q: %w", path, err) } diff --git a/cmd/rename/rename.go b/cmd/rename/rename.go index 9d54ce1..7e5ed16 100644 --- a/cmd/rename/rename.go +++ b/cmd/rename/rename.go @@ -58,10 +58,15 @@ func runRename(cmd *cobra.Command, args []string, a *app.App) error { path := args[0] toFlag, _ := cmd.Flags().GetString("to") + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } + updated, err := a.RenameEntity(ctx, app.RenameEntityRequest{ - EntityPath: path, - NewName: toFlag, - ActorID: cli.GetActorUserID(ctx), + EntityID: entity.EntityID, + NewName: toFlag, + ActorID: cli.GetActorUserID(ctx), }) if err != nil { return fmt.Errorf("failed to rename %q: %w", path, err) diff --git a/cmd/return/return.go b/cmd/return/return.go index 5b3cc33..839e8ef 100644 --- a/cmd/return/return.go +++ b/cmd/return/return.go @@ -55,11 +55,15 @@ func runReturn(cmd *cobra.Command, args []string, a *app.App) error { reqs := make([]app.ChangeStatusRequest, len(args)) for i, path := range args { + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } reqs[i] = app.ChangeStatusRequest{ - EntityPath: path, - Status: inventory.EntityStatusOk, - Note: noteFlag, - ActorID: cli.GetActorUserID(ctx), + EntityID: entity.EntityID, + Status: inventory.EntityStatusOk, + Note: noteFlag, + ActorID: cli.GetActorUserID(ctx), } } diff --git a/cmd/return/return_test.go b/cmd/return/return_test.go index 3bbb44b..3373d34 100644 --- a/cmd/return/return_test.go +++ b/cmd/return/return_test.go @@ -31,9 +31,13 @@ func seedForReturn(t *testing.T, a *app.App) { }) require.NoError(t, err) } - _, err := a.MarkLoaned(ctx, []app.ChangeStatusRequest{ - {EntityPath: "Garage:Toolbox:Wrench", Status: inventory.EntityStatusLoaned, ActorID: "test"}, - {EntityPath: "Garage:Toolbox:Hammer", Status: inventory.EntityStatusLoaned, ActorID: "test"}, + wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench") + require.NoError(t, err) + hammer, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Hammer") + require.NoError(t, err) + _, err = a.MarkLoaned(ctx, []app.ChangeStatusRequest{ + {EntityID: wrench.EntityID, Status: inventory.EntityStatusLoaned, ActorID: "test"}, + {EntityID: hammer.EntityID, Status: inventory.EntityStatusLoaned, ActorID: "test"}, }) require.NoError(t, err) } diff --git a/cmd/status/status_test.go b/cmd/status/status_test.go index dd1ee3a..57fd8cf 100644 --- a/cmd/status/status_test.go +++ b/cmd/status/status_test.go @@ -52,8 +52,10 @@ func TestRunStatus_ShowsCurrentStatus(t *testing.T) { func TestRunStatus_ShowsRemovedEntity(t *testing.T) { a := apptesting.OpenApp(t) seedForStatus(t, a) - err := a.RemoveEntity(t.Context(), app.RemoveEntityRequest{ - EntityPath: "Garage:Toolbox:Wrench", ActorID: "test", + wrench, err := a.LookupEntityByPath(t.Context(), "Garage:Toolbox:Wrench") + require.NoError(t, err) + err = a.RemoveEntity(t.Context(), app.RemoveEntityRequest{ + EntityID: wrench.EntityID, ActorID: "test", }) require.NoError(t, err) @@ -72,8 +74,10 @@ func TestRunStatus_MultipleMatches_RankedByEventID(t *testing.T) { seedForStatus(t, a) // Remove the wrench, then re-add it — two entities ever at this path - err := a.RemoveEntity(t.Context(), app.RemoveEntityRequest{ - EntityPath: "Garage:Toolbox:Wrench", ActorID: "test", + wrench, err := a.LookupEntityByPath(t.Context(), "Garage:Toolbox:Wrench") + require.NoError(t, err) + err = a.RemoveEntity(t.Context(), app.RemoveEntityRequest{ + EntityID: wrench.EntityID, ActorID: "test", }) require.NoError(t, err) _, err = a.CreateEntity(t.Context(), app.CreateEntityRequest{ diff --git a/cmd/tag/tag.go b/cmd/tag/tag.go index bbd128f..c6453b7 100644 --- a/cmd/tag/tag.go +++ b/cmd/tag/tag.go @@ -66,20 +66,26 @@ func runTag(cmd *cobra.Command, args []string, a *app.App) error { } out := cli.NewOutputWriter(cmd.OutOrStdout(), cmd.ErrOrStderr(), cfg.IsJSON(), cfg.IsQuiet()) + entity, err := a.LookupEntityByPath(ctx, path) + if err != nil { + return fmt.Errorf("failed to find %q: %w", path, err) + } + isMutation := len(addFlags) > 0 || len(removeFlags) > 0 if isMutation { - if err := a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: path, - ActorID: cli.GetActorUserID(ctx), - Add: addFlags, - Remove: removeFlags, - }); err != nil { + err = a.TagEntity(ctx, app.TagEntityRequest{ + EntityID: entity.EntityID, + ActorID: cli.GetActorUserID(ctx), + Add: addFlags, + Remove: removeFlags, + }) + if err != nil { return fmt.Errorf("tag %q: %w", path, err) } } - tags, err := a.ListTags(ctx, app.ListTagsRequest{EntityPath: path}) + tags, err := a.ListTags(ctx, app.ListTagsRequest{EntityID: entity.EntityID}) if err != nil { return fmt.Errorf("list tags for %q: %w", path, err) } diff --git a/cmd/tag/tag_test.go b/cmd/tag/tag_test.go index 143edfa..01061ad 100644 --- a/cmd/tag/tag_test.go +++ b/cmd/tag/tag_test.go @@ -56,7 +56,9 @@ func TestTagCmd_Add(t *testing.T) { _, _, err := runTagCmd(t.Context(), t, a, "Garage:Wrench", "--add", "tool", "--add", "hand_tool") require.NoError(t, err) - tags, err := a.ListTags(t.Context(), app.ListTagsRequest{EntityPath: "Garage:Wrench"}) + wrench, err := a.LookupEntityByPath(t.Context(), "Garage:Wrench") + require.NoError(t, err) + tags, err := a.ListTags(t.Context(), app.ListTagsRequest{EntityID: wrench.EntityID}) require.NoError(t, err) assert.Equal(t, []string{"hand_tool", "tool"}, tags) } @@ -65,14 +67,16 @@ func TestTagCmd_Remove(t *testing.T) { a := apptesting.OpenApp(t) seedForTagCmd(t, a) + wrench, err := a.LookupEntityByPath(t.Context(), "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(t.Context(), app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool", "hand_tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool", "hand_tool"}, })) - _, _, err := runTagCmd(t.Context(), t, a, "Garage:Wrench", "--remove", "hand_tool") + _, _, err = runTagCmd(t.Context(), t, a, "Garage:Wrench", "--remove", "hand_tool") require.NoError(t, err) - tags, err := a.ListTags(t.Context(), app.ListTagsRequest{EntityPath: "Garage:Wrench"}) + tags, err := a.ListTags(t.Context(), app.ListTagsRequest{EntityID: wrench.EntityID}) require.NoError(t, err) assert.Equal(t, []string{"tool"}, tags) } @@ -81,14 +85,16 @@ func TestTagCmd_MixedFlags(t *testing.T) { a := apptesting.OpenApp(t) seedForTagCmd(t, a) + wrench, err := a.LookupEntityByPath(t.Context(), "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(t.Context(), app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool"}, })) - _, _, err := runTagCmd(t.Context(), t, a, "Garage:Wrench", "--add", "hand_tool", "--remove", "tool") + _, _, err = runTagCmd(t.Context(), t, a, "Garage:Wrench", "--add", "hand_tool", "--remove", "tool") require.NoError(t, err) - tags, err := a.ListTags(t.Context(), app.ListTagsRequest{EntityPath: "Garage:Wrench"}) + tags, err := a.ListTags(t.Context(), app.ListTagsRequest{EntityID: wrench.EntityID}) require.NoError(t, err) assert.Equal(t, []string{"hand_tool"}, tags) } @@ -97,8 +103,10 @@ func TestTagCmd_JSON_List(t *testing.T) { a := apptesting.OpenApp(t) seedForTagCmd(t, a) + wrench, err := a.LookupEntityByPath(t.Context(), "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(t.Context(), app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool"}, })) jsonCtx := context.WithValue(t.Context(), config.ConfigKey, apptesting.NewTestConfig(t, apptesting.WithJSON())) diff --git a/docs/adr/0029-tui-tree-navigation.md b/docs/adr/0029-tui-tree-navigation.md new file mode 100644 index 0000000..34ad1be --- /dev/null +++ b/docs/adr/0029-tui-tree-navigation.md @@ -0,0 +1,71 @@ +# TUI navigation replaces drill-in/out list with a persistent expand/collapse tree + +The current TUI navigation shows exactly one level at a time: drilling into a node replaces the entire list with that node's children; drilling out reloads the parent level. This is disorienting because the user loses spatial context — they cannot see where they are in the hierarchy relative to siblings or ancestors. + +The web UI shows a persistent left-panel tree where nodes expand and collapse in place. This ADR aligns the TUI with that model. + +## Decisions + +### Widget: hand-rolled tree over `bubbles/viewport` + +`charm.land/bubbles/v2` (v2.1.0) does not include a tree component — the official PR (#639) was closed without merging in February 2026. The third-party `teatree` library exists but carries a provisional (pre-v1) API with no stability guarantee. + +The tree widget is hand-rolled. It maintains a `[]treeNode` flat slice of currently visible (non-collapsed) nodes. Each `treeNode` carries: + +``` +treeNode { + entityID string + displayName string + status inventory.EntityStatus + hasChildren bool + loaded bool // true once GetChildren has been called for this node + expanded bool + depth int + parentID string // "" for root nodes +} +``` + +`bubbles/viewport` handles scrolling. `bubbles/list` is removed entirely — its fuzzy filtering was per-level only and is superseded by the existing `scry` command (`s`), which performs inventory-wide Levenshtein search. + +### Loading: lazy on expand + +Root entities are loaded at startup via `GetRootEntities` (as today). Children are loaded on first expand of a node via `GetChildren`. A node's `loaded` flag is set to `true` once its children have been spliced in. Re-expanding a node that is already loaded does not re-fetch — the children are already in the slice, just hidden. + +### Expand/collapse keybindings (extends ADR 0026) + +| Key | Behaviour | +|---|---| +| `l` / `→` / `enter` | If collapsed and has children: expand (load if needed). If expanded: collapse. If leaf: no-op. | +| `h` / `←` | If expanded: collapse. If collapsed or leaf: move cursor to parent node. | +| `j` / `↓` | Move cursor down one visible node. | +| `k` / `↑` | Move cursor up one visible node. | + +All action keybindings (`a`, `L`, `b`, `x`, `r`, `f`, `H`, `s`, `d`) are unchanged. The `Filter` key (`/`) is removed — `bubbles/list` and its filter are gone. + +### Mutation refresh: targeted + +After any mutation, only the affected node's parent's children are reloaded via `GetChildren`. The updated children are spliced back into the `[]treeNode` at that parent's position, preserving the expanded/collapsed state of all other nodes. The cursor repositions on the mutated entity by ID. If the entity is absent from the refreshed children (e.g. a `return` on a `borrowed` entity sets it to `removed`, which `GetChildren` excludes), the cursor falls to the next visible node. + +When a mutation adds a child to a node that is currently collapsed, the node's `hasChildren` flag is updated but the node is not auto-expanded. + +### Scry navigation: reveal in tree + +When the user selects a result in `modeScry`, all ancestor nodes from root to the target entity are expanded, lazy-loading any that have not yet been fetched. Previously expanded siblings remain expanded. The cursor is placed on the target entity. `scryNavigatedMsg` carries `pathStack` (which already contains the ancestor IDs needed to drive the reveal chain). + +## Consequences + +- `bubbles/list`, `delegate.go`, `toListItems`, `selectByID`, `pathStack`, `parentStack`, `drillDown`, `drillUp`, `loadLevel`, `handleLevelMsg`, `childrenLoadedMsg`, `levelRestoredMsg`, and `rootsLoadedMsg` are removed or replaced. +- `Model.list list.Model` is replaced by `Model.tree treeModel` (the new viewport-backed widget) and `Model.nodes []treeNode` (the authoritative node slice, including collapsed nodes). +- The crumb bar (`renderNavPane` breadcrumb) is removed — ancestry is visible directly in the tree. +- `childRefreshMsg` is replaced by a `treeRefreshMsg` that carries the parent ID and its reloaded children. +- The `Filter` keybinding is removed from `keyMap`. The help line gains `expand`/`collapse` labels. +- ADR 0027 (mode-based view switching) is unaffected — `modeBrowse`, `modeForm`, `modeConfirm`, `modeScry` remain. The tree widget lives entirely within `modeBrowse`. +- ADR 0026 keybinding table gains the expand/collapse semantics for `l`/`h` described above; all action keys are unchanged. + +## Considered Options + +**Keep `bubbles/list` with a flattened indented view (A3).** Rejected — `bubbles/list` provides fuzzy filtering as its primary value-add; without it the component is just a cursor + scroll wrapper with extra coupling. A `bubbles/viewport` with a `[]treeNode` slice is simpler and fully owned. + +**Use `teatree` (third-party).** Rejected — provisional API (pre-v1), single-author library. The hand-rolled implementation is comparable in size (~200 lines) and has no external dependency risk. + +**Reset tree on scry navigation.** Rejected — destroying the user's expanded state on every scry result contradicts the spatial-context benefit that motivated the tree in the first place. diff --git a/docs/adr/0030-entity-id-as-primary-identifier-in-request-structs.md b/docs/adr/0030-entity-id-as-primary-identifier-in-request-structs.md new file mode 100644 index 0000000..044358a --- /dev/null +++ b/docs/adr/0030-entity-id-as-primary-identifier-in-request-structs.md @@ -0,0 +1,56 @@ +# Mutations accept `entity_id`; path resolution is a CLI/UI boundary concern + +Request structs in `internal/app` were originally built around `EntityPath` (a colon-separated display path) as the sole way to identify an existing entity for mutation. This made sense when the CLI was the only caller: the user types a path, the app resolves it. + +As the web UI and TUI evolved, both layers acquired `entity_id` from their own context (URL path values, tree node state) but were forced to round-trip through path resolution anyway — fetching the entity by ID, discarding the ID, passing `FullPathDisplay` into the request, and re-resolving it back to the ID inside the app layer. + +## Decisions + +### Mutations take `entity_id`; path is a CLI input concern + +The following request structs replace `EntityPath string` with `EntityID string`: + +- `RenameEntityRequest` +- `ReparentEntityRequest` — additionally `NewParentPath` → `NewParentID` +- `RemoveEntityRequest` +- `ChangeStatusRequest` +- `TagEntityRequest` +- `ListTagsRequest` +- `GetHistoryRequest` — `EntityPath` removed; `EntityID` was already present + +The app layer never performs path resolution for these operations. If `EntityID` is empty the method returns an error immediately. + +### `LookupEntityByPath` is the single path→ID bridge + +`resolveEntityPath` (previously private) is promoted to a public `App` method: + +```go +func (a *App) LookupEntityByPath(ctx context.Context, path string) (EntityResult, error) +``` + +CLI commands that accept a path argument call this method before constructing a mutating request. + +### Creation requests retain `ParentPath` + +`CreateEntityRequest.ParentPath` and `BorrowEntityRequest.ParentPath` are unchanged. These fields carry genuine user input (the user types or selects a destination path), not a reference to an entity the caller already holds. PathResolution for creation is the CLI's responsibility as today. + +### TUI `App` interface shape is unchanged + +The TUI's narrow `App` interface (`internal/tui/app.go`) retains its status-specific method names (`MarkLoaned`, `MarkLost`, etc.). Callers populate `EntityID` from the selected tree node's `EntityResult`. The interface shape is a separate concern. + +### Web handlers pass `entity_id` directly + +HTTP handlers extract `entityID` from the URL path value and pass it into request structs without fetching the entity first to obtain its `FullPathDisplay`. The redundant `buildDetailData` → `FullPathDisplay` detour is removed from `handleEditName` and `handleToggleMissing`. + +## Consequences + +- The private `resolveEntityPath`, `resolveEntityPathTx`, `resolveEntityPathWith` methods are collapsed into `LookupEntityByPath` (public) and a transaction-scoped internal helper if still needed. +- Every CLI command that previously passed a raw path string into a request struct gains a `LookupEntityByPath` call before the request is built. +- Web and TUI callers that already hold `entity_id` make no additional store round-trips. +- The app layer's mutation methods become testable without constructing a valid entity path — tests pass an `entity_id` directly. + +## Considered Options + +**Keep dual-mode (EntityPath + EntityID) on each request struct.** Rejected — leaves a permanent ambiguity in every method about which field takes precedence and which callers use which. `GetHistoryRequest` already demonstrated the dual-mode pattern; its inconsistency with the rest of the structs is what motivated this ADR. + +**Push path→ID resolution into the store layer.** Rejected — the filtering step (matching `FullPathCanonical` across candidates with the same canonical leaf name) is application-level disambiguation, not a SQL predicate. The store already provides `GetEntitiesByCanonicalName`; the selection logic belongs in `LookupEntityByPath`. diff --git a/internal/app/entities.go b/internal/app/entities.go index 9d3e0b8..d742d8b 100644 --- a/internal/app/entities.go +++ b/internal/app/entities.go @@ -24,9 +24,9 @@ func (a *App) CreateEntity(ctx context.Context, req CreateEntityRequest) (Entity // RenameEntity renames an entity resolved by its current path. func (a *App) RenameEntity(ctx context.Context, req RenameEntityRequest) (EntityResult, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return EntityResult{}, fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return EntityResult{}, wrapEntityError(req.EntityID, err) } payload := eventbus.EntityRenamedPayload{ @@ -57,9 +57,9 @@ func (a *App) RenameEntity(ctx context.Context, req RenameEntityRequest) (Entity // ReparentEntity moves an entity to a new parent, resolved by paths. func (a *App) ReparentEntity(ctx context.Context, req ReparentEntityRequest) (EntityResult, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return EntityResult{}, fmt.Errorf("resolve entity path %q: %w", req.EntityPath, err) + return EntityResult{}, wrapEntityError(req.EntityID, err) } if entity.Locked { @@ -67,11 +67,11 @@ func (a *App) ReparentEntity(ctx context.Context, req ReparentEntityRequest) (En } var newParentID *string - if req.NewParentPath != "" { + if req.NewParentID != "" { var parentEntity *inventory.Entity - parentEntity, err = a.resolveEntityPath(ctx, req.NewParentPath) + parentEntity, err = a.store.GetEntity(ctx, req.NewParentID) if err != nil { - return EntityResult{}, fmt.Errorf("resolve new parent path %q: %w", req.NewParentPath, err) + return EntityResult{}, wrapEntityError(req.NewParentID, err) } if parentEntity.Discrete { return EntityResult{}, fmt.Errorf("cannot move into %q: entity is discrete", parentEntity.FullPathDisplay) @@ -107,9 +107,9 @@ func (a *App) ReparentEntity(ctx context.Context, req ReparentEntityRequest) (En // RemoveEntity permanently marks an entity as removed. func (a *App) RemoveEntity(ctx context.Context, req RemoveEntityRequest) error { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return wrapEntityError(req.EntityID, err) } if entity.Status == inventory.EntityStatusBorrowed { @@ -134,15 +134,6 @@ func (a *App) RemoveEntity(ctx context.Context, req RemoveEntityRequest) error { return nil } -// GetEntityByPath retrieves an entity by its colon-separated display path. -func (a *App) GetEntityByPath(ctx context.Context, path string) (EntityResult, error) { - entity, err := a.resolveEntityPath(ctx, path) - if err != nil { - return EntityResult{}, err - } - return a.entityWithTags(ctx, entity) -} - // LookupEntityStatus returns all entities ever at the given path (any status, including removed), // ranked by last_event_id DESC (most recent first). Returns ErrNotFound when no entity matches. func (a *App) LookupEntityStatus(ctx context.Context, path string) ([]EntityResult, error) { @@ -287,6 +278,18 @@ func (a *App) resolveEntityPath(ctx context.Context, path string) (*inventory.En }) } +// LookupEntityByPath resolves a colon-separated path to an EntityResult. +// This is the single PathResolution entry point for CLI callers. Web and TUI +// callers that already hold an entity_id should pass it directly to request +// structs and skip this call entirely. +func (a *App) LookupEntityByPath(ctx context.Context, path string) (EntityResult, error) { + entity, err := a.resolveEntityPath(ctx, path) + if err != nil { + return EntityResult{}, err + } + return a.entityWithTags(ctx, entity) +} + func (a *App) resolveEntityPathTx(ctx context.Context, tx store.Tx, path string) (*inventory.Entity, error) { return a.resolveEntityPathWith(path, func(canonical string) ([]*inventory.Entity, error) { return a.store.GetEntitiesByCanonicalNameTx(ctx, tx, canonical) diff --git a/internal/app/entities_test.go b/internal/app/entities_test.go index 8d3c18d..a9ffb6d 100644 --- a/internal/app/entities_test.go +++ b/internal/app/entities_test.go @@ -108,23 +108,23 @@ func TestReparentEntity_Locked_Error(t *testing.T) { a := openTestApp(t) ctx := context.Background() - _, err := a.CreateEntity(ctx, app.CreateEntityRequest{ + garage, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Garage", Locked: true, ActorID: "alice", }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + office, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Office", ActorID: "alice", }) require.NoError(t, err) _, err = a.ReparentEntity(ctx, app.ReparentEntityRequest{ - EntityPath: "Garage", - NewParentPath: "Office", - ActorID: "alice", + EntityID: garage.EntityID, + NewParentID: office.EntityID, + ActorID: "alice", }) assert.ErrorContains(t, err, "locked") } @@ -133,23 +133,23 @@ func TestReparentEntity_IntoDiscrete_Error(t *testing.T) { a := openTestApp(t) ctx := context.Background() - _, err := a.CreateEntity(ctx, app.CreateEntityRequest{ + box, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Box of Nails", Discrete: true, ActorID: "alice", }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + wrench, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Wrench", ActorID: "alice", }) require.NoError(t, err) _, err = a.ReparentEntity(ctx, app.ReparentEntityRequest{ - EntityPath: "Wrench", - NewParentPath: "Box of Nails", - ActorID: "alice", + EntityID: wrench.EntityID, + NewParentID: box.EntityID, + ActorID: "alice", }) assert.ErrorContains(t, err, "discrete") } @@ -164,7 +164,7 @@ func TestReparentEntity_LockedChildMovesWithParent(t *testing.T) { }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + fileCabinet, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "File Cabinet", ParentPath: "Garage", ActorID: "alice", @@ -180,7 +180,7 @@ func TestReparentEntity_LockedChildMovesWithParent(t *testing.T) { }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + office, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Office", ActorID: "alice", }) @@ -188,20 +188,20 @@ func TestReparentEntity_LockedChildMovesWithParent(t *testing.T) { // Moving File Cabinet (not locked) to Office should also move Top Drawer. _, err = a.ReparentEntity(ctx, app.ReparentEntityRequest{ - EntityPath: "Garage:File Cabinet", - NewParentPath: "Office", - ActorID: "alice", + EntityID: fileCabinet.EntityID, + NewParentID: office.EntityID, + ActorID: "alice", }) require.NoError(t, err) // Top Drawer should now be under Office:File Cabinet. - drawer, err := a.GetEntityByPath(ctx, "Office:File Cabinet:Top Drawer") + drawer, err := a.LookupEntityByPath(ctx, "Office:File Cabinet:Top Drawer") require.NoError(t, err) assert.Equal(t, "Office:File Cabinet:Top Drawer", drawer.FullPathDisplay) assert.True(t, drawer.Locked) } -func TestGetEntity_ByPath(t *testing.T) { +func TestLookupEntity_ByPath(t *testing.T) { a := openTestApp(t) ctx := context.Background() @@ -211,12 +211,12 @@ func TestGetEntity_ByPath(t *testing.T) { }) require.NoError(t, err) - result, err := a.GetEntityByPath(ctx, "Garage") + result, err := a.LookupEntityByPath(ctx, "Garage") require.NoError(t, err) assert.Equal(t, "Garage", result.DisplayName) } -func TestGetEntityByPath_Disambiguation(t *testing.T) { +func TestLookupEntityByPath_Disambiguation(t *testing.T) { a := openTestApp(t) ctx := context.Background() @@ -235,11 +235,11 @@ func TestGetEntityByPath_Disambiguation(t *testing.T) { }) require.NoError(t, err) - result, err := a.GetEntityByPath(ctx, "Garage:Shelf") + result, err := a.LookupEntityByPath(ctx, "Garage:Shelf") require.NoError(t, err) assert.Equal(t, "Garage:Shelf", result.FullPathDisplay) - result2, err := a.GetEntityByPath(ctx, "Shelf") + result2, err := a.LookupEntityByPath(ctx, "Shelf") require.NoError(t, err) assert.Equal(t, "Shelf", result2.FullPathDisplay) } @@ -248,18 +248,18 @@ func TestRemoveEntity_ThenMutate_Error(t *testing.T) { a := openTestApp(t) ctx := context.Background() - _, err := a.CreateEntity(ctx, app.CreateEntityRequest{ + garage, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Garage", ActorID: "alice", }) require.NoError(t, err) err = a.RemoveEntity(ctx, app.RemoveEntityRequest{ - EntityPath: "Garage", ActorID: "alice", + EntityID: garage.EntityID, ActorID: "alice", }) require.NoError(t, err) _, err = a.RenameEntity(ctx, app.RenameEntityRequest{ - EntityPath: "Garage", NewName: "Workshop", ActorID: "alice", + EntityID: garage.EntityID, NewName: "Workshop", ActorID: "alice", }) assert.Error(t, err) } @@ -331,7 +331,7 @@ func TestApp_GetChildren(t *testing.T) { }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + removedEntity, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Removed", ParentPath: "Parent", ActorID: "alice", @@ -339,8 +339,8 @@ func TestApp_GetChildren(t *testing.T) { require.NoError(t, err) err = a.RemoveEntity(ctx, app.RemoveEntityRequest{ - EntityPath: "Parent:Removed", - ActorID: "alice", + EntityID: removedEntity.EntityID, + ActorID: "alice", }) require.NoError(t, err) @@ -412,7 +412,7 @@ func TestApp_GetChildren(t *testing.T) { }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + leaf, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Leaf", ParentPath: "GP:Mid", ActorID: "alice", @@ -420,8 +420,8 @@ func TestApp_GetChildren(t *testing.T) { require.NoError(t, err) err = a.RemoveEntity(ctx, app.RemoveEntityRequest{ - EntityPath: "GP:Mid:Leaf", - ActorID: "alice", + EntityID: leaf.EntityID, + ActorID: "alice", }) require.NoError(t, err) @@ -431,3 +431,56 @@ func TestApp_GetChildren(t *testing.T) { assert.False(t, results[0].HasChildren, "Mid should report HasChildren=false when its only child is removed") }) } + +func TestLookupEntityByPath_Found(t *testing.T) { + a := openTestApp(t) + ctx := context.Background() + + created, err := a.CreateEntity(ctx, app.CreateEntityRequest{ + DisplayName: "Garage", + ActorID: "alice", + }) + require.NoError(t, err) + + result, err := a.LookupEntityByPath(ctx, "Garage") + require.NoError(t, err) + assert.Equal(t, created.EntityID, result.EntityID) + assert.Equal(t, "Garage", result.DisplayName) +} + +func TestLookupEntityByPath_NotFound(t *testing.T) { + a := openTestApp(t) + ctx := context.Background() + + _, err := a.LookupEntityByPath(ctx, "NoSuchThing") + assert.ErrorIs(t, err, app.ErrNotFound) +} + +func TestRenameEntity_ByID(t *testing.T) { + a := openTestApp(t) + ctx := context.Background() + + created, err := a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Garage", ActorID: "alice"}) + require.NoError(t, err) + + result, err := a.RenameEntity(ctx, app.RenameEntityRequest{ + EntityID: created.EntityID, + NewName: "Workshop", + ActorID: "alice", + }) + require.NoError(t, err) + assert.Equal(t, "Workshop", result.DisplayName) + assert.Equal(t, created.EntityID, result.EntityID) +} + +func TestRenameEntity_UnknownID_Error(t *testing.T) { + a := openTestApp(t) + ctx := context.Background() + + _, err := a.RenameEntity(ctx, app.RenameEntityRequest{ + EntityID: "nonexistent", + NewName: "Whatever", + ActorID: "alice", + }) + assert.ErrorIs(t, err, app.ErrNotFound) +} diff --git a/internal/app/errors.go b/internal/app/errors.go index 69c5235..6c90bc9 100644 --- a/internal/app/errors.go +++ b/internal/app/errors.go @@ -1,6 +1,22 @@ package app -import "errors" +import ( + "errors" + "fmt" + + "github.com/asphaltbuffet/wherehouse/internal/store" +) // ErrNotFound is returned when a requested entity does not exist. var ErrNotFound = errors.New("not found") + +// wrapEntityError translates a store.GetEntity error into an app-layer error. +// store.ErrNotFound becomes app.ErrNotFound; all other errors (DB failures, +// context cancellations, etc.) are propagated directly so callers can +// distinguish "entity missing" from "storage unavailable". +func wrapEntityError(id string, err error) error { + if errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("get entity %q: %w", id, ErrNotFound) + } + return fmt.Errorf("get entity %q: %w", id, err) +} diff --git a/internal/app/history.go b/internal/app/history.go index f3ea2ea..e7aed94 100644 --- a/internal/app/history.go +++ b/internal/app/history.go @@ -10,24 +10,13 @@ import ( // Results default to newest-first; set OldestFirst to reverse. // A positive Limit caps the number of returned events. func (a *App) GetHistory(ctx context.Context, req GetHistoryRequest) ([]HistoryResult, error) { - var entityID string - - switch { - case req.EntityID != "": - entityID = req.EntityID - case req.EntityPath != "": - entity, err := a.resolveEntityPath(ctx, req.EntityPath) - if err != nil { - return nil, fmt.Errorf("resolve path %q: %w", req.EntityPath, err) - } - entityID = entity.EntityID - default: - return nil, errors.New("GetHistory: either EntityPath or EntityID must be set") + if req.EntityID == "" { + return nil, errors.New("GetHistory: EntityID must be set") } - events, err := a.store.GetEventsByEntity(ctx, entityID) + events, err := a.store.GetEventsByEntity(ctx, req.EntityID) if err != nil { - return nil, fmt.Errorf("get history for %s: %w", entityID, err) + return nil, fmt.Errorf("get history for %s: %w", req.EntityID, err) } results := make([]HistoryResult, 0, len(events)) diff --git a/internal/app/history_test.go b/internal/app/history_test.go index fbaa44f..a1d55b2 100644 --- a/internal/app/history_test.go +++ b/internal/app/history_test.go @@ -15,12 +15,12 @@ func TestGetHistory_ByPath(t *testing.T) { a := openTestApp(t) ctx := context.Background() - _, err := a.CreateEntity(ctx, app.CreateEntityRequest{ + garage, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Garage", ActorID: "alice", }) require.NoError(t, err) - history, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityPath: "Garage"}) + history, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityID: garage.EntityID}) require.NoError(t, err) assert.Len(t, history, 1) assert.Equal(t, inventory.EntityCreatedEvent, history[0].EventType) diff --git a/internal/app/import_test.go b/internal/app/import_test.go index 9da16b8..bd84503 100644 --- a/internal/app/import_test.go +++ b/internal/app/import_test.go @@ -221,9 +221,9 @@ func seedReparentScenario(t *testing.T) (*app.App, []app.ExportResult) { _, err = a.ReparentEntity( ctx, app.ReparentEntityRequest{ - EntityPath: parent.FullPathDisplay, - NewParentPath: gp2.FullPathDisplay, - ActorID: "alice", + EntityID: parent.EntityID, + NewParentID: gp2.EntityID, + ActorID: "alice", }, ) require.NoError(t, err) diff --git a/internal/app/requests.go b/internal/app/requests.go index 9c52f47..0620c05 100644 --- a/internal/app/requests.go +++ b/internal/app/requests.go @@ -16,30 +16,30 @@ type CreateEntityRequest struct { // RenameEntityRequest is the input for renaming an entity. type RenameEntityRequest struct { - EntityPath string - NewName string - ActorID string - Note string + EntityID string + NewName string + ActorID string + Note string } // ReparentEntityRequest is the input for moving an entity to a new parent. type ReparentEntityRequest struct { - EntityPath string - NewParentPath string // empty means make root-level - ActorID string - Note string + EntityID string + NewParentID string // empty means make root-level + ActorID string + Note string } // RemoveEntityRequest is the input for removing an entity. type RemoveEntityRequest struct { - EntityPath string - ActorID string - Note string + EntityID string + ActorID string + Note string } // ChangeStatusRequest is the input for changing an entity's status. type ChangeStatusRequest struct { - EntityPath string + EntityID string Status inventory.EntityStatus StatusContext string ActorID string @@ -57,7 +57,6 @@ type BorrowEntityRequest struct { // GetHistoryRequest is the input for retrieving an entity's event history. type GetHistoryRequest struct { - EntityPath string EntityID string Limit int OldestFirst bool @@ -71,14 +70,14 @@ type FindEntitiesRequest struct { // TagEntityRequest is the input for adding/removing tags on an entity. type TagEntityRequest struct { - EntityPath string - ActorID string - Add []string - Remove []string - Note string + EntityID string + ActorID string + Add []string + Remove []string + Note string } // ListTagsRequest is the input for listing tags on an entity. type ListTagsRequest struct { - EntityPath string + EntityID string } diff --git a/internal/app/root_entities_test.go b/internal/app/root_entities_test.go index 31971bb..d8c1d59 100644 --- a/internal/app/root_entities_test.go +++ b/internal/app/root_entities_test.go @@ -68,10 +68,10 @@ func TestApp_GetRootEntities(t *testing.T) { _, err := a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Active", ActorID: "alice"}) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Gone", ActorID: "alice"}) + gone, err := a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Gone", ActorID: "alice"}) require.NoError(t, err) - err = a.RemoveEntity(ctx, app.RemoveEntityRequest{EntityPath: "Gone", ActorID: "alice"}) + err = a.RemoveEntity(ctx, app.RemoveEntityRequest{EntityID: gone.EntityID, ActorID: "alice"}) require.NoError(t, err) results, err := a.GetRootEntities(ctx) diff --git a/internal/app/status.go b/internal/app/status.go index 7995226..f4182c2 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -13,9 +13,9 @@ import ( // ChangeStatus records a status-change event for the entity at the given path. func (a *App) ChangeStatus(ctx context.Context, req ChangeStatusRequest) (EntityResult, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return EntityResult{}, fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return EntityResult{}, wrapEntityError(req.EntityID, err) } if entity.Locked && req.Status == inventory.EntityStatusMissing { @@ -111,9 +111,9 @@ func execBatch[T any]( } func (a *App) markLostInTx(ctx context.Context, tx store.Tx, req ChangeStatusRequest) (string, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return "", fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return "", wrapEntityError(req.EntityID, err) } if entity.Locked { @@ -143,7 +143,7 @@ func (a *App) markLostInTx(ctx context.Context, tx store.Tx, req ChangeStatusReq if _, err = a.bus.DispatchInTx( ctx, tx, inventory.EntityStatusChangedEvent, req.ActorID, raw, note, ); err != nil { - return "", fmt.Errorf("mark lost %q: %w", req.EntityPath, err) + return "", fmt.Errorf("mark lost %q: %w", req.EntityID, err) } return entity.EntityID, nil @@ -165,9 +165,9 @@ func (a *App) MarkReturned(ctx context.Context, reqs []ChangeStatusRequest) ([]E } func (a *App) markReturnInTx(ctx context.Context, tx store.Tx, req ChangeStatusRequest) (string, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return "", fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return "", wrapEntityError(req.EntityID, err) } if entity.Status != inventory.EntityStatusLoaned && entity.Status != inventory.EntityStatusBorrowed { @@ -200,16 +200,16 @@ func (a *App) markReturnInTx(ctx context.Context, tx store.Tx, req ChangeStatusR if _, err = a.bus.DispatchInTx( ctx, tx, inventory.EntityStatusChangedEvent, req.ActorID, raw, note, ); err != nil { - return "", fmt.Errorf("mark returned %q: %w", req.EntityPath, err) + return "", fmt.Errorf("mark returned %q: %w", req.EntityID, err) } return entity.EntityID, nil } func (a *App) markLoanInTx(ctx context.Context, tx store.Tx, req ChangeStatusRequest) (string, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return "", fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return "", wrapEntityError(req.EntityID, err) } if entity.Locked { @@ -245,7 +245,7 @@ func (a *App) markLoanInTx(ctx context.Context, tx store.Tx, req ChangeStatusReq if _, err = a.bus.DispatchInTx( ctx, tx, inventory.EntityStatusChangedEvent, req.ActorID, raw, note, ); err != nil { - return "", fmt.Errorf("mark loaned %q: %w", req.EntityPath, err) + return "", fmt.Errorf("mark loaned %q: %w", req.EntityID, err) } return entity.EntityID, nil @@ -303,9 +303,9 @@ func (a *App) borrowEntityInTx(ctx context.Context, tx store.Tx, req BorrowEntit } func (a *App) markFoundInTx(ctx context.Context, tx store.Tx, req ChangeStatusRequest) (string, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return "", fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return "", wrapEntityError(req.EntityID, err) } if entity.Status != inventory.EntityStatusMissing { @@ -333,7 +333,7 @@ func (a *App) markFoundInTx(ctx context.Context, tx store.Tx, req ChangeStatusRe if _, err = a.bus.DispatchInTx( ctx, tx, inventory.EntityStatusChangedEvent, req.ActorID, raw, note, ); err != nil { - return "", fmt.Errorf("mark found %q: %w", req.EntityPath, err) + return "", fmt.Errorf("mark found %q: %w", req.EntityID, err) } return entity.EntityID, nil diff --git a/internal/app/status_test.go b/internal/app/status_test.go index c6295c5..94ea858 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -20,13 +20,13 @@ func TestChangeStatus_Missing(t *testing.T) { }) require.NoError(t, err) - _, err = a.CreateEntity(ctx, app.CreateEntityRequest{ + wrench, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Wrench", ParentPath: "Garage", ActorID: "alice", }) require.NoError(t, err) result, err := a.ChangeStatus(ctx, app.ChangeStatusRequest{ - EntityPath: "Garage:Wrench", + EntityID: wrench.EntityID, Status: inventory.EntityStatusMissing, StatusContext: "lost at job site", ActorID: "alice", @@ -40,15 +40,15 @@ func TestChangeStatus_LockedEntity_MissingForbidden(t *testing.T) { a := openTestApp(t) ctx := context.Background() - _, err := a.CreateEntity(ctx, app.CreateEntityRequest{ + garage, err := a.CreateEntity(ctx, app.CreateEntityRequest{ DisplayName: "Garage", Locked: true, ActorID: "alice", }) require.NoError(t, err) _, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{ - EntityPath: "Garage", - Status: inventory.EntityStatusMissing, - ActorID: "alice", + EntityID: garage.EntityID, + Status: inventory.EntityStatusMissing, + ActorID: "alice", }) assert.ErrorContains(t, err, "locked") } diff --git a/internal/app/status_transitions_test.go b/internal/app/status_transitions_test.go index 608d819..ad37b90 100644 --- a/internal/app/status_transitions_test.go +++ b/internal/app/status_transitions_test.go @@ -33,19 +33,22 @@ func seedEntityInStatus(t *testing.T, a *app.App, status inventory.EntityStatus) }) require.NoError(t, err) + item, err := a.LookupEntityByPath(ctx, "Box:Item") + require.NoError(t, err) + switch status { case inventory.EntityStatusOk: // already ok case inventory.EntityStatusMissing: - _, err = a.MarkLost(ctx, []app.ChangeStatusRequest{{EntityPath: "Box:Item", ActorID: "test"}}) + _, err = a.MarkLost(ctx, []app.ChangeStatusRequest{{EntityID: item.EntityID, ActorID: "test"}}) require.NoError(t, err) case inventory.EntityStatusLoaned: _, err = a.MarkLoaned(ctx, []app.ChangeStatusRequest{ - {EntityPath: "Box:Item", StatusContext: "Bob", ActorID: "test"}, + {EntityID: item.EntityID, StatusContext: "Bob", ActorID: "test"}, }) require.NoError(t, err) case inventory.EntityStatusRemoved: - require.NoError(t, a.RemoveEntity(ctx, app.RemoveEntityRequest{EntityPath: "Box:Item", ActorID: "test"})) + require.NoError(t, a.RemoveEntity(ctx, app.RemoveEntityRequest{EntityID: item.EntityID, ActorID: "test"})) case inventory.EntityStatusBorrowed: t.Fatalf("borrowed handled above") } @@ -56,22 +59,46 @@ func seedEntityInStatus(t *testing.T, a *app.App, status inventory.EntityStatus) func TestStatusTransitionTable(t *testing.T) { type call func(ctx context.Context, a *app.App) error + lookupItem := func(ctx context.Context, a *app.App) (string, error) { + e, err := a.LookupEntityByPath(ctx, "Box:Item") + if err != nil { + return "", err + } + return e.EntityID, nil + } + markLost := func(ctx context.Context, a *app.App) error { - _, err := a.MarkLost(ctx, []app.ChangeStatusRequest{{EntityPath: "Box:Item", ActorID: "test"}}) + id, err := lookupItem(ctx, a) + if err != nil { + return err + } + _, err = a.MarkLost(ctx, []app.ChangeStatusRequest{{EntityID: id, ActorID: "test"}}) return err } markFound := func(ctx context.Context, a *app.App) error { - _, err := a.MarkFound(ctx, []app.ChangeStatusRequest{{EntityPath: "Box:Item", ActorID: "test"}}) + id, err := lookupItem(ctx, a) + if err != nil { + return err + } + _, err = a.MarkFound(ctx, []app.ChangeStatusRequest{{EntityID: id, ActorID: "test"}}) return err } markLoaned := func(ctx context.Context, a *app.App) error { - _, err := a.MarkLoaned(ctx, []app.ChangeStatusRequest{ - {EntityPath: "Box:Item", StatusContext: "Bob", ActorID: "test"}, + id, err := lookupItem(ctx, a) + if err != nil { + return err + } + _, err = a.MarkLoaned(ctx, []app.ChangeStatusRequest{ + {EntityID: id, StatusContext: "Bob", ActorID: "test"}, }) return err } markReturned := func(ctx context.Context, a *app.App) error { - _, err := a.MarkReturned(ctx, []app.ChangeStatusRequest{{EntityPath: "Box:Item", ActorID: "test"}}) + id, err := lookupItem(ctx, a) + if err != nil { + return err + } + _, err = a.MarkReturned(ctx, []app.ChangeStatusRequest{{EntityID: id, ActorID: "test"}}) return err } @@ -134,9 +161,12 @@ func TestStatusBatch_IllegalTransitionRollsBackBatch(t *testing.T) { require.NoError(t, err) } + entityA, err := a.LookupEntityByPath(ctx, "Box:A") + require.NoError(t, err) + // A is ok (legal lost), B is ok then we attempt found on it (illegal from ok) — whole batch must roll back. _, err = a.MarkFound(ctx, []app.ChangeStatusRequest{ - {EntityPath: "Box:A", ActorID: "test"}, // A is ok -> found illegal + {EntityID: entityA.EntityID, ActorID: "test"}, // A is ok -> found illegal }) require.Error(t, err) diff --git a/internal/app/tags.go b/internal/app/tags.go index f14b792..89afa9b 100644 --- a/internal/app/tags.go +++ b/internal/app/tags.go @@ -10,13 +10,13 @@ import ( "github.com/asphaltbuffet/wherehouse/internal/logging" ) -// TagEntity adds and/or removes tags on the entity identified by req.EntityPath. +// TagEntity adds and/or removes tags on the entity identified by req.EntityID. // Tags in both Add and Remove cancel each other out (a warning is logged). // Adding an existing tag or removing a missing tag are no-ops. func (a *App) TagEntity(ctx context.Context, req TagEntityRequest) error { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return wrapEntityError(req.EntityID, err) } addSet := make(map[string]bool, len(req.Add)) @@ -72,11 +72,11 @@ func (a *App) TagEntity(ctx context.Context, req TagEntityRequest) error { return nil } -// ListTags returns the canonical tags for the entity at req.EntityPath, sorted alphabetically. +// ListTags returns the canonical tags for the entity identified by req.EntityID, sorted alphabetically. func (a *App) ListTags(ctx context.Context, req ListTagsRequest) ([]string, error) { - entity, err := a.resolveEntityPath(ctx, req.EntityPath) + entity, err := a.store.GetEntity(ctx, req.EntityID) if err != nil { - return nil, fmt.Errorf("resolve path %q: %w", req.EntityPath, err) + return nil, wrapEntityError(req.EntityID, err) } tags, err := a.store.GetTagsByEntity(ctx, entity.EntityID) if err != nil { diff --git a/internal/app/tags_test.go b/internal/app/tags_test.go index 46a19ea..7a88199 100644 --- a/internal/app/tags_test.go +++ b/internal/app/tags_test.go @@ -30,14 +30,16 @@ func TestTagEntity_Add(t *testing.T) { seedForTags(t, a) ctx := context.Background() - err := a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", - ActorID: "alice", - Add: []string{"tool", "hand_tool"}, + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) + err = a.TagEntity(ctx, app.TagEntityRequest{ + EntityID: wrench.EntityID, + ActorID: "alice", + Add: []string{"tool", "hand_tool"}, }) require.NoError(t, err) - result, err := a.GetEntityByPath(ctx, "Garage:Wrench") + result, err := a.LookupEntityByPath(ctx, "Garage:Wrench") require.NoError(t, err) assert.Equal(t, []string{"hand_tool", "tool"}, result.Tags) } @@ -47,14 +49,16 @@ func TestTagEntity_Remove(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool", "hand_tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool", "hand_tool"}, })) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Remove: []string{"hand_tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Remove: []string{"hand_tool"}, })) - result, err := a.GetEntityByPath(ctx, "Garage:Wrench") + result, err := a.LookupEntityByPath(ctx, "Garage:Wrench") require.NoError(t, err) assert.Equal(t, []string{"tool"}, result.Tags) } @@ -64,17 +68,19 @@ func TestTagEntity_MixedAddRemove(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool"}, })) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", - ActorID: "alice", - Add: []string{"hand_tool"}, - Remove: []string{"tool"}, + EntityID: wrench.EntityID, + ActorID: "alice", + Add: []string{"hand_tool"}, + Remove: []string{"tool"}, })) - result, err := a.GetEntityByPath(ctx, "Garage:Wrench") + result, err := a.LookupEntityByPath(ctx, "Garage:Wrench") require.NoError(t, err) assert.Equal(t, []string{"hand_tool"}, result.Tags) } @@ -84,16 +90,19 @@ func TestTagEntity_OverlapCancels(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) + // "tool" in both add and remove — should cancel; only "hand_tool" added - err := a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", - ActorID: "alice", - Add: []string{"tool", "hand_tool"}, - Remove: []string{"tool"}, + err = a.TagEntity(ctx, app.TagEntityRequest{ + EntityID: wrench.EntityID, + ActorID: "alice", + Add: []string{"tool", "hand_tool"}, + Remove: []string{"tool"}, }) require.NoError(t, err) - result, err := a.GetEntityByPath(ctx, "Garage:Wrench") + result, err := a.LookupEntityByPath(ctx, "Garage:Wrench") require.NoError(t, err) assert.Equal(t, []string{"hand_tool"}, result.Tags) } @@ -103,15 +112,17 @@ func TestTagEntity_AddDuplicate(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool"}, })) // Re-adding the same tag must be a no-op (no error). require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool"}, })) - result, err := a.GetEntityByPath(ctx, "Garage:Wrench") + result, err := a.LookupEntityByPath(ctx, "Garage:Wrench") require.NoError(t, err) assert.Equal(t, []string{"tool"}, result.Tags) } @@ -121,9 +132,12 @@ func TestTagEntity_RemoveMissing(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) + // Removing a tag that doesn't exist must be a no-op (no error). require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Remove: []string{"nonexistent"}, + EntityID: wrench.EntityID, ActorID: "alice", Remove: []string{"nonexistent"}, })) } @@ -132,7 +146,7 @@ func TestTagEntity_UnknownPath(t *testing.T) { ctx := context.Background() err := a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Nope:DoesNotExist", ActorID: "alice", Add: []string{"tool"}, + EntityID: "nonexistent-id", ActorID: "alice", Add: []string{"tool"}, }) require.Error(t, err) } @@ -142,11 +156,13 @@ func TestListTags(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool", "screwdriver"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool", "screwdriver"}, })) - tags, err := a.ListTags(ctx, app.ListTagsRequest{EntityPath: "Garage:Wrench"}) + tags, err := a.ListTags(ctx, app.ListTagsRequest{EntityID: wrench.EntityID}) require.NoError(t, err) assert.Equal(t, []string{"screwdriver", "tool"}, tags) } @@ -155,7 +171,7 @@ func TestListTags_UnknownPath(t *testing.T) { a := apptesting.OpenApp(t) ctx := context.Background() - _, err := a.ListTags(ctx, app.ListTagsRequest{EntityPath: "Nope:Missing"}) + _, err := a.ListTags(ctx, app.ListTagsRequest{EntityID: "nonexistent-id"}) require.Error(t, err) } @@ -164,11 +180,13 @@ func TestTagEntity_AppearsInHistory(t *testing.T) { seedForTags(t, a) ctx := context.Background() + wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench") + require.NoError(t, err) require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{ - EntityPath: "Garage:Wrench", ActorID: "alice", Add: []string{"tool"}, + EntityID: wrench.EntityID, ActorID: "alice", Add: []string{"tool"}, })) - history, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityPath: "Garage:Wrench"}) + history, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityID: wrench.EntityID}) require.NoError(t, err) var eventTypes []string diff --git a/internal/tui/confirm.go b/internal/tui/confirm.go index 2a3596e..bdfa0fd 100644 --- a/internal/tui/confirm.go +++ b/internal/tui/confirm.go @@ -85,10 +85,10 @@ func (c confirmModel) submitCmd() tea.Cmd { case confirmLost: return func() tea.Msg { reqs := []app.ChangeStatusRequest{{ - EntityPath: entity.FullPathDisplay, - Status: inventory.EntityStatusMissing, - ActorID: cli.GetActorUserID(context.Background()), - Note: note, + EntityID: entity.EntityID, + Status: inventory.EntityStatusMissing, + ActorID: cli.GetActorUserID(context.Background()), + Note: note, }} results, err := a.MarkLost(context.Background(), reqs) if err != nil { @@ -103,9 +103,9 @@ func (c confirmModel) submitCmd() tea.Cmd { case confirmReturn: return func() tea.Msg { reqs := []app.ChangeStatusRequest{{ - EntityPath: entity.FullPathDisplay, - ActorID: cli.GetActorUserID(context.Background()), - Note: note, + EntityID: entity.EntityID, + ActorID: cli.GetActorUserID(context.Background()), + Note: note, }} results, err := a.MarkReturned(context.Background(), reqs) if err != nil { @@ -120,10 +120,10 @@ func (c confirmModel) submitCmd() tea.Cmd { case confirmFound: return func() tea.Msg { reqs := []app.ChangeStatusRequest{{ - EntityPath: entity.FullPathDisplay, - Status: inventory.EntityStatusOk, - ActorID: cli.GetActorUserID(context.Background()), - Note: note, + EntityID: entity.EntityID, + Status: inventory.EntityStatusOk, + ActorID: cli.GetActorUserID(context.Background()), + Note: note, }} results, err := a.MarkFound(context.Background(), reqs) if err != nil { diff --git a/internal/tui/delegate.go b/internal/tui/delegate.go deleted file mode 100644 index 99748f0..0000000 --- a/internal/tui/delegate.go +++ /dev/null @@ -1,59 +0,0 @@ -package tui - -import ( - "fmt" - "io" - "strings" - - "charm.land/bubbles/v2/list" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/asphaltbuffet/wherehouse/internal/inventory" - "github.com/asphaltbuffet/wherehouse/internal/styles" -) - -const chevron = "▶" - -type delegate struct { - selected lipgloss.Style - normal lipgloss.Style - st *styles.Styles -} - -func newDelegate(st *styles.Styles) delegate { - return delegate{ - selected: st.TUISelected(), - normal: lipgloss.NewStyle(), - st: st, - } -} - -func (d delegate) Height() int { return 1 } -func (d delegate) Spacing() int { return 0 } -func (d delegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } - -func (d delegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(item) - if !ok { - return - } - - rendered := d.st.AccentText().Render(chevron) - prefix := rendered - if !i.result.HasChildren { - prefix = strings.Repeat(" ", lipgloss.Width(rendered)) - } - - name := i.result.DisplayName - if index == m.Index() { - name = d.selected.Render(name) - } - - statusTag := "" - if i.result.Status != inventory.EntityStatusOk { - statusTag = " " + d.st.Muted().Render(fmt.Sprintf("[%s]", i.result.Status.String())) - } - - fmt.Fprint(w, prefix+" "+name+statusTag) -} diff --git a/internal/tui/form.go b/internal/tui/form.go index f1717d3..af344f1 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -191,7 +191,7 @@ func (f formModel) submitCmd() tea.Cmd { a := f.appRef return func() tea.Msg { reqs := []app.ChangeStatusRequest{{ - EntityPath: entity.FullPathDisplay, + EntityID: entity.EntityID, StatusContext: to, ActorID: cli.GetActorUserID(context.Background()), Note: note, diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 76d449d..ba31e7a 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -8,14 +8,39 @@ type actionDoneMsg struct { err error } -// childRefreshMsg triggers a reload of the current level after a mutation, -// optionally repositioning the cursor on targetEntityID. -type childRefreshMsg struct { +// rootsLoadedMsg carries the result of the initial root entity fetch. +type rootsLoadedMsg struct { + items []app.EntityResult + err error +} + +// treeExpandedMsg carries children loaded when a node is expanded. +type treeExpandedMsg struct { + parentID string + depth int + items []app.EntityResult + err error +} + +// treeRefreshMsg carries reloaded children for a parent after a mutation. +type treeRefreshMsg struct { + parentID string items []app.EntityResult targetEntityID string err error } +// treeRevealMsg carries one level of children needed to reveal a scry result. +// When remainingPath is empty the target has been reached. +type treeRevealMsg struct { + parentID string + depth int + items []app.EntityResult + remainingPath []string // entity IDs still to expand toward the target + targetEntityID string + err error +} + // historyLoadedMsg carries GetHistory results for the history view. type historyLoadedMsg struct { entity app.EntityResult @@ -30,11 +55,9 @@ type scryResultsMsg struct { err error } -// scryNavigatedMsg triggers browse-tree navigation to a scry result's position. +// scryNavigatedMsg triggers tree reveal navigation to a scry result. type scryNavigatedMsg struct { - items []app.EntityResult targetEntityID string - pathStack []string - parentStack []string + ancestorIDs []string // ordered root→parent, excluding the target itself err error } diff --git a/internal/tui/model.go b/internal/tui/model.go index 963a556..e79fde8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -7,12 +7,10 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/asphaltbuffet/wherehouse/internal/app" - "github.com/asphaltbuffet/wherehouse/internal/entitypath" "github.com/asphaltbuffet/wherehouse/internal/inventory" "github.com/asphaltbuffet/wherehouse/internal/styles" versioncmd "github.com/asphaltbuffet/wherehouse/internal/version" @@ -24,7 +22,7 @@ const keyEsc = "esc" type tuiMode int const ( - modeBrowse tuiMode = iota // two-pane navigator (default) + modeBrowse tuiMode = iota // tree navigator (default) modeForm // text-input form for add/loan/borrow modeConfirm // y/n prompt for lost/found/return modeScry // inventory-wide search @@ -39,39 +37,21 @@ const ( rightPaneHistory // entity event history ) -// borderWidth is the total horizontal space consumed by a single rounded border (left + right). -const borderWidth = 2 - -// borderHeight is the total vertical space consumed by a single rounded border (top + bottom). -const borderHeight = 2 - -// headerHeight is the number of terminal rows used by the application header bar. -const headerHeight = 1 - -// helpHeightShort is the number of rows the collapsed help bar occupies. -const helpHeightShort = 1 - -// crumbHeight is the number of rows used by the breadcrumb line inside the nav pane border. -const crumbHeight = 1 - -// listViewOverhead is the number of extra lines bubbles/list View() renders beyond SetSize height. -// The list pads items to fill PerPage slots then wraps in lipgloss.Height(N), which in -// lipgloss v2 counts trailing newlines as extra rows, producing height+3 total lines. -const listViewOverhead = 3 - -// navWidthRatio is the fraction of terminal width given to the navigation pane. -const navWidthRatio = 0.60 - -// navPaneMinWidth is the minimum number of columns for the navigation pane. -const navPaneMinWidth = 20 +const ( + borderWidth = 2 + borderHeight = 2 + headerHeight = 1 + helpHeightShort = 1 + navWidthRatio = 0.60 + navPaneMinWidth = 20 +) // keyMap defines all keybindings exposed to the help bubble. type keyMap struct { Up key.Binding Down key.Binding - DrillIn key.Binding - DrillOut key.Binding - Filter key.Binding + Expand key.Binding + Collapse key.Binding Help key.Binding Quit key.Binding Add key.Binding @@ -97,17 +77,13 @@ func defaultKeyMap() keyMap { key.WithKeys("j", "down"), key.WithHelp("j/↓", "down"), ), - DrillIn: key.NewBinding( + Expand: key.NewBinding( key.WithKeys("l", "right", "enter"), - key.WithHelp("l/→", "open"), + key.WithHelp("l/→", "expand"), ), - DrillOut: key.NewBinding( + Collapse: key.NewBinding( key.WithKeys("h", "left"), - key.WithHelp("h/←", "back"), - ), - Filter: key.NewBinding( - key.WithKeys("/"), - key.WithHelp("/", "filter"), + key.WithHelp("h/←", "collapse"), ), Help: key.NewBinding( key.WithKeys("?"), @@ -166,104 +142,57 @@ func defaultKeyMap() keyMap { // ShortHelp implements help.KeyMap. func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.Down, k.DrillIn, k.DrillOut, k.Scry, k.Detail, k.Help, k.Quit} + return []key.Binding{k.Up, k.Down, k.Expand, k.Collapse, k.Scry, k.Detail, k.Help, k.Quit} } // FullHelp implements help.KeyMap. func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Up, k.Down}, - {k.DrillIn, k.DrillOut, k.Filter}, + {k.Expand, k.Collapse}, {k.Add, k.Loan, k.Borrow}, {k.Lost, k.Return, k.Found}, {k.History, k.Scry, k.Detail, k.Help, k.Quit}, } } -// item wraps app.EntityResult to satisfy bubbles/list.Item. -type item struct { - result app.EntityResult -} - -func (i item) FilterValue() string { return i.result.DisplayName } -func (i item) Title() string { return i.result.DisplayName } -func (i item) Description() string { return "" } - -// rootsLoadedMsg carries the result of the initial root entity fetch. -type rootsLoadedMsg struct { - items []app.EntityResult - err error -} - -// childrenLoadedMsg carries the result of a drill-down fetch. -type childrenLoadedMsg struct { - parentID string - parentPath string - items []app.EntityResult +// Model is the bubbletea model for the TUI. +type Model struct { + app App + tree treeModel + nodes []treeNode // all loaded nodes; superset of visible + visible []int // indices into nodes currently shown + cursor int // index into visible + help help.Model + keys keyMap + st *styles.Styles + termWidth int + termHeight int err error + mode tuiMode + errMsg string + rightPane rightPaneKind + form formModel + confirm confirmModel + history historyModel + scry scryModel } -// levelRestoredMsg carries the result of a drill-up reload. -type levelRestoredMsg struct { - pathStack []string - parentStack []string - items []app.EntityResult - err error -} - -// Model is the bubbletea model for the TUI. -type Model struct { - app App - list list.Model - help help.Model - keys keyMap - st *styles.Styles - pathStack []string // FullPathDisplay values, empty = at root - parentStack []string // entity IDs of ancestors, for navigating back - termWidth int - termHeight int - err error - mode tuiMode - errMsg string // transient detail-pane error, cleared on next action - rightPane rightPaneKind // what the right pane shows (hidden by default) - form formModel - confirm confirmModel - history historyModel - scry scryModel -} - -// New creates a new TUI model. +// New constructs a TUI Model backed by the given App. func New(a App) Model { st := styles.DefaultStyles() - d := newDelegate(st) - l := list.New(nil, d, 0, 0) - l.SetShowTitle(false) - l.SetShowFilter(false) - l.SetShowStatusBar(false) - l.SetShowPagination(false) - l.SetFilteringEnabled(true) - l.DisableQuitKeybindings() - l.SetShowHelp(false) - - // Strip keys we've claimed at the Model level so the list never intercepts them. - km := l.KeyMap - km.NextPage = key.NewBinding(key.WithKeys("right", "l")) - km.PrevPage = key.NewBinding(key.WithKeys("left", "h")) - l.KeyMap = km - h := help.New() h.ShowAll = false - return Model{ app: a, - list: l, + tree: newTreeModel(st), help: h, keys: defaultKeyMap(), st: st, } } -// Init loads root entities on startup. +// Init implements tea.Model; fires the initial root entity load. func (m Model) Init() tea.Cmd { return func() tea.Msg { entities, err := m.app.GetRootEntities(context.Background()) @@ -271,144 +200,205 @@ func (m Model) Init() tea.Cmd { } } -// Update handles all incoming messages. +// --- Update --- + +// Update implements tea.Model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Route messages to active sub-model; fall through for modeBrowse. switch m.mode { case modeBrowse: - // handled below + return m.updateBrowse(msg) case modeForm: return m.updateForm(msg) case modeConfirm: return m.updateConfirm(msg) case modeScry: return m.updateScry(msg) + default: + return m.updateBrowse(msg) } - - return m.updateBrowse(msg) } +//nolint:gocognit,gocyclo,cyclop,funlen // message dispatch hub; length is inherent to handling all browse-mode messages func (m Model) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) { - if result, cmd, handled := m.handleLevelMsg(msg); handled { - return result, cmd - } - - switch msg := msg.(type) { - case historyLoadedMsg: - if msg.gen != m.history.gen { - return m, nil - } - var cmd tea.Cmd - m.history, cmd = m.history.Update(msg) - return m, cmd - - case tea.KeyPressMsg: - return m.handleKey(msg) - - case tea.WindowSizeMsg: - return m.handleWindowSize(msg), nil - } - - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m Model) handleLevelMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { - var histCmd tea.Cmd switch msg := msg.(type) { case rootsLoadedMsg: if msg.err != nil { m.err = msg.err - return m, tea.Quit, true + return m, tea.Quit } - m.pathStack = nil - m.parentStack = nil - m = m.loadLevel(msg.items, "") - m, histCmd = m.reloadHistoryCmd() - return m, histCmd, true - - case childrenLoadedMsg: + m.nodes = make([]treeNode, len(msg.items)) + for i, r := range msg.items { + m.nodes[i] = treeNodeFromResult(r, 0, "") + } + m.visible = rebuildVisible(m.nodes) + m.cursor = 0 + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd + + case treeExpandedMsg: if msg.err != nil { - m.err = msg.err - return m, tea.Quit, true + m.errMsg = fmt.Sprintf("load failed: %v", msg.err) + return m, nil } - m.pathStack = append(m.pathStack, msg.parentPath) - m.parentStack = append(m.parentStack, msg.parentID) - m = m.loadLevel(msg.items, "") - m, histCmd = m.reloadHistoryCmd() - return m, histCmd, true - - case levelRestoredMsg: + children := make([]treeNode, len(msg.items)) + for i, r := range msg.items { + children[i] = treeNodeFromResult(r, msg.depth+1, msg.parentID) + } + if pi := findNodeIndex(m.nodes, msg.parentID); pi >= 0 { + m.nodes[pi].loaded = true + m.nodes[pi].expanded = true + } + m.nodes = spliceChildren(m.nodes, msg.parentID, children) + m.visible = rebuildVisible(m.nodes) + m.cursor = setCursorToEntity(m.visible, m.nodes, msg.parentID, m.cursor) + m.cursor = clampCursor(m.cursor, len(m.visible)) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd + + case treeRefreshMsg: if msg.err != nil { - m.err = msg.err - return m, tea.Quit, true + m.errMsg = fmt.Sprintf("refresh failed: %v", msg.err) + return m, nil + } + if msg.parentID == "" { + m = m.handleRootRefresh(msg.items, msg.targetEntityID) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd + } + children := make([]treeNode, len(msg.items)) + depth := 0 + if pi := findNodeIndex(m.nodes, msg.parentID); pi >= 0 { + depth = m.nodes[pi].depth + m.nodes[pi].loaded = true } - m.pathStack = msg.pathStack - m.parentStack = msg.parentStack - m = m.loadLevel(msg.items, "") - m, histCmd = m.reloadHistoryCmd() - return m, histCmd, true + for i, r := range msg.items { + children[i] = treeNodeFromResult(r, depth+1, msg.parentID) + } + m.nodes = spliceChildren(m.nodes, msg.parentID, children) + m.visible = rebuildVisible(m.nodes) + if msg.targetEntityID != "" { + m.cursor = setCursorToEntity(m.visible, m.nodes, msg.targetEntityID, m.cursor) + } + m.cursor = clampCursor(m.cursor, len(m.visible)) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd - case childRefreshMsg: + case treeRevealMsg: if msg.err != nil { - m.errMsg = fmt.Sprintf("refresh failed: %v", msg.err) - return m, nil, true + m.errMsg = fmt.Sprintf("navigate failed: %v", msg.err) + return m, nil + } + children := make([]treeNode, len(msg.items)) + for i, r := range msg.items { + children[i] = treeNodeFromResult(r, msg.depth+1, msg.parentID) + } + if pi := findNodeIndex(m.nodes, msg.parentID); pi >= 0 { + m.nodes[pi].loaded = true + m.nodes[pi].expanded = true } - m = m.loadLevel(msg.items, msg.targetEntityID) - m, histCmd = m.reloadHistoryCmd() - return m, histCmd, true + m.nodes = spliceChildren(m.nodes, msg.parentID, children) + if len(msg.remainingPath) > 0 { + nextID := msg.remainingPath[0] + rest := msg.remainingPath[1:] + nextDepth := msg.depth + 1 + if ni := findNodeIndex(m.nodes, nextID); ni >= 0 && !m.nodes[ni].loaded { + a := m.app + return m, func() tea.Msg { + items, err := a.GetChildren(context.Background(), nextID) + return treeRevealMsg{ + parentID: nextID, + depth: nextDepth, + items: items, + remainingPath: rest, + targetEntityID: msg.targetEntityID, + err: err, + } + } + } + if ni := findNodeIndex(m.nodes, nextID); ni >= 0 { + m.nodes[ni].expanded = true + } + m.visible = rebuildVisible(m.nodes) + nextChildren := childrenOf(m.nodes, nextID) + nextMsg := treeRevealMsg{ + parentID: nextID, + depth: nextDepth, + items: nodeResultSlice(nextChildren), + remainingPath: rest, + targetEntityID: msg.targetEntityID, + } + return m.updateBrowse(nextMsg) + } + m.mode = modeBrowse + m.visible = rebuildVisible(m.nodes) + m.cursor = setCursorToEntity(m.visible, m.nodes, msg.targetEntityID, m.cursor) + m.cursor = clampCursor(m.cursor, len(m.visible)) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd case scryNavigatedMsg: if msg.err != nil { m.errMsg = fmt.Sprintf("navigate failed: %v", msg.err) - return m, nil, true + return m, nil } m.mode = modeBrowse - m.pathStack = msg.pathStack - m.parentStack = msg.parentStack - m = m.loadLevel(msg.items, msg.targetEntityID) - m, histCmd = m.reloadHistoryCmd() - return m, histCmd, true - } + if newCursor := setCursorToEntity(m.visible, m.nodes, msg.targetEntityID, -1); newCursor >= 0 { + m.cursor = newCursor + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd + } + if findNodeIndex(m.nodes, msg.targetEntityID) >= 0 { + return m.revealNode(msg.targetEntityID) + } + if len(msg.ancestorIDs) > 0 { + return m.revealViaAncestors(msg.targetEntityID, msg.ancestorIDs) + } + m = m.syncTree() + return m, nil - return m, nil, false -} + case historyLoadedMsg: + if msg.gen != m.history.gen { + return m, nil + } + var cmd tea.Cmd + m.history, cmd = m.history.Update(msg) + return m, cmd -func (m Model) handleWindowSize(msg tea.WindowSizeMsg) Model { - m.termWidth = msg.Width - m.termHeight = msg.Height - m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) - m.help.SetWidth(msg.Width) - if m.rightPane == rightPaneHistory { - m.history = m.history.Resize(m.detailPaneWidth(), m.paneHeight()) + case tea.KeyPressMsg: + return m.handleKey(msg) + + case tea.WindowSizeMsg: + return m.handleWindowSize(msg), nil } - return m -} -// loadLevel replaces the list contents and positions cursor on targetEntityID (or top if empty). -func (m Model) loadLevel(entities []app.EntityResult, targetEntityID string) Model { - m.list.SetItems(toListItems(entities)) - m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) - m.list.ResetFilter() - selectByID(&m.list, targetEntityID) - return m + return m, nil } func (m Model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - if msg.String() == keyEsc { + if kMsg, ok := msg.(tea.KeyPressMsg); ok && kMsg.String() == keyEsc { + m.mode = modeBrowse + return m, nil + } + if done, ok := msg.(actionDoneMsg); ok { + if done.err != nil { + m.errMsg = done.err.Error() m.mode = modeBrowse return m, nil } - case actionDoneMsg: - if msg.err != nil { - m.form.err = msg.err - return m, nil - } m.mode = modeBrowse - return m, m.refreshCmd(msg.result.EntityID) + return m, m.refreshCmd(done.result.EntityID, done.result.FullPathDisplay) } var cmd tea.Cmd m.form, cmd = m.form.Update(msg) @@ -418,15 +408,17 @@ func (m Model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case confirmCancelledMsg: + _ = msg m.mode = modeBrowse return m, nil case actionDoneMsg: - m.mode = modeBrowse if msg.err != nil { m.errMsg = msg.err.Error() + m.mode = modeBrowse return m, nil } - return m, m.refreshCmd(msg.result.EntityID) + m.mode = modeBrowse + return m, m.refreshCmd(msg.result.EntityID, msg.result.FullPathDisplay) } var cmd tea.Cmd m.confirm, cmd = m.confirm.Update(msg) @@ -451,52 +443,20 @@ func (m Model) updateScry(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -// refreshCmd reloads the current level after a mutation. -func (m Model) refreshCmd(targetEntityID string) tea.Cmd { - var parentID string - if len(m.parentStack) > 0 { - parentID = m.parentStack[len(m.parentStack)-1] - } - a := m.app - id := targetEntityID - if parentID == "" { - return func() tea.Msg { - items, err := a.GetRootEntities(context.Background()) - return childRefreshMsg{items: items, targetEntityID: id, err: err} - } - } - return func() tea.Msg { - items, err := a.GetChildren(context.Background(), parentID) - return childRefreshMsg{items: items, targetEntityID: id, err: err} - } -} - -// selectByID scans the current list for the entity with the given ID and selects it. -// Falls back to ResetSelected if not found (e.g. entity was removed). -func selectByID(l *list.Model, entityID string) { - if entityID == "" { - l.ResetSelected() - return - } - for i, it := range l.Items() { - if it, ok := it.(item); ok && it.result.EntityID == entityID { - l.Select(i) - return - } +func (m Model) handleWindowSize(msg tea.WindowSizeMsg) Model { + m.termWidth = msg.Width + m.termHeight = msg.Height + m.tree = m.tree.SetSize(m.navPaneInnerWidth(), m.treeHeight()) + m.help.SetWidth(msg.Width) + if m.rightPane == rightPaneHistory { + m.history = m.history.Resize(m.detailPaneWidth(), m.paneHeight()) } - l.ResetSelected() + m = m.syncTree() + return m } func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - // Delegate all input to the list while filtering. - if m.list.SettingFilter() { - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd - } - - // Navigation keys clear any transient error. - if key.Matches(msg, m.keys.Up, m.keys.Down, m.keys.DrillIn, m.keys.DrillOut) { + if key.Matches(msg, m.keys.Up, m.keys.Down, m.keys.Expand, m.keys.Collapse) { m.errMsg = "" } @@ -506,7 +466,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Help): m.help.ShowAll = !m.help.ShowAll - m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) + m.tree = m.tree.SetSize(m.navPaneInnerWidth(), m.treeHeight()) return m, nil case key.Matches(msg, m.keys.PgUp), key.Matches(msg, m.keys.PgDown): @@ -517,118 +477,320 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.history, cmd = m.history.Update(msg) return m, cmd - case key.Matches(msg, m.keys.DrillIn): - return m.drillDown() + case key.Matches(msg, m.keys.Expand): + return m.handleExpand() + + case key.Matches(msg, m.keys.Collapse): + return m.handleCollapse() - case key.Matches(msg, m.keys.DrillOut): - return m.drillUp() + case key.Matches(msg, m.keys.Up): + m.cursor = clampCursor(m.cursor-1, len(m.visible)) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd - case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down): - return m.handleNavKey(msg) + case key.Matches(msg, m.keys.Down): + m.cursor = clampCursor(m.cursor+1, len(m.visible)) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd case key.Matches(msg, m.keys.Add): return m.openAdd() - case key.Matches(msg, m.keys.Loan): return m.openLoan() - case key.Matches(msg, m.keys.Borrow): return m.openBorrow() - case key.Matches(msg, m.keys.Lost): return m.openLost() - case key.Matches(msg, m.keys.Return): return m.openReturn() - case key.Matches(msg, m.keys.Found): return m.openFound() - case key.Matches(msg, m.keys.History): return m.openHistory() - case key.Matches(msg, m.keys.Scry): return m.openScry() - case key.Matches(msg, m.keys.Detail): return m.toggleDetail() } - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd + return m, nil } -// selectedItem returns the currently highlighted item, if any. -func (m Model) selectedItem() (item, bool) { - sel, ok := m.list.SelectedItem().(item) - return sel, ok -} +// handleExpand: l/→/enter — expand if collapsed, collapse if expanded, no-op on leaf. +func (m Model) handleExpand() (tea.Model, tea.Cmd) { + if len(m.visible) == 0 { + return m, nil + } + ni := m.visible[m.cursor] + n := m.nodes[ni] -func (m Model) toggleDetail() (tea.Model, tea.Cmd) { - if _, ok := m.selectedItem(); !ok { + if !n.hasChildren { return m, nil } - if m.rightPane == rightPaneDetail { - m.rightPane = rightPaneHidden - } else { - m.rightPane = rightPaneDetail + if n.expanded { + // Already expanded → collapse. + m.nodes[ni].expanded = false + m.visible = rebuildVisible(m.nodes) + m.cursor = clampCursor(m.cursor, len(m.visible)) + m = m.syncTree() + return m, nil } - m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) + // Collapsed → expand. Lazy-load if needed. + if !n.loaded { + a := m.app + id := n.result.EntityID + depth := n.depth + return m, func() tea.Msg { + items, err := a.GetChildren(context.Background(), id) + return treeExpandedMsg{parentID: id, depth: depth, items: items, err: err} + } + } + // Already loaded — just expand. + m.nodes[ni].expanded = true + m.visible = rebuildVisible(m.nodes) + m = m.syncTree() return m, nil } -func (m Model) handleNavKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - var listCmd tea.Cmd - m.list, listCmd = m.list.Update(msg) - m, histCmd := m.reloadHistoryCmd() - if histCmd != nil { - return m, tea.Batch(listCmd, histCmd) +// handleCollapse: h/← — collapse if expanded, move to parent if collapsed/leaf. +func (m Model) handleCollapse() (tea.Model, tea.Cmd) { + if len(m.visible) == 0 { + return m, nil + } + ni := m.visible[m.cursor] + n := m.nodes[ni] + + if n.expanded { + m.nodes[ni].expanded = false + m.visible = rebuildVisible(m.nodes) + m.cursor = clampCursor(m.cursor, len(m.visible)) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd + } + // Move cursor to parent. + if n.parentID == "" { + return m, nil + } + m.cursor = setCursorToEntity(m.visible, m.nodes, n.parentID, m.cursor) + m = m.syncTree() + var hCmd tea.Cmd + m, hCmd = m.reloadHistoryCmd() + return m, hCmd +} + +// refreshCmd reloads the parent of the given entity and returns a treeRefreshMsg. +// parentPath is used to find the parent node by FullPathDisplay when parentID is unknown. +func (m Model) refreshCmd(entityID, entityFullPath string) tea.Cmd { + // Determine parentID from the target node's parentID field. + if ni := findNodeIndex(m.nodes, entityID); ni >= 0 { + parentID := m.nodes[ni].parentID + a := m.app + id := entityID + if parentID == "" { + return func() tea.Msg { + items, err := a.GetRootEntities(context.Background()) + // Roots don't have a single parentID; use empty string sentinel. + return treeRefreshMsg{parentID: "", items: items, targetEntityID: id, err: err} + } + } + return func() tea.Msg { + items, err := a.GetChildren(context.Background(), parentID) + return treeRefreshMsg{parentID: parentID, items: items, targetEntityID: id, err: err} + } + } + // Entity not yet in nodes (just created) — find parent by path. + // Parent path is entityFullPath without the last segment. + parts := strings.Split(entityFullPath, ":") + a := m.app + id := entityID + if len(parts) <= 1 { + return func() tea.Msg { + items, err := a.GetRootEntities(context.Background()) + return treeRefreshMsg{parentID: "", items: items, targetEntityID: id, err: err} + } } - return m, listCmd + parentPath := strings.Join(parts[:len(parts)-1], ":") + if pi := findNodeIndexByPath(m.nodes, parentPath); pi >= 0 { + parentID := m.nodes[pi].result.EntityID + return func() tea.Msg { + items, err := a.GetChildren(context.Background(), parentID) + return treeRefreshMsg{parentID: parentID, items: items, targetEntityID: id, err: err} + } + } + // Parent unknown — reload roots as best effort. + return func() tea.Msg { + items, err := a.GetRootEntities(context.Background()) + return treeRefreshMsg{parentID: "", items: items, targetEntityID: id, err: err} + } +} + +// treeRefreshMsg with empty parentID means roots; handle that in updateBrowse. +// Override: when parentID is "" treat as root splice. +func (m Model) handleRootRefresh(items []app.EntityResult, targetEntityID string) Model { + newRoots := make([]treeNode, len(items)) + for i, r := range items { + if existing := findNodeIndex(m.nodes, r.EntityID); existing >= 0 { + n := m.nodes[existing] + n.result = r + n.hasChildren = r.HasChildren + newRoots[i] = n + } else { + newRoots[i] = treeNodeFromResult(r, 0, "") + } + } + var nonRoots []treeNode + for _, n := range m.nodes { + if n.depth > 0 { + nonRoots = append(nonRoots, n) + } + } + combined := make([]treeNode, 0, len(newRoots)+len(nonRoots)) + combined = append(combined, newRoots...) + combined = append(combined, nonRoots...) + m.nodes = combined + m.visible = rebuildVisible(m.nodes) + if targetEntityID != "" { + m.cursor = setCursorToEntity(m.visible, m.nodes, targetEntityID, m.cursor) + } + m.cursor = clampCursor(m.cursor, len(m.visible)) + return m +} + +// revealNode expands all loaded ancestor nodes to make entityID visible. +func (m Model) revealNode(entityID string) (tea.Model, tea.Cmd) { + ni := findNodeIndex(m.nodes, entityID) + if ni < 0 { + return m, nil + } + // Walk ancestors upward and expand each. + pid := m.nodes[ni].parentID + for pid != "" { + if pi := findNodeIndex(m.nodes, pid); pi >= 0 { + m.nodes[pi].expanded = true + pid = m.nodes[pi].parentID + } else { + break + } + } + m.visible = rebuildVisible(m.nodes) + m.cursor = setCursorToEntity(m.visible, m.nodes, entityID, m.cursor) + m.cursor = clampCursor(m.cursor, len(m.visible)) + m = m.syncTree() + m2, cmd := m.reloadHistoryCmd() + return m2, cmd +} + +// revealViaAncestors fires GetChildren for each unloaded ancestor in order. +func (m Model) revealViaAncestors(targetEntityID string, ancestorIDs []string) (tea.Model, tea.Cmd) { + if len(ancestorIDs) == 0 { + return m.revealNode(targetEntityID) + } + firstID := ancestorIDs[0] + rest := ancestorIDs[1:] + firstDepth := 0 + // Determine depth of first ancestor (it must be a root or already loaded). + if ni := findNodeIndex(m.nodes, firstID); ni >= 0 { + firstDepth = m.nodes[ni].depth + if !m.nodes[ni].loaded { + a := m.app + return m, func() tea.Msg { + items, err := a.GetChildren(context.Background(), firstID) + return treeRevealMsg{ + parentID: firstID, + depth: firstDepth, + items: items, + remainingPath: rest, + targetEntityID: targetEntityID, + err: err, + } + } + } + m.nodes[ni].expanded = true + } + m.visible = rebuildVisible(m.nodes) + m = m.syncTree() + return m.revealViaAncestors(targetEntityID, rest) +} + +// childrenOf returns all direct children of parentID from nodes. +func childrenOf(nodes []treeNode, parentID string) []treeNode { + var out []treeNode + for _, n := range nodes { + if n.parentID == parentID { + out = append(out, n) + } + } + return out +} + +// nodeResultSlice extracts EntityResult from a slice of treeNodes. +func nodeResultSlice(nodes []treeNode) []app.EntityResult { + out := make([]app.EntityResult, len(nodes)) + for i, n := range nodes { + out[i] = n.result + } + return out +} + +// syncTree updates the treeModel's cursor, visible slice, and re-renders. +func (m Model) syncTree() Model { + m.tree.cursor = m.cursor + m.tree.visible = m.visible + m.tree = m.tree.render(m.nodes) + m.tree = m.tree.scrollToCursor() + return m } -// reloadHistoryCmd reinitialises the history model for the current selection -// and returns the updated Model and a loadCmd. Returns (m, nil) when the -// history pane is not open or no entity is selected. func (m Model) reloadHistoryCmd() (Model, tea.Cmd) { if m.rightPane != rightPaneHistory { return m, nil } - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m, nil } - m.history = newHistoryModel(sel.result, m.app, m.st, m.detailPaneWidth(), m.paneHeight(), m.history.gen+1) + m.history = newHistoryModel(r, m.app, m.st, m.detailPaneWidth(), m.paneHeight(), m.history.gen+1) return m, m.history.loadCmd() } -// gateError sets errMsg and returns the model unchanged (no mode switch). func (m Model) gateError(msg string) (tea.Model, tea.Cmd) { m.errMsg = msg return m, nil } +// selectedResult returns the app.EntityResult for the currently focused node. +func (m Model) selectedResult() (app.EntityResult, bool) { + if len(m.visible) == 0 || m.cursor < 0 || m.cursor >= len(m.visible) { + return app.EntityResult{}, false + } + return m.nodes[m.visible[m.cursor]].result, true +} + func (m Model) openAdd() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } - if sel.result.Discrete { + if r.Discrete { return m.gateError("cannot add: entity is discrete (no children allowed)") } m.errMsg = "" - m.form = newFormModel(formAdd, sel.result, m.app, m.st) + m.form = newFormModel(formAdd, r, m.app, m.st) m.mode = modeForm return m, nil } func (m Model) openLoan() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } - r := sel.result if r.Locked { return m.gateError("cannot loan: entity is locked") } @@ -642,22 +804,21 @@ func (m Model) openLoan() (tea.Model, tea.Cmd) { } func (m Model) openBorrow() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } m.errMsg = "" - m.form = newFormModel(formBorrow, sel.result, m.app, m.st) + m.form = newFormModel(formBorrow, r, m.app, m.st) m.mode = modeForm return m, nil } func (m Model) openLost() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } - r := sel.result if r.Locked { return m.gateError("cannot mark lost: entity is locked") } @@ -671,11 +832,10 @@ func (m Model) openLost() (tea.Model, tea.Cmd) { } func (m Model) openReturn() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } - r := sel.result if r.Status != inventory.EntityStatusLoaned && r.Status != inventory.EntityStatusBorrowed { return m.gateError("cannot return: entity must be loaned or borrowed (is " + r.Status.String() + ")") } @@ -686,11 +846,10 @@ func (m Model) openReturn() (tea.Model, tea.Cmd) { } func (m Model) openFound() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } - r := sel.result if r.Status != inventory.EntityStatusMissing { return m.gateError("cannot mark found: entity must be missing (is " + r.Status.String() + ")") } @@ -701,19 +860,19 @@ func (m Model) openFound() (tea.Model, tea.Cmd) { } func (m Model) openHistory() (tea.Model, tea.Cmd) { - sel, ok := m.selectedItem() + r, ok := m.selectedResult() if !ok { return m.gateError("no entity selected") } if m.rightPane == rightPaneHistory { m.rightPane = rightPaneHidden - m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) + m.tree = m.tree.SetSize(m.navPaneInnerWidth(), m.treeHeight()) return m, nil } m.errMsg = "" m.rightPane = rightPaneHistory - m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) - m.history = newHistoryModel(sel.result, m.app, m.st, m.detailPaneWidth(), m.paneHeight(), m.history.gen+1) + m.tree = m.tree.SetSize(m.navPaneInnerWidth(), m.treeHeight()) + m.history = newHistoryModel(r, m.app, m.st, m.detailPaneWidth(), m.paneHeight(), m.history.gen+1) return m, m.history.loadCmd() } @@ -725,7 +884,22 @@ func (m Model) openScry() (tea.Model, tea.Cmd) { return m, nil } -// View renders the full TUI. +func (m Model) toggleDetail() (tea.Model, tea.Cmd) { + if _, ok := m.selectedResult(); !ok { + return m, nil + } + if m.rightPane == rightPaneDetail { + m.rightPane = rightPaneHidden + } else { + m.rightPane = rightPaneDetail + } + m.tree = m.tree.SetSize(m.navPaneInnerWidth(), m.treeHeight()) + return m, nil +} + +// --- View --- + +// View implements tea.Model. func (m Model) View() tea.View { if m.err != nil { v := tea.NewView(fmt.Sprintf("error: %v\n", m.err)) @@ -761,19 +935,22 @@ func (m Model) View() tea.View { return v } -// CurrentPath returns the breadcrumb string for the current navigation level. +// --- exported accessors for tests --- + +// CurrentPath returns the FullPathDisplay of the currently focused entity, or "wherehouse" at root with no selection. func (m Model) CurrentPath() string { - if len(m.pathStack) == 0 { + r, ok := m.selectedResult() + if !ok { return "wherehouse" } - return m.pathStack[len(m.pathStack)-1] + return r.FullPathDisplay } -// ItemCount returns the number of items currently displayed. -func (m Model) ItemCount() int { return len(m.list.Items()) } +// ItemCount returns the number of currently visible nodes. +func (m Model) ItemCount() int { return len(m.visible) } // CursorIndex returns the index of the currently highlighted item. -func (m Model) CursorIndex() int { return m.list.Index() } +func (m Model) CursorIndex() int { return m.cursor } // RightPane returns the current right pane state: "hidden", "detail", or "history". func (m Model) RightPane() string { @@ -784,8 +961,9 @@ func (m Model) RightPane() string { return "history" case rightPaneHidden: return "hidden" + default: + return "hidden" } - return "hidden" } // Mode returns the current mode name for test assertions. @@ -819,8 +997,7 @@ func (m Model) navPaneWidth() int { if m.rightPane == rightPaneHidden { return m.termWidth } - w := max(int(float64(m.termWidth)*navWidthRatio), navPaneMinWidth) - return w + return max(int(float64(m.termWidth)*navWidthRatio), navPaneMinWidth) } func (m Model) navPaneInnerWidth() int { @@ -837,7 +1014,6 @@ func (m Model) detailPaneInnerWidth() int { func (m Model) helpHeight() int { if m.help.ShowAll { - // FullHelp renders len(groups) rows + 1 for the short-help fallback line. return len(m.keys.FullHelp()) + 1 } return helpHeightShort @@ -847,8 +1023,8 @@ func (m Model) paneHeight() int { return max(0, m.termHeight-headerHeight-m.helpHeight()) } -func (m Model) listHeight() int { - return max(0, m.paneHeight()-borderHeight-crumbHeight-listViewOverhead) +func (m Model) treeHeight() int { + return max(0, m.paneHeight()-borderHeight) } func (m Model) renderHeader() string { @@ -857,25 +1033,10 @@ func (m Model) renderHeader() string { } func (m Model) renderNavPane() string { - crumbText := "wherehouse" - if len(m.pathStack) > 0 { - crumbs := make([]string, len(m.pathStack)) - for i, p := range m.pathStack { - if parsed, err := entitypath.Parse(p); err == nil { - crumbs[i] = parsed.Base() - } else { - crumbs[i] = p - } - } - crumbText = strings.Join(crumbs, " › ") - } - crumb := m.st.TUICrumb().Render(crumbText) - inner := lipgloss.JoinVertical(lipgloss.Left, crumb, m.list.View()) - return m.st.TUINavBorder(). Width(m.navPaneWidth()). Height(m.paneHeight()). - Render(inner) + Render(m.tree.View()) } func (m Model) renderDetailPane() string { @@ -901,32 +1062,20 @@ func (m Model) buildDetailContent() string { prefix = m.st.DangerText().Render(m.errMsg) + "\n\n" } - sel, ok := m.list.SelectedItem().(item) - if !ok || m.list.IsFiltered() && m.list.SelectedItem() == nil { - return prefix + m.st.TUIDetailValue().Render("—") - } + r, ok := m.selectedResult() if !ok { return prefix + m.st.TUIDetailValue().Render("no selection") } - r := sel.result w := m.detailPaneInnerWidth() - label := func(s string) string { - return m.st.TUIDetailLabel().Render(s + ":") - } - val := func(s string) string { - return m.st.TUIDetailValue().Render(s) - } - row := func(l, v string) string { - return lipgloss.JoinHorizontal(lipgloss.Top, l, " ", v) - } + label := func(s string) string { return m.st.TUIDetailLabel().Render(s + ":") } + val := func(s string) string { return m.st.TUIDetailValue().Render(s) } + row := func(l, v string) string { return lipgloss.JoinHorizontal(lipgloss.Top, l, " ", v) } var lines []string - lines = append(lines, m.st.TUICrumb().Width(w).Render(r.DisplayName)) lines = append(lines, "") - lines = append(lines, row(label("status"), statusDisplay(r, m.st))) if !r.UpdatedAt.IsZero() { @@ -965,10 +1114,9 @@ func (m Model) renderHelp() string { return m.help.View(m.keys) } -// statusDisplay renders a colored status string for the detail pane. func statusDisplay(r app.EntityResult, st *styles.Styles) string { s := r.Status.String() - switch r.Status.String() { + switch s { case "ok": return st.SuccessText().Render(s) case "missing": @@ -979,45 +1127,3 @@ func statusDisplay(r app.EntityResult, st *styles.Styles) string { return st.Muted().Render(s) } } - -func (m Model) drillDown() (tea.Model, tea.Cmd) { - selected, ok := m.list.SelectedItem().(item) - if !ok || !selected.result.HasChildren { - return m, nil - } - id := selected.result.EntityID - path := selected.result.FullPathDisplay - return m, func() tea.Msg { - children, err := m.app.GetChildren(context.Background(), id) - return childrenLoadedMsg{parentID: id, parentPath: path, items: children, err: err} - } -} - -func (m Model) drillUp() (tea.Model, tea.Cmd) { - if len(m.pathStack) == 0 { - return m, nil - } - newLen := len(m.pathStack) - 1 - targetPath := append([]string(nil), m.pathStack[:newLen]...) - targetParent := append([]string(nil), m.parentStack[:newLen]...) - - if len(targetParent) == 0 { - return m, func() tea.Msg { - entities, err := m.app.GetRootEntities(context.Background()) - return rootsLoadedMsg{items: entities, err: err} - } - } - parentID := targetParent[len(targetParent)-1] - return m, func() tea.Msg { - children, err := m.app.GetChildren(context.Background(), parentID) - return levelRestoredMsg{pathStack: targetPath, parentStack: targetParent, items: children, err: err} - } -} - -func toListItems(entities []app.EntityResult) []list.Item { - items := make([]list.Item, len(entities)) - for i, e := range entities { - items[i] = item{result: e} - } - return items -} diff --git a/internal/tui/scry.go b/internal/tui/scry.go index f0a8c94..92b5ffb 100644 --- a/internal/tui/scry.go +++ b/internal/tui/scry.go @@ -17,9 +17,17 @@ import ( // scryUIOverhead is the number of terminal rows used by title, input, and help bar. const scryUIOverhead = 4 +type scryFocus int + +const ( + scryFocusInput scryFocus = iota + scryFocusList +) + type scryModel struct { input textinput.Model results list.Model + focus scryFocus appRef App st *styles.Styles } @@ -51,6 +59,7 @@ func newScryModel(a App, st *styles.Styles) scryModel { return scryModel{ input: inp, results: l, + focus: scryFocusInput, appRef: a, st: st, } @@ -65,26 +74,60 @@ func (s scryModel) searchCmd() tea.Cmd { } } +func (s scryModel) navigate() tea.Cmd { + sel, ok := s.results.SelectedItem().(scryItem) + if !ok { + return nil + } + entity := sel.result.Entity + return func() tea.Msg { + return scryNavigatedMsg{targetEntityID: entity.EntityID} + } +} + func (s scryModel) Update(msg tea.Msg) (scryModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: - switch msg.String() { - case keyEsc: + key := msg.String() + + // Esc always cancels, regardless of focus. + if key == keyEsc { return s, func() tea.Msg { return scryCancelledMsg{} } + } - case "enter": - sel, ok := s.results.SelectedItem().(scryItem) - if !ok { - return s, nil - } - entity := sel.result.Entity - a := s.appRef - return s, s.navigateCmd(entity, a) + // Enter always navigates to the selected result. + if key == "enter" { + return s, s.navigate() } - var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - return s, tea.Batch(cmd, s.searchCmd()) + // Tab or ↓ from input moves focus to the list. + if s.focus == scryFocusInput && (key == "tab" || key == "down") { + s.focus = scryFocusList + s.input.Blur() + var cmd tea.Cmd + s.results, cmd = s.results.Update(msg) + return s, cmd + } + + // Any printable character while list is focused returns focus to the input. + if s.focus == scryFocusList && len([]rune(key)) == 1 { + s.focus = scryFocusInput + s.input.Focus() + var inputCmd tea.Cmd + s.input, inputCmd = s.input.Update(msg) + return s, tea.Batch(inputCmd, s.searchCmd()) + } + + if s.focus == scryFocusList { + var cmd tea.Cmd + s.results, cmd = s.results.Update(msg) + return s, cmd + } + + // Input focused: forward to text input and re-search. + var inputCmd tea.Cmd + s.input, inputCmd = s.input.Update(msg) + return s, tea.Batch(inputCmd, s.searchCmd()) case scryResultsMsg: if msg.err != nil { @@ -109,28 +152,18 @@ func (s scryModel) Update(msg tea.Msg) (scryModel, tea.Cmd) { type scryCancelledMsg struct{} -// navigateCmd loads root entities and returns a scryNavigatedMsg targeting the entity. -// EntityResult does not carry parentID, so we always reload from root and let the -// Update handler position the cursor by ID. Deep navigation is a future improvement -// once EntityResult exposes parentID. -func (s scryModel) navigateCmd(entity app.EntityResult, a App) tea.Cmd { - return func() tea.Msg { - roots, err := a.GetRootEntities(context.Background()) - return scryNavigatedMsg{ - items: roots, - targetEntityID: entity.EntityID, - pathStack: nil, - parentStack: nil, - err: err, - } +func (s scryModel) helpText() string { + if s.focus == scryFocusInput { + return "[tab/↓] select result [enter] navigate [esc] cancel" } + return "[j/k] move [enter] navigate [any char] search [esc] cancel" } func (s scryModel) View(width, height int) string { title := s.st.TUIDetailLabel().Render("scry") inp := s.input.View() results := s.results.View() - help := s.st.Muted().Render("[enter] navigate [esc] cancel") + help := s.st.Muted().Render(s.helpText()) return lipgloss.NewStyle().Width(width).Height(height).Render( strings.Join([]string{title, inp, results, help}, "\n"), ) diff --git a/internal/tui/tree.go b/internal/tui/tree.go new file mode 100644 index 0000000..76d31e7 --- /dev/null +++ b/internal/tui/tree.go @@ -0,0 +1,250 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/viewport" + + "github.com/asphaltbuffet/wherehouse/internal/app" + "github.com/asphaltbuffet/wherehouse/internal/inventory" + "github.com/asphaltbuffet/wherehouse/internal/styles" +) + +const ( + treeIndent = " " + treeItemArrow = "▶ " + treeItemLeaf = " " +) + +// treeNode represents one entity in the tree, whether visible or not. +type treeNode struct { + result app.EntityResult // full result for detail pane rendering + hasChildren bool + loaded bool // true once GetChildren has been called for this node + expanded bool + depth int + parentID string // "" for root nodes +} + +func treeNodeFromResult(r app.EntityResult, depth int, parentID string) treeNode { + return treeNode{ + result: r, + hasChildren: r.HasChildren, + depth: depth, + parentID: parentID, + } +} + +// treeModel is a viewport-backed tree widget. +type treeModel struct { + vp viewport.Model + st *styles.Styles + cursor int // index into the visible slice + visible []int // indices into Model.nodes that are currently shown +} + +func newTreeModel(st *styles.Styles) treeModel { + vp := viewport.New() + return treeModel{vp: vp, st: st} +} + +func (t treeModel) SetSize(w, h int) treeModel { + t.vp.SetWidth(w) + t.vp.SetHeight(h) + return t +} + +// render rebuilds the viewport content from the visible slice of nodes. +func (t treeModel) render(nodes []treeNode) treeModel { + if len(t.visible) == 0 { + t.vp.SetContent("") + return t + } + lines := make([]string, len(t.visible)) + for i, ni := range t.visible { + n := nodes[ni] + lines[i] = t.renderLine(n, i == t.cursor) + } + t.vp.SetContent(strings.Join(lines, "\n")) + return t +} + +func (t treeModel) renderLine(n treeNode, selected bool) string { + indent := strings.Repeat(treeIndent, n.depth) + + var arrow string + if n.hasChildren { + if n.expanded { + arrow = "▼ " + } else { + arrow = "▶ " + } + } else { + arrow = treeItemLeaf + } + + label := n.result.DisplayName + if n.result.Locked { + label += " 🔒" + } + + statusSuffix := "" + if n.result.Status != inventory.EntityStatusOk { + statusSuffix = " [" + n.result.Status.String() + "]" + } + + line := indent + arrow + label + statusSuffix + if selected { + return t.st.TUISelected().Render(line) + } + return line +} + +// rebuildVisible rebuilds the visible index slice from nodes, respecting expanded state. +// Call this after any structural change (expand, collapse, splice, delete). +func rebuildVisible(nodes []treeNode) []int { + visible := make([]int, 0, len(nodes)) + expandedIDs := make(map[string]bool) + for _, n := range nodes { + if n.expanded { + expandedIDs[n.result.EntityID] = true + } + } + for i, n := range nodes { + if n.depth == 0 { + visible = append(visible, i) + continue + } + if allAncestorsExpanded(nodes, n, expandedIDs) { + visible = append(visible, i) + } + } + return visible +} + +// allAncestorsExpanded returns true if every ancestor of n is expanded. +func allAncestorsExpanded(nodes []treeNode, n treeNode, expandedIDs map[string]bool) bool { + pid := n.parentID + for pid != "" { + if !expandedIDs[pid] { + return false + } + found := false + for _, p := range nodes { + if p.result.EntityID == pid { + pid = p.parentID + found = true + break + } + } + if !found { + break + } + } + return true +} + +// clampCursor keeps cursor in [0, len(visible)-1]. +func clampCursor(cursor, visibleLen int) int { + if visibleLen == 0 { + return 0 + } + if cursor < 0 { + return 0 + } + if cursor >= visibleLen { + return visibleLen - 1 + } + return cursor +} + +// scrollToCursor adjusts the viewport so the cursor line is visible. +func (t treeModel) scrollToCursor() treeModel { + if t.cursor < t.vp.YOffset() { + t.vp.SetYOffset(t.cursor) + } else if t.cursor >= t.vp.YOffset()+t.vp.Height() { + t.vp.SetYOffset(t.cursor - t.vp.Height() + 1) + } + return t +} + +// spliceChildren inserts children of parentID immediately after the parent in nodes. +// Existing children of that parent (already loaded) are removed first. +func spliceChildren(nodes []treeNode, parentID string, children []treeNode) []treeNode { + nodes = removeSubtree(nodes, parentID) + parentIdx := -1 + for i, n := range nodes { + if n.result.EntityID == parentID { + parentIdx = i + break + } + } + if parentIdx < 0 { + return nodes + } + result := make([]treeNode, 0, len(nodes)+len(children)) + result = append(result, nodes[:parentIdx+1]...) + result = append(result, children...) + result = append(result, nodes[parentIdx+1:]...) + return result +} + +// removeSubtree removes all descendants of parentID from nodes. +func removeSubtree(nodes []treeNode, parentID string) []treeNode { + descendants := make(map[string]bool) + descendants[parentID] = true + changed := true + for changed { + changed = false + for _, n := range nodes { + if !descendants[n.result.EntityID] && descendants[n.parentID] { + descendants[n.result.EntityID] = true + changed = true + } + } + } + delete(descendants, parentID) + result := make([]treeNode, 0, len(nodes)) + for _, n := range nodes { + if !descendants[n.result.EntityID] { + result = append(result, n) + } + } + return result +} + +// findNodeIndex returns the first index in nodes where entityID matches, or -1. +func findNodeIndex(nodes []treeNode, entityID string) int { + for i, n := range nodes { + if n.result.EntityID == entityID { + return i + } + } + return -1 +} + +// findNodeIndexByPath returns the first index in nodes where fullPath matches, or -1. +func findNodeIndexByPath(nodes []treeNode, fullPath string) int { + for i, n := range nodes { + if n.result.FullPathDisplay == fullPath { + return i + } + } + return -1 +} + +// setCursorToEntity moves the cursor to the visible row for entityID. +// Returns the new cursor position (unchanged if not found). +func setCursorToEntity(visible []int, nodes []treeNode, entityID string, current int) int { + for i, ni := range visible { + if nodes[ni].result.EntityID == entityID { + return i + } + } + return current +} + +// View returns the rendered viewport string. +func (t treeModel) View() string { + return t.vp.View() +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7cc5336..2684c2d 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -146,6 +146,20 @@ func loadedModelFake(t *testing.T, f *fakeTUIApp) tui.Model { return updated.(tui.Model) } +// expandNode presses l on the current cursor position and waits for the +// treeExpandedMsg to come back, returning the settled model. +func expandNode(t *testing.T, m tui.Model) tui.Model { + t.Helper() + updated, cmd := m.Update(keyMsg("l")) + m = updated.(tui.Model) + if cmd != nil { + childMsg := cmd() + updated2, _ := m.Update(childMsg) + m = updated2.(tui.Model) + } + return m +} + func TestModel_InitLoadsRootEntities(t *testing.T) { roots := []app.EntityResult{ entityResult("e1", "Garage", "Garage", true), @@ -155,21 +169,20 @@ func TestModel_InitLoadsRootEntities(t *testing.T) { m := tui.New(f) - // Init returns a Cmd that fetches root entities; simulate receiving the result. initCmd := m.Init() require.NotNil(t, initCmd) - // Execute the command to get the message it produces. msg := initCmd() updatedModel, _ := m.Update(msg) updated := updatedModel.(tui.Model) assert.Equal(t, 2, updated.ItemCount()) - assert.Equal(t, "wherehouse", updated.CurrentPath()) + // At root with cursor on first entity. + assert.Equal(t, "Garage", updated.CurrentPath()) } -func TestModel_DrillDown(t *testing.T) { +func TestModel_ExpandCollapse(t *testing.T) { children := []app.EntityResult{ entityResult("e10", "Toolbox", "Garage:Toolbox", false), entityResult("e11", "Ladder", "Garage:Ladder", false), @@ -183,20 +196,27 @@ func TestModel_DrillDown(t *testing.T) { children: map[string][]app.EntityResult{"e1": children}, } - t.Run("l on entity with children loads children and updates path", func(t *testing.T) { + t.Run("l on entity with children expands it and shows children", func(t *testing.T) { m := loadedModelFake(t, f) + // Cursor on Garage (index 0, HasChildren=true). + m = expandNode(t, m) - // Press l to drill into Garage (index 0, HasChildren=true). - drillCmd := func() tea.Model { - updated, cmd := m.Update(keyMsg("l")) - require.NotNil(t, cmd) - childMsg := cmd() - updated, _ = updated.(tui.Model).Update(childMsg) - return updated - }() + // Visible: Garage, Toolbox, Ladder, Shelf = 4 items. + assert.Equal(t, 4, m.ItemCount()) + // Cursor remains on Garage. + assert.Equal(t, "Garage", m.CurrentPath()) + }) + + t.Run("l on already-expanded node collapses it", func(t *testing.T) { + m := loadedModelFake(t, f) + m = expandNode(t, m) + require.Equal(t, 4, m.ItemCount()) - assert.Equal(t, "Garage", drillCmd.(tui.Model).CurrentPath()) - assert.Equal(t, 2, drillCmd.(tui.Model).ItemCount()) + // Press l again — collapses Garage, children hidden. + updated, cmd := m.Update(keyMsg("l")) + m = updated.(tui.Model) + assert.Nil(t, cmd) + assert.Equal(t, 2, m.ItemCount()) }) t.Run("l on childless entity is no-op", func(t *testing.T) { @@ -207,110 +227,68 @@ func TestModel_DrillDown(t *testing.T) { updated2, cmd := m2.Update(keyMsg("l")) assert.Nil(t, cmd) - assert.Equal(t, "wherehouse", updated2.(tui.Model).CurrentPath()) assert.Equal(t, 2, updated2.(tui.Model).ItemCount()) }) } -func TestModel_DrillUp(t *testing.T) { - grandchildren := []app.EntityResult{ - entityResult("e20", "Office", "Basement:Office", false), - } +func TestModel_CollapseNavigation(t *testing.T) { children := []app.EntityResult{ entityResult("e10", "Toolbox", "Garage:Toolbox", false), - entityResult("e11", "Basement", "Basement", true), } roots := []app.EntityResult{ entityResult("e1", "Garage", "Garage", true), - entityResult("e2", "Basement", "Basement", true), - entityResult("e3", "Shelf", "Shelf", false), + entityResult("e2", "Shelf", "Shelf", false), } f := &fakeTUIApp{ - roots: roots, - children: map[string][]app.EntityResult{ - "e1": children, - "e11": grandchildren, - }, + roots: roots, + children: map[string][]app.EntityResult{"e1": children}, } - t.Run("h after single drill-down returns to root", func(t *testing.T) { + t.Run("h on expanded node collapses it", func(t *testing.T) { m := loadedModelFake(t, f) + m = expandNode(t, m) + require.Equal(t, 3, m.ItemCount()) - // Drill down into Garage (index 0). - _, cmd := m.Update(keyMsg("l")) - drilled, _ := m.Update(cmd()) - require.Equal(t, "Garage", drilled.(tui.Model).CurrentPath()) - - // Drill up. - _, upCmd := drilled.(tui.Model).Update(keyMsg("h")) - require.NotNil(t, upCmd) - back, _ := drilled.(tui.Model).Update(upCmd()) - - assert.Equal(t, "wherehouse", back.(tui.Model).CurrentPath()) - assert.Equal(t, 3, back.(tui.Model).ItemCount()) + updated, cmd := m.Update(keyMsg("h")) + m2 := updated.(tui.Model) + assert.Nil(t, cmd) + assert.Equal(t, 2, m2.ItemCount()) + assert.Equal(t, "Garage", m2.CurrentPath()) }) - t.Run("h after two drill-downs returns to intermediate level not root", func(t *testing.T) { + t.Run("h on collapsed child moves cursor to parent", func(t *testing.T) { m := loadedModelFake(t, f) + // Expand Garage, move cursor to Toolbox. + m = expandNode(t, m) + updated, _ := m.Update(keyMsg("j")) + m = updated.(tui.Model) + require.Equal(t, "Garage:Toolbox", m.CurrentPath()) - // Drill into Garage children. - _, cmd := m.Update(keyMsg("l")) - depth1, _ := m.Update(cmd()) - require.Equal(t, "Garage", depth1.(tui.Model).CurrentPath()) - - // Select the Basement child (index 1) and drill in. - depth1, _ = depth1.(tui.Model).Update(keyMsg("j")) - _, cmd2 := depth1.(tui.Model).Update(keyMsg("l")) - depth2, _ := depth1.(tui.Model).Update(cmd2()) - require.Equal(t, "Basement", depth2.(tui.Model).CurrentPath()) - - // Press h — must go back to Garage children, not root. - _, upCmd := depth2.(tui.Model).Update(keyMsg("h")) - require.NotNil(t, upCmd) - back, _ := depth2.(tui.Model).Update(upCmd()) - - assert.Equal(t, "Garage", back.(tui.Model).CurrentPath()) - assert.Equal(t, 2, back.(tui.Model).ItemCount()) + // h moves cursor to parent (Garage), no load needed. + updated2, cmd := m.Update(keyMsg("h")) + m2 := updated2.(tui.Model) + assert.Nil(t, cmd) + assert.Equal(t, "Garage", m2.CurrentPath()) }) - t.Run("h twice from two levels deep reaches root", func(t *testing.T) { + t.Run("h at root collapsed node is no-op", func(t *testing.T) { m := loadedModelFake(t, f) + assert.Equal(t, "Garage", m.CurrentPath()) - // Drill Garage → Basement child. - _, cmd := m.Update(keyMsg("l")) - depth1, _ := m.Update(cmd()) - depth1, _ = depth1.(tui.Model).Update(keyMsg("j")) - _, cmd2 := depth1.(tui.Model).Update(keyMsg("l")) - depth2, _ := depth1.(tui.Model).Update(cmd2()) - - // First h → back to Garage. - _, upCmd := depth2.(tui.Model).Update(keyMsg("h")) - mid, _ := depth2.(tui.Model).Update(upCmd()) - require.Equal(t, "Garage", mid.(tui.Model).CurrentPath()) - - // Second h → back to root. - _, upCmd2 := mid.(tui.Model).Update(keyMsg("h")) - require.NotNil(t, upCmd2) - root, _ := mid.(tui.Model).Update(upCmd2()) - assert.Equal(t, "wherehouse", root.(tui.Model).CurrentPath()) - assert.Equal(t, 3, root.(tui.Model).ItemCount()) - }) - - t.Run("h at root is no-op", func(t *testing.T) { - m := loadedModelFake(t, f) updated, cmd := m.Update(keyMsg("h")) + m2 := updated.(tui.Model) assert.Nil(t, cmd) - assert.Equal(t, "wherehouse", updated.(tui.Model).CurrentPath()) + assert.Equal(t, "Garage", m2.CurrentPath()) }) } func TestModel_QuitKeys(t *testing.T) { roots := []app.EntityResult{entityResult("e1", "Garage", "Garage", false)} - for _, key := range []string{"q", "Q"} { - t.Run(fmt.Sprintf("%s quits", key), func(t *testing.T) { + for _, k := range []string{"q", "Q"} { + t.Run(fmt.Sprintf("%s quits", k), func(t *testing.T) { m := loadedModel(t, roots) - _, cmd := m.Update(keyMsg(key)) + _, cmd := m.Update(keyMsg(k)) require.NotNil(t, cmd) msg := cmd() assert.Equal(t, tea.QuitMsg{}, msg) @@ -430,12 +408,10 @@ func TestModel_ActionGating(t *testing.T) { f := &fakeTUIApp{roots: []app.EntityResult{locked, shelf}} m := loadedModelFake(t, f) - // Trigger an errMsg. updated, _ := m.Update(keyMsg("L")) m2 := updated.(tui.Model) require.NotEmpty(t, m2.ErrMsg()) - // Navigate down — errMsg should clear. updated2, _ := m2.Update(keyMsg("j")) assert.Empty(t, updated2.(tui.Model).ErrMsg()) }) @@ -498,7 +474,6 @@ func TestModel_ConfirmMode(t *testing.T) { updated, _ := m.Update(keyMsg("x")) require.Equal(t, "confirm", updated.(tui.Model).Mode()) - // esc emits confirmCancelledMsg as a Cmd; execute it then feed back. updated2, cancelCmd := updated.(tui.Model).Update(keyMsg("esc")) require.NotNil(t, cancelCmd) updated3, _ := updated2.(tui.Model).Update(cancelCmd()) @@ -509,11 +484,9 @@ func TestModel_ConfirmMode(t *testing.T) { f := &fakeTUIApp{roots: []app.EntityResult{okEntity}} m := loadedModelFake(t, f) - // Open confirm. updated, _ := m.Update(keyMsg("x")) require.Equal(t, "confirm", updated.(tui.Model).Mode()) - // Type a character — must land in the note field. updated2, _ := updated.(tui.Model).Update(keyMsg("g")) assert.Equal(t, "g", updated2.(tui.Model).ConfirmNote()) }) @@ -538,23 +511,21 @@ func TestModel_ConfirmMode(t *testing.T) { } m := loadedModelFake(t, f) - // Open confirm and type a note. updated, _ := m.Update(keyMsg("x")) require.Equal(t, "confirm", updated.(tui.Model).Mode()) updated, _ = updated.(tui.Model).Update(keyMsg("m")) updated, _ = updated.(tui.Model).Update(keyMsg("i")) updated, _ = updated.(tui.Model).Update(keyMsg("a")) - // Submit — note must reach the app call. _, submitCmd := updated.(tui.Model).Update(keyMsg("enter")) require.NotNil(t, submitCmd) - submitCmd() // execute to trigger MarkLost on the fake + submitCmd() require.Len(t, f.lastLostReqs, 1) assert.Equal(t, "mia", f.lastLostReqs[0].Note) }) - t.Run("enter submits lost and refreshes list", func(t *testing.T) { + t.Run("enter submits lost and refreshes tree", func(t *testing.T) { refreshed := entityResultWithStatus("e1", "Wrench", "Garage:Wrench", inventory.EntityStatusMissing, false) f := &fakeTUIApp{ roots: []app.EntityResult{okEntity}, @@ -562,24 +533,22 @@ func TestModel_ConfirmMode(t *testing.T) { } m := loadedModelFake(t, f) - // Open confirm. updated, _ := m.Update(keyMsg("x")) require.Equal(t, "confirm", updated.(tui.Model).Mode()) - // Press enter — fires submitCmd. updated2, submitCmd := updated.(tui.Model).Update(keyMsg("enter")) require.NotNil(t, submitCmd) - // Execute submitCmd → actionDoneMsg. actionMsg := submitCmd() updated3, refreshCmd := updated2.(tui.Model).Update(actionMsg) assert.Equal(t, "browse", updated3.(tui.Model).Mode()) require.NotNil(t, refreshCmd) - // Execute refreshCmd → childRefreshMsg. + // Execute refreshCmd → treeRefreshMsg with updated entity. refreshMsg := refreshCmd() updated4, _ := updated3.(tui.Model).Update(refreshMsg) assert.Equal(t, "browse", updated4.(tui.Model).Mode()) + // Root level still has 1 entity. assert.Equal(t, 1, updated4.(tui.Model).ItemCount()) }) } @@ -650,11 +619,9 @@ func TestModel_HistoryRendersEvents(t *testing.T) { } m := loadedModelFake(t, f) - // Size the terminal so the viewport has non-zero dimensions. sized, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = sized.(tui.Model) - // Open history and execute the load command. afterOpen, loadCmd := m.Update(keyMsg("H")) m2 := afterOpen.(tui.Model) require.Equal(t, "history", m2.RightPane()) @@ -686,7 +653,6 @@ func TestModel_HistoryPane(t *testing.T) { assert.Equal(t, "browse", m2.Mode()) require.NotNil(t, loadCmd) - // Execute load cmd — still in browse mode, history pane open. histMsg := loadCmd() updated2, _ := m2.Update(histMsg) assert.Equal(t, "history", updated2.(tui.Model).RightPane()) @@ -741,21 +707,17 @@ func TestModel_ScryShowsResults(t *testing.T) { } m := loadedModelFake(t, f) - // Size the terminal so the scry list has non-zero dimensions. sized, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) m = sized.(tui.Model) - // Open scry. inScry, _ := m.Update(keyMsg("s")) m = inScry.(tui.Model) require.Equal(t, "scry", m.Mode()) - // Type "d" to trigger a search; feed all resulting messages back. _, batchCmd := m.Update(keyMsg("d")) require.NotNil(t, batchCmd) m = feedBatch(t, m, batchCmd) - // The entity path must be visible in the rendered view. assert.Contains(t, m.View().Content, "Garage:Drill Bit") } @@ -773,36 +735,64 @@ func TestModel_ScryNavigate(t *testing.T) { inScry, _ := m.Update(keyMsg("s")) m = inScry.(tui.Model) - // Trigger search and load results. _, batchCmd := m.Update(keyMsg("d")) m = feedBatch(t, m, batchCmd) - // Press enter — must fire a navigate cmd and eventually return to browse. afterEnter, navigateCmd := m.Update(keyMsg("enter")) require.NotNil(t, navigateCmd, "enter with results should return a navigate cmd") navigateMsg := navigateCmd() afterNavigate, _ := afterEnter.(tui.Model).Update(navigateMsg) assert.Equal(t, "browse", afterNavigate.(tui.Model).Mode()) + // Target entity is already visible in the tree; cursor should be on it. + assert.Equal(t, "Garage:Drill Bit", afterNavigate.(tui.Model).CurrentPath()) } func TestModel_ScryEnterNoResults(t *testing.T) { - // Regression: pressing enter in scry with no matching results caused infinite - // recursion (stack overflow) because updateScry re-routed scryNavigatedMsg - // back through m.Update while still in modeScry. entity := entityResultWithStatus("e1", "Wrench", "Garage:Wrench", inventory.EntityStatusOk, false) f := &fakeTUIApp{roots: []app.EntityResult{entity}} m := loadedModelFake(t, f) - // Open scry mode (findResults is empty — no matches for anything). inScry, _ := m.Update(keyMsg("s")) require.Equal(t, "scry", inScry.(tui.Model).Mode()) - // Press enter with an empty result list — must not panic or recurse. updated, _ := inScry.(tui.Model).Update(keyMsg("enter")) assert.Equal(t, "scry", updated.(tui.Model).Mode()) } +func TestModel_ScryTabMovesToList(t *testing.T) { + drillBit := entityResult("e1", "Drill Bit", "Garage:Drill Bit", false) + f := &fakeTUIApp{ + roots: []app.EntityResult{drillBit}, + findResults: []app.FindResult{{Entity: drillBit, Distance: 0}}, + } + m := loadedModelFake(t, f) + + sized, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = sized.(tui.Model) + + inScry, _ := m.Update(keyMsg("s")) + m = inScry.(tui.Model) + require.Equal(t, "scry", m.Mode()) + + // Load results. + _, batchCmd := m.Update(keyMsg("d")) + m = feedBatch(t, m, batchCmd) + assert.Contains(t, m.View().Content, "Garage:Drill Bit") + + // Tab moves focus to the list; enter from list focus still navigates. + afterTab, _ := m.Update(keyMsg("tab")) + m = afterTab.(tui.Model) + + afterEnter, navigateCmd := m.Update(keyMsg("enter")) + require.NotNil(t, navigateCmd, "enter from list focus should produce navigate cmd") + + navigateMsg := navigateCmd() + afterNavigate, _ := afterEnter.(tui.Model).Update(navigateMsg) + assert.Equal(t, "browse", afterNavigate.(tui.Model).Mode()) + assert.Equal(t, "Garage:Drill Bit", afterNavigate.(tui.Model).CurrentPath()) +} + func TestModel_CursorNavigation(t *testing.T) { roots := []app.EntityResult{ entityResult("e1", "Garage", "Garage", true), @@ -839,3 +829,40 @@ func TestModel_CursorNavigation(t *testing.T) { assert.Equal(t, 2, updated.(tui.Model).CursorIndex()) }) } + +func TestModel_TreeExpandPreservesState(t *testing.T) { + // Expanding one node must not collapse or disturb siblings. + garage := entityResult("e1", "Garage", "Garage", true) + shelf := entityResult("e2", "Shelf", "Shelf", true) + garageKids := []app.EntityResult{ + entityResult("e10", "Toolbox", "Garage:Toolbox", false), + } + shelfKids := []app.EntityResult{ + entityResult("e20", "Book", "Shelf:Book", false), + } + f := &fakeTUIApp{ + roots: []app.EntityResult{garage, shelf}, + children: map[string][]app.EntityResult{ + "e1": garageKids, + "e2": shelfKids, + }, + } + + m := loadedModelFake(t, f) + + // Expand Garage. + m = expandNode(t, m) + require.Equal(t, 3, m.ItemCount()) // Garage, Toolbox, Shelf + + // Move to Shelf (index 2) and expand it. + updated, _ := m.Update(keyMsg("j")) + m = updated.(tui.Model) + updated, _ = m.Update(keyMsg("j")) + m = updated.(tui.Model) + require.Equal(t, "Shelf", m.CurrentPath()) + m = expandNode(t, m) + + // Both Garage (and its child) and Shelf (and its child) must be visible. + // Garage, Toolbox, Shelf, Book = 4. + assert.Equal(t, 4, m.ItemCount()) +} diff --git a/internal/web/handlers_entity.go b/internal/web/handlers_entity.go index a9f72a7..edf9b3a 100644 --- a/internal/web/handlers_entity.go +++ b/internal/web/handlers_entity.go @@ -201,9 +201,9 @@ func (s *Server) handleEditName(w http.ResponseWriter, r *http.Request) { } _, err = s.cfg.App.RenameEntity(r.Context(), app.RenameEntityRequest{ - EntityPath: data.Entity.FullPathDisplay, - NewName: newName, - ActorID: "webui", + EntityID: entityID, + NewName: newName, + ActorID: "webui", }) if err != nil { s.cfg.Logger.Error("rename entity", "error", err, "entity_id", entityID) @@ -253,9 +253,9 @@ func (s *Server) handleToggleMissing(w http.ResponseWriter, r *http.Request) { } _, err = s.cfg.App.ChangeStatus(r.Context(), app.ChangeStatusRequest{ - EntityPath: data.Entity.FullPathDisplay, - Status: target, - ActorID: "webui", + EntityID: entityID, + Status: target, + ActorID: "webui", }) if err != nil { s.cfg.Logger.Error("change status", "error", err, "entity_id", entityID)