diff --git a/.changes/unreleased/Added-20260612-195753.yaml b/.changes/unreleased/Added-20260612-195753.yaml new file mode 100644 index 0000000..4415075 --- /dev/null +++ b/.changes/unreleased/Added-20260612-195753.yaml @@ -0,0 +1,5 @@ +kind: Added +body: Each database instance can be named via 'db name' command +time: 2026-06-12T19:57:53.467217151-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Added-20260612-195846.yaml b/.changes/unreleased/Added-20260612-195846.yaml new file mode 100644 index 0000000..8681c0e --- /dev/null +++ b/.changes/unreleased/Added-20260612-195846.yaml @@ -0,0 +1,5 @@ +kind: Added +body: '''info'' command shows inventory information and sums entities by status' +time: 2026-06-12T19:58:46.067873164-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Added-20260612-234254.yaml b/.changes/unreleased/Added-20260612-234254.yaml new file mode 100644 index 0000000..8bb0948 --- /dev/null +++ b/.changes/unreleased/Added-20260612-234254.yaml @@ -0,0 +1,5 @@ +kind: Added +body: (TUI) detail pane can be toggled +time: 2026-06-12T23:42:54.25848273-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Changed-20260613-000537.yaml b/.changes/unreleased/Changed-20260613-000537.yaml new file mode 100644 index 0000000..1903188 --- /dev/null +++ b/.changes/unreleased/Changed-20260613-000537.yaml @@ -0,0 +1,5 @@ +kind: Changed +body: (TUI) history is shown in right pane as a toggle +time: 2026-06-13T00:05:37.127335509-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Fixed-20260612-215537.yaml b/.changes/unreleased/Fixed-20260612-215537.yaml new file mode 100644 index 0000000..67e9e2f --- /dev/null +++ b/.changes/unreleased/Fixed-20260612-215537.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: Removed extra vertical spaces between entites in file list +time: 2026-06-12T21:55:37.811554046-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Fixed-20260612-231534.yaml b/.changes/unreleased/Fixed-20260612-231534.yaml new file mode 100644 index 0000000..bbac2cb --- /dev/null +++ b/.changes/unreleased/Fixed-20260612-231534.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: '`--create-parents` actually creates missing ancestors' +time: 2026-06-12T23:15:34.333119832-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Fixed-20260612-233119.yaml b/.changes/unreleased/Fixed-20260612-233119.yaml new file mode 100644 index 0000000..7751e94 --- /dev/null +++ b/.changes/unreleased/Fixed-20260612-233119.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: (TUI) history shows each entity event +time: 2026-06-12T23:31:19.024623359-04:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Fixed-20260614-005257.yaml b/.changes/unreleased/Fixed-20260614-005257.yaml new file mode 100644 index 0000000..ba13976 --- /dev/null +++ b/.changes/unreleased/Fixed-20260614-005257.yaml @@ -0,0 +1,5 @@ +kind: Fixed +body: (TUI) scry shows list of matched entities +time: 2026-06-14T00:52:57.700667096-04:00 +custom: + Issue: "" diff --git a/CONTEXT.md b/CONTEXT.md index b919b2f..06d9fca 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -116,6 +116,10 @@ A typed iota enum classifying which layer a `DoctorIssue` belongs to: `config`, The top-level verdict of a `doctor` run. `true` when no `DoctorIssue`s were found across all checks; `false` otherwise. Exposed as `healthy` in `--json` output. Determines exit code regardless of output mode. +### WherehouseName + +A user-assigned display label for a database file, stored in `schema_metadata` under the key `"display_name"`. Independent of the filename. Set via `wherehouse db name `; cleared via `wherehouse db name --clear`. Never parsed as a path or entity name. When unset, displays as `(unnamed)`. Max 255 characters; newlines are rejected. + --- ## Terms to avoid diff --git a/cmd/add/add.go b/cmd/add/add.go index 4496f0d..6822d39 100644 --- a/cmd/add/add.go +++ b/cmd/add/add.go @@ -75,6 +75,7 @@ func runAdd(cmd *cobra.Command, args []string, a *app.App) error { locked, _ := cmd.Flags().GetBool("locked") discrete, _ := cmd.Flags().GetBool("discrete") allowDupes, _ := cmd.Flags().GetBool("allow-duplicates") + createParents, _ := cmd.Flags().GetBool("create-parents") if !allowDupes { seen := make(map[string]struct{}, len(args)) @@ -102,11 +103,12 @@ func runAdd(cmd *cobra.Command, args []string, a *app.App) error { return fmt.Errorf("cannot determine entity name from %q", arg) } reqs = append(reqs, app.CreateEntityRequest{ - DisplayName: name, - Locked: locked, - Discrete: discrete, - ParentPath: p.Dir().String(), - ActorID: cli.GetActorUserID(ctx), + DisplayName: name, + Locked: locked, + Discrete: discrete, + ParentPath: p.Dir().String(), + ActorID: cli.GetActorUserID(ctx), + CreateParents: createParents, }) } diff --git a/cmd/add/add_test.go b/cmd/add/add_test.go index 6614e5a..8d09f87 100644 --- a/cmd/add/add_test.go +++ b/cmd/add/add_test.go @@ -249,6 +249,83 @@ func TestRunAdd_File_CreateParents_CreatesAncestors(t *testing.T) { assert.True(t, paths["Garage:Toolbox:Wrench"]) } +func TestRunAdd_CreateParents_CreatesAncestors(t *testing.T) { + a := apptesting.OpenApp(t) + ctx := t.Context() + + cmd := add.NewAddCmd(a) + cmd.SetArgs([]string{"Dining Area:Buffet", "--create-parents"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + entities, err := a.ListEntities(ctx) + require.NoError(t, err) + paths := make(map[string]bool) + for _, e := range entities { + paths[e.FullPathDisplay] = true + } + assert.True(t, paths["Dining Area"], "parent should be created") + assert.True(t, paths["Dining Area:Buffet"], "leaf should be created") +} + +func TestRunAdd_CreateParents_ThreeLevels(t *testing.T) { + a := apptesting.OpenApp(t) + ctx := t.Context() + + cmd := add.NewAddCmd(a) + cmd.SetArgs([]string{"A:B:C", "--create-parents"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + entities, err := a.ListEntities(ctx) + require.NoError(t, err) + paths := make(map[string]bool) + for _, e := range entities { + paths[e.FullPathDisplay] = true + } + assert.True(t, paths["A"]) + assert.True(t, paths["A:B"]) + assert.True(t, paths["A:B:C"]) +} + +func TestRunAdd_CreateParents_ExistingParent(t *testing.T) { + a := apptesting.OpenApp(t) + ctx := t.Context() + + _, err := a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Garage", ActorID: "test"}) + require.NoError(t, err) + + cmd := add.NewAddCmd(a) + cmd.SetArgs([]string{"Garage:Toolbox", "--create-parents"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + entities, err := a.ListEntities(ctx) + require.NoError(t, err) + paths := make(map[string]bool) + for _, e := range entities { + paths[e.FullPathDisplay] = true + } + assert.True(t, paths["Garage"]) + assert.True(t, paths["Garage:Toolbox"]) +} + +func TestRunAdd_WithoutCreateParents_MissingParentErrors(t *testing.T) { + a := apptesting.OpenApp(t) + + cmd := add.NewAddCmd(a) + cmd.SetArgs([]string{"Dining Area:Buffet"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + // writeTempCSV writes content to a temp file and returns the path. func writeTempCSV(t *testing.T, content string) string { t.Helper() diff --git a/cmd/db/db.go b/cmd/db/db.go new file mode 100644 index 0000000..bee469c --- /dev/null +++ b/cmd/db/db.go @@ -0,0 +1,116 @@ +package db + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/asphaltbuffet/wherehouse/internal/app" + "github.com/asphaltbuffet/wherehouse/internal/cli" + "github.com/asphaltbuffet/wherehouse/internal/config" +) + +// NewDefaultDBCmd wires the db command group for production use. +func NewDefaultDBCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Manage database settings", + } + cmd.AddCommand(NewDefaultDBNameCmd()) + return cmd +} + +// NewDBCmd creates the `db` parent command with the `name` subcommand injected with a. +func NewDBCmd(a *app.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Manage database settings", + } + cmd.AddCommand(NewDBNameCmd(a)) + return cmd +} + +// NewDefaultDBNameCmd wires the db name command for production use. +func NewDefaultDBNameCmd() *cobra.Command { + cmd := buildDBNameCmd() + cmd.RunE = func(cmd *cobra.Command, args []string) error { + s, a, err := cli.OpenDatabase(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer s.Close() + return runDBName(cmd, args, a) + } + return cmd +} + +// NewDBNameCmd creates the `db name` subcommand injected with a. +func NewDBNameCmd(a *app.App) *cobra.Command { + cmd := buildDBNameCmd() + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runDBName(cmd, args, a) + } + return cmd +} + +func buildDBNameCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "name [display name]", + Short: "Set the display name of this database", + Long: `Set a human-readable display name for this database file. + +The name is stored inside the database and is independent of the filename. + +Examples: + wherehouse db name "123 Fake Street - House" + wherehouse db name "123 Fake Street - House" --db ~/inventories/rental.db + wherehouse db name --clear`, + Args: cobra.MaximumNArgs(1), + } + cmd.Flags().Bool("clear", false, "remove the display name") + return cmd +} + +func runDBName(cmd *cobra.Command, args []string, a *app.App) error { + ctx := cmd.Context() + clearFlag, _ := cmd.Flags().GetBool("clear") + + if clearFlag && len(args) > 0 { + return errors.New("--clear cannot be combined with a name argument") + } + if !clearFlag && len(args) == 0 { + return errors.New("provide a display name or use --clear") + } + + if clearFlag { + if err := a.ClearWherehouseName(ctx); err != nil { + return fmt.Errorf("clear name: %w", err) + } + } else { + if err := a.SetWherehouseName(ctx, args[0]); err != nil { + return fmt.Errorf("set name: %w", err) + } + } + + cfg, ok := cli.GetConfig(ctx) + if !ok { + cfg = config.GetDefaults() + } + out := cli.NewOutputWriterFromConfig(cmd.OutOrStdout(), cmd.ErrOrStderr(), cfg) + + if out.IsJSON() { + info, err := a.GetInfo(ctx) + if err != nil { + return fmt.Errorf("get info for json: %w", err) + } + return out.JSON(app.ToInfoOutput(info)) + } + + if clearFlag { + out.Success("Display name cleared") + } else { + out.Success(fmt.Sprintf("Display name set to %q", args[0])) + } + return nil +} diff --git a/cmd/db/db_test.go b/cmd/db/db_test.go new file mode 100644 index 0000000..da0b04a --- /dev/null +++ b/cmd/db/db_test.go @@ -0,0 +1,78 @@ +package db_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dbcmd "github.com/asphaltbuffet/wherehouse/cmd/db" + "github.com/asphaltbuffet/wherehouse/internal/apptesting" + "github.com/asphaltbuffet/wherehouse/internal/config" +) + +func TestDbNameCmd_SetsName(t *testing.T) { + a := apptesting.OpenApp(t) + cmd := dbcmd.NewDBCmd(a) + cmd.SetArgs([]string{"name", "My Workshop"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + info, err := a.GetInfo(t.Context()) + require.NoError(t, err) + assert.Equal(t, "My Workshop", info.Name) +} + +func TestDbNameCmd_ClearsName(t *testing.T) { + a := apptesting.OpenApp(t) + ctx := t.Context() + + require.NoError(t, a.SetWherehouseName(ctx, "Old Name")) + + cmd := dbcmd.NewDBCmd(a) + cmd.SetArgs([]string{"name", "--clear"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + info, err := a.GetInfo(ctx) + require.NoError(t, err) + assert.Equal(t, "(unnamed)", info.Name) +} + +func TestDbNameCmd_EmptyArgRejected(t *testing.T) { + a := apptesting.OpenApp(t) + cmd := dbcmd.NewDBCmd(a) + cmd.SetArgs([]string{"name", ""}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + assert.Error(t, cmd.Execute()) +} + +func TestDbNameCmd_ClearAndArgMutuallyExclusive(t *testing.T) { + a := apptesting.OpenApp(t) + cmd := dbcmd.NewDBCmd(a) + cmd.SetArgs([]string{"name", "--clear", "Some Name"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + assert.Error(t, cmd.Execute()) +} + +func TestDbNameCmd_JSON(t *testing.T) { + a := apptesting.OpenApp(t) + out := &bytes.Buffer{} + cmd := dbcmd.NewDBCmd(a) + cmd.SetArgs([]string{"name", "Test Name"}) + cmd.SetContext(context.WithValue(t.Context(), config.ConfigKey, apptesting.NewTestConfig(t, apptesting.WithJSON()))) + cmd.SetOut(out) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var result map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &result)) + assert.Equal(t, "Test Name", result["name"]) +} diff --git a/cmd/db/doc.go b/cmd/db/doc.go new file mode 100644 index 0000000..b73b52f --- /dev/null +++ b/cmd/db/doc.go @@ -0,0 +1,2 @@ +// Package db implements the `wherehouse db` command group for database management. +package db diff --git a/cmd/info/doc.go b/cmd/info/doc.go new file mode 100644 index 0000000..01cb7af --- /dev/null +++ b/cmd/info/doc.go @@ -0,0 +1,2 @@ +// Package info implements the `wherehouse info` command. +package info diff --git a/cmd/info/info.go b/cmd/info/info.go new file mode 100644 index 0000000..ebe359a --- /dev/null +++ b/cmd/info/info.go @@ -0,0 +1,101 @@ +package info + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/asphaltbuffet/wherehouse/internal/app" + "github.com/asphaltbuffet/wherehouse/internal/cli" + "github.com/asphaltbuffet/wherehouse/internal/config" + "github.com/asphaltbuffet/wherehouse/internal/inventory" +) + +// NewDefaultInfoCmd wires the info command for production use. +func NewDefaultInfoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "info", + Short: "Show database information and entity counts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s, a, err := cli.OpenDatabase(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer s.Close() + return runInfo(cmd, a) + }, + } + return cmd +} + +// NewInfoCmd creates the `info` command injected with a. +func NewInfoCmd(a *app.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "info", + Short: "Show database information and entity counts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runInfo(cmd, a) + }, + } + return cmd +} + +var statusOrder = []string{ + inventory.EntityStatusOk.String(), + inventory.EntityStatusMissing.String(), + inventory.EntityStatusBorrowed.String(), + inventory.EntityStatusLoaned.String(), + inventory.EntityStatusRemoved.String(), +} + +func runInfo(cmd *cobra.Command, a *app.App) error { + ctx := cmd.Context() + + result, err := a.GetInfo(ctx) + if err != nil { + return fmt.Errorf("info: %w", err) + } + + cfg, ok := cli.GetConfig(ctx) + if !ok { + cfg = config.GetDefaults() + } + out := cli.NewOutputWriterFromConfig(cmd.OutOrStdout(), cmd.ErrOrStderr(), cfg) + + if out.IsJSON() { + return out.JSON(app.ToInfoOutput(result)) + } + + maxWidth := 1 + for _, s := range statusOrder { + if n := result.EntityCounts[s]; digits(n) > maxWidth { + maxWidth = digits(n) + } + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Name: %s\n", result.Name) + fmt.Fprintf(&sb, "Database: %s\n", result.DatabasePath) + sb.WriteString("\nEntities:\n") + for _, s := range statusOrder { + fmt.Fprintf(&sb, " %-9s %*d\n", strings.ToUpper(s)+":", maxWidth, result.EntityCounts[s]) + } + + out.Print(sb.String()) + return nil +} + +func digits(n int) int { + if n == 0 { + return 1 + } + d := 0 + for n > 0 { + d++ + n /= 10 + } + return d +} diff --git a/cmd/info/info_test.go b/cmd/info/info_test.go new file mode 100644 index 0000000..770b382 --- /dev/null +++ b/cmd/info/info_test.go @@ -0,0 +1,91 @@ +package info_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + infocmd "github.com/asphaltbuffet/wherehouse/cmd/info" + "github.com/asphaltbuffet/wherehouse/internal/app" + "github.com/asphaltbuffet/wherehouse/internal/apptesting" + "github.com/asphaltbuffet/wherehouse/internal/config" +) + +func runInfo(t *testing.T, a *app.App, args ...string) string { + t.Helper() + cmd := infocmd.NewInfoCmd(a) + cmd.SetArgs(args) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + return out.String() +} + +func TestInfoCmd_ShowsUnnamed(t *testing.T) { + a := apptesting.OpenApp(t) + out := runInfo(t, a) + assert.Contains(t, out, "Name: (unnamed)") +} + +func TestInfoCmd_ShowsName(t *testing.T) { + a := apptesting.OpenApp(t) + require.NoError(t, a.SetWherehouseName(t.Context(), "My Garage")) + out := runInfo(t, a) + assert.Contains(t, out, "Name: My Garage") +} + +func TestInfoCmd_ShowsAllStatuses(t *testing.T) { + a := apptesting.OpenApp(t) + out := runInfo(t, a) + assert.Contains(t, out, "OK:") + assert.Contains(t, out, "MISSING:") + assert.Contains(t, out, "BORROWED:") + assert.Contains(t, out, "LOANED:") + assert.Contains(t, out, "REMOVED:") +} + +func TestInfoCmd_ShowsDatabasePath(t *testing.T) { + a := apptesting.OpenApp(t) + info, err := a.GetInfo(t.Context()) + require.NoError(t, err) + out := runInfo(t, a) + assert.Contains(t, out, "Database: "+info.DatabasePath) +} + +func TestInfoCmd_CountsEntities(t *testing.T) { + a := apptesting.OpenApp(t) + _, err := a.CreateEntity(t.Context(), app.CreateEntityRequest{DisplayName: "Shelf", ActorID: "alice"}) + require.NoError(t, err) + out := runInfo(t, a) + assert.Contains(t, out, "OK:") + assert.Contains(t, out, "1") +} + +func TestInfoCmd_JSON(t *testing.T) { + a := apptesting.OpenApp(t) + require.NoError(t, a.SetWherehouseName(t.Context(), "Test House")) + + cmd := infocmd.NewInfoCmd(a) + cmd.SetContext(context.WithValue(t.Context(), config.ConfigKey, apptesting.NewTestConfig(t, apptesting.WithJSON()))) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + var result map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &result)) + assert.Equal(t, "Test House", result["name"]) + assert.NotEmpty(t, result["database"]) + entities, ok := result["entities"].(map[string]any) + require.True(t, ok, "entities must be a map") + assert.Contains(t, entities, "ok") + assert.Contains(t, entities, "missing") + assert.Contains(t, entities, "borrowed") + assert.Contains(t, entities, "loaned") + assert.Contains(t, entities, "removed") +} diff --git a/cmd/root.go b/cmd/root.go index 53a6f39..4dfecb7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,11 +11,13 @@ import ( "github.com/asphaltbuffet/wherehouse/cmd/add" "github.com/asphaltbuffet/wherehouse/cmd/borrow" configpkg "github.com/asphaltbuffet/wherehouse/cmd/config" + dbcmd "github.com/asphaltbuffet/wherehouse/cmd/db" "github.com/asphaltbuffet/wherehouse/cmd/doctor" "github.com/asphaltbuffet/wherehouse/cmd/export" "github.com/asphaltbuffet/wherehouse/cmd/found" "github.com/asphaltbuffet/wherehouse/cmd/history" importcmd "github.com/asphaltbuffet/wherehouse/cmd/import" + infocmd "github.com/asphaltbuffet/wherehouse/cmd/info" listcmd "github.com/asphaltbuffet/wherehouse/cmd/list" "github.com/asphaltbuffet/wherehouse/cmd/loan" "github.com/asphaltbuffet/wherehouse/cmd/lost" @@ -92,6 +94,8 @@ Examples: rootCmd.AddCommand(status.NewDefaultStatusCmd()) rootCmd.AddCommand(tag.NewDefaultTagCmd()) rootCmd.AddCommand(doctor.NewDefaultDoctorCmd()) + rootCmd.AddCommand(dbcmd.NewDefaultDBCmd()) + rootCmd.AddCommand(infocmd.NewDefaultInfoCmd()) // hidden internal commands — not shown in help output rootCmd.AddCommand(man.NewManCmd()) diff --git a/docs/adr/0026-tui-action-keybindings.md b/docs/adr/0026-tui-action-keybindings.md index f589e41..12616a0 100644 --- a/docs/adr/0026-tui-action-keybindings.md +++ b/docs/adr/0026-tui-action-keybindings.md @@ -12,8 +12,10 @@ The TUI uses `h`/`j`/`k`/`l` for vim-style navigation (back/down/up/open). When | `x` | lost | selection is `ok`, not `locked` | | `r` | return | selection is `loaned` or `borrowed` | | `f` | found | selection is `missing` | -| `H` | history | selection exists (ungated) | +| `H` | toggle history in right pane (hides if already showing) | selection exists | | `s` | scry | always (no selection required) | +| `d` | toggle detail in right pane (hides if already showing) | selection exists | +| `pgup` / `pgdn` | scroll history pane | no-op when history not visible | `/` remains the bubbles/list local filter (current level only). `s` opens the inventory-wide Levenshtein search (`scry`). These are intentionally distinct — `/` filters what is already visible; `s` searches the whole inventory. diff --git a/docs/adr/0027-tui-mode-based-view-switching.md b/docs/adr/0027-tui-mode-based-view-switching.md index 69f93f1..faaebb3 100644 --- a/docs/adr/0027-tui-mode-based-view-switching.md +++ b/docs/adr/0027-tui-mode-based-view-switching.md @@ -2,16 +2,20 @@ When adding interactive actions (forms, confirm prompts, history view, scry search) to the TUI, we needed a way to swap what is rendered and what `Update` routes. We use a `tuiMode` integer enum on the single `Model` struct rather than launching separate `tea.Program` instances or composing separate top-level bubbles. -The modes are: `modeBrowse` (the existing two-pane navigator), `modeForm` (text-input form for `add`/`loan`/`borrow`), `modeConfirm` (y/n prompt with optional inline note for `lost`/`found`/`return`), `modeHistory` (scrollable event timeline), and `modeScry` (inventory-wide Levenshtein search). +The modes are: `modeBrowse` (the two-pane navigator), `modeForm` (text-input form for `add`/`loan`/`borrow`), `modeConfirm` (y/n prompt with optional inline note for `lost`/`found`/`return`), and `modeScry` (inventory-wide Levenshtein search). History is not a separate mode — see below. `View()` dispatches on `m.mode`; `Update()` routes `tea.KeyPressMsg` to the active sub-model. All sub-models (`formModel`, `confirmModel`, `historyModel`, `scryModel`) are value fields on `Model` and are initialized when their mode is entered. ## Consequences - **Keybinding gates are evaluated in `modeBrowse` only.** Sub-models handle their own input exclusively while active; the browse keymap is unreachable from other modes. -- **`modeConfirm` interaction:** `[y]` is pre-selected; `Enter` or `y` submits; `n`/`Esc` cancels; any other printable character feeds an inline note field directly — no Tab-cycling between widgets. +- **`modeConfirm` interaction:** `Enter` submits; `Esc` cancels; any other printable character (including `y` and `n`) feeds an inline note field directly — no Tab-cycling between widgets. `confirmModel` owns its own cancellation: it emits `confirmCancelledMsg` on `Esc`; `updateConfirm` handles only outcomes (`confirmCancelledMsg`, `actionDoneMsg`) and does not intercept key presses directly. - **Post-mutation refresh** reloads the current level and attempts to reposition the cursor on the affected entity by ID. If the entity is absent from the refreshed list (e.g. `return` on a `borrowed` entity sets it to `removed`, which `GetChildren` excludes), the cursor falls back to `ResetSelected()`. - **Scry navigation** exits `modeScry`, loads the matched entity's parent level, and positions the cursor on that entity by ID — same mechanism as post-mutation refresh. +- **History lives in the right pane, not a mode.** `H` toggles a history panel inside `modeBrowse`'s right pane — the same pane used by the detail view (`d`). The two are mutually exclusive: pressing `H` while detail is showing replaces it with history, and vice versa; pressing the active key again hides the pane. `pgup`/`pgdn` scroll the history viewport while it is visible and are no-ops otherwise. `modeHistory` was removed; `historyModel` remains as a pane renderer only. +- **History auto-refreshes on any level load.** After any level-load message (`rootsLoadedMsg`, `childrenLoadedMsg`, `levelRestoredMsg`, `childRefreshMsg`, `scryNavigatedMsg`) settles and `loadLevel` positions the cursor, if `rightPane == rightPaneHistory` the model reinitialises `historyModel` for the newly selected entity and fires `loadCmd()`. This keeps the history pane coherent during drill-down, drill-up, post-mutation refresh, and scry navigation. +- **History loads use a generation counter.** Each `historyModel` carries a `gen int` stamp incremented on every reinitialisation. `historyLoadedMsg` carries the `gen` it was fired for; `updateBrowse` discards responses where `msg.gen != m.history.gen`. This prevents out-of-order responses from rapid Up/Down navigation from briefly displaying history for the wrong entity. +- **`q` always quits.** The history pane is not a mode; `q` is not intercepted by pane state. Dismiss history with `H`. ## Considered Options diff --git a/docs/adr/0028-history-load-generation-counter.md b/docs/adr/0028-history-load-generation-counter.md new file mode 100644 index 0000000..ef3259f --- /dev/null +++ b/docs/adr/0028-history-load-generation-counter.md @@ -0,0 +1,17 @@ +# History pane uses a generation counter to discard stale loads + +When the history pane is open and the user navigates with Up/Down, each keypress reinitialises `historyModel` and fires a new `loadCmd()` DB fetch. Because multiple fetches can be in-flight simultaneously, out-of-order `historyLoadedMsg` responses could briefly display history for the wrong entity. + +We stamp each `historyModel` with a monotonically incrementing `gen int` counter. `historyLoadedMsg` carries the `gen` value active when the load was fired. `updateBrowse` silently discards any `historyLoadedMsg` whose `gen` does not match `m.history.gen`. + +## Consequences + +- Stale responses are dropped at the message-handling boundary; no UI flicker. +- No debounce timer or cancellation context required — the counter is zero-cost. +- `gen` is incremented in `newHistoryModel`, which is the sole construction path. + +## Considered Options + +**Debounce (fire load only after idle delay).** Rejected — adds latency perceptible to keyboard users and requires a timer message type with its own cancellation logic. + +**Accept out-of-order (last writer wins).** Rejected — correctness should not depend on SQLite response ordering, even when local latency is typically sub-millisecond. diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 67f0e65..7b74087 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -40,7 +40,7 @@ func validateEventLog(events []store.RawEvent, factories map[inventory.EventType // Track created/removed before per-event validation so that malformed // or otherwise-flagged events are still counted for orphan detection. if parseErr == nil && ev.EntityID != nil && *ev.EntityID != "" { - switch et { //nolint:exhaustive // only created/removed affect orphan tracking + switch et { //nolint:exhaustive // only created/borrowed/removed affect orphan tracking; all other event types are intentionally ignored case inventory.EntityCreatedEvent, inventory.EntityBorrowedEvent: createdIDs[*ev.EntityID] = true case inventory.EntityRemovedEvent: @@ -184,7 +184,7 @@ func buildProjectionSets(events []store.RawEvent) (map[string]bool, map[string]i if parseErr != nil { continue } - switch et { //nolint:exhaustive // only created/removed affect the expected-present set + switch et { //nolint:exhaustive // only created/borrowed/removed affect the expected-present set; all other event types are intentionally ignored case inventory.EntityCreatedEvent, inventory.EntityBorrowedEvent: created[id] = true case inventory.EntityRemovedEvent: diff --git a/internal/app/doctor_kind.go b/internal/app/doctor_kind.go index daf72bb..267ba6a 100644 --- a/internal/app/doctor_kind.go +++ b/internal/app/doctor_kind.go @@ -2,15 +2,14 @@ package app //go:generate stringer -type=DoctorKind -linecomment -// DoctorKind classifies which layer a DoctorIssue belongs to. -// - // DoctorKind classifies which layer a DoctorIssue belongs to. type DoctorKind int -//nolint:revive // linecomment strings serve as the stringer output; no separate doc needed const ( - DoctorKindConfig DoctorKind = iota + 1 // config - DoctorKindEventLog // event_log - DoctorKindProjection // projection + // DoctorKindConfig classifies issues found in the configuration layer. + DoctorKindConfig DoctorKind = iota + 1 // config + // DoctorKindEventLog classifies issues found in the event stream itself. + DoctorKindEventLog // event_log + // DoctorKindProjection classifies issues found in derived projection state. + DoctorKindProjection // projection ) diff --git a/internal/app/entities.go b/internal/app/entities.go index f88aa65..9d3e0b8 100644 --- a/internal/app/entities.go +++ b/internal/app/entities.go @@ -368,17 +368,9 @@ func (a *App) CreateEntities(ctx context.Context, reqs []CreateEntityRequest) ([ } func (a *App) createEntityInTx(ctx context.Context, tx store.Tx, req CreateEntityRequest) (EntityResult, error) { - var parentID *string - - if req.ParentPath != "" { - parent, err := a.resolveEntityPathTx(ctx, tx, req.ParentPath) - if err != nil { - return EntityResult{}, fmt.Errorf("resolve parent path %q: %w", req.ParentPath, err) - } - if parent.Discrete { - return EntityResult{}, fmt.Errorf("cannot add child to %q: entity is discrete", parent.FullPathDisplay) - } - parentID = &parent.EntityID + parentID, err := a.resolveParentID(ctx, tx, req) + if err != nil { + return EntityResult{}, err } entityID, err := nanoid.New() @@ -415,3 +407,31 @@ func (a *App) createEntityInTx(ctx context.Context, tx store.Tx, req CreateEntit return entityToResult(entity, nil), nil } + +func (a *App) resolveParentID(ctx context.Context, tx store.Tx, req CreateEntityRequest) (*string, error) { + if req.ParentPath == "" { + return nil, nil //nolint:nilnil // nil *string is the correct "no parent" sentinel + } + + if req.CreateParents { + p, err := entitypath.Parse(req.ParentPath + ":" + req.DisplayName) + if err != nil { + return nil, fmt.Errorf("parse full path: %w", err) + } + if _, _, err = a.ensureAncestors(ctx, tx, p.Segments(), BulkAddOptions{ + CreateParents: true, + ActorID: req.ActorID, + }); err != nil { + return nil, err + } + } + + parent, err := a.resolveEntityPathTx(ctx, tx, req.ParentPath) + if err != nil { + return nil, fmt.Errorf("resolve parent path %q: %w", req.ParentPath, err) + } + if parent.Discrete { + return nil, fmt.Errorf("cannot add child to %q: entity is discrete", parent.FullPathDisplay) + } + return &parent.EntityID, nil +} diff --git a/internal/app/info.go b/internal/app/info.go new file mode 100644 index 0000000..c667995 --- /dev/null +++ b/internal/app/info.go @@ -0,0 +1,63 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/asphaltbuffet/wherehouse/internal/store" +) + +const displayNameKey = "display_name" +const maxNameLength = 255 + +// GetInfo returns the database display name, path, and entity counts by status. +func (a *App) GetInfo(ctx context.Context) (InfoResult, error) { + name, err := a.store.GetMetadata(ctx, displayNameKey) + if errors.Is(err, store.ErrNotFound) { + name = "" + } else if err != nil { + return InfoResult{}, fmt.Errorf("get info: %w", err) + } + + if name == "" { + name = "(unnamed)" + } + + counts, err := a.store.CountEntitiesByStatus(ctx) + if err != nil { + return InfoResult{}, fmt.Errorf("get info counts: %w", err) + } + + return InfoResult{ + Name: name, + DatabasePath: a.store.Path(), + EntityCounts: counts, + }, nil +} + +// SetWherehouseName stores a display name for this database. +func (a *App) SetWherehouseName(ctx context.Context, name string) error { + if name == "" { + return errors.New("name cannot be empty") + } + if strings.ContainsAny(name, "\n\r") { + return errors.New("name cannot contain newlines") + } + if len(name) > maxNameLength { + return errors.New("name cannot exceed 255 characters") + } + if err := a.store.SetMetadata(ctx, displayNameKey, name); err != nil { + return fmt.Errorf("set wherehouse name: %w", err) + } + return nil +} + +// ClearWherehouseName removes the display name, reverting to "(unnamed)". +func (a *App) ClearWherehouseName(ctx context.Context) error { + if err := a.store.DeleteMetadata(ctx, displayNameKey); err != nil { + return fmt.Errorf("clear wherehouse name: %w", err) + } + return nil +} diff --git a/internal/app/info_test.go b/internal/app/info_test.go new file mode 100644 index 0000000..88ad6df --- /dev/null +++ b/internal/app/info_test.go @@ -0,0 +1,91 @@ +package app_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/asphaltbuffet/wherehouse/internal/app" + "github.com/asphaltbuffet/wherehouse/internal/store" +) + +func openTestAppWithPath(t *testing.T) (*app.App, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + s, err := store.Open(store.Config{Path: path, AutoMigrate: true}) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return app.New(s), path +} + +func TestGetInfo_Unnamed(t *testing.T) { + a, dbPath := openTestAppWithPath(t) + ctx := context.Background() + + info, err := a.GetInfo(ctx) + require.NoError(t, err) + + assert.Equal(t, "(unnamed)", info.Name) + assert.Equal(t, dbPath, info.DatabasePath) + assert.Equal(t, 0, info.EntityCounts["ok"]) + assert.Equal(t, 0, info.EntityCounts["missing"]) + assert.Equal(t, 0, info.EntityCounts["borrowed"]) + assert.Equal(t, 0, info.EntityCounts["loaned"]) + assert.Equal(t, 0, info.EntityCounts["removed"]) +} + +func TestGetInfo_WithName(t *testing.T) { + a, _ := openTestAppWithPath(t) + ctx := context.Background() + + require.NoError(t, a.SetWherehouseName(ctx, "123 Fake Street")) + + info, err := a.GetInfo(ctx) + require.NoError(t, err) + assert.Equal(t, "123 Fake Street", info.Name) +} + +func TestGetInfo_AfterClear(t *testing.T) { + a, _ := openTestAppWithPath(t) + ctx := context.Background() + + require.NoError(t, a.SetWherehouseName(ctx, "My House")) + require.NoError(t, a.ClearWherehouseName(ctx)) + + info, err := a.GetInfo(ctx) + require.NoError(t, err) + assert.Equal(t, "(unnamed)", info.Name) +} + +func TestGetInfo_EntityCounts(t *testing.T) { + a, _ := openTestAppWithPath(t) + ctx := context.Background() + + _, err := a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Garage", ActorID: "alice"}) + require.NoError(t, err) + + info, err := a.GetInfo(ctx) + require.NoError(t, err) + assert.Equal(t, 1, info.EntityCounts["ok"]) +} + +func TestSetWherehouseName_Validation(t *testing.T) { + a, _ := openTestAppWithPath(t) + ctx := context.Background() + + require.ErrorContains(t, a.SetWherehouseName(ctx, ""), "name cannot be empty") + require.ErrorContains(t, a.SetWherehouseName(ctx, "bad\nname"), "name cannot contain newlines") + require.ErrorContains(t, a.SetWherehouseName(ctx, "bad\rname"), "name cannot contain newlines") + require.ErrorContains(t, a.SetWherehouseName(ctx, strings.Repeat("x", 256)), "name cannot exceed 255 characters") +} + +func TestSetWherehouseName_MaxLength(t *testing.T) { + a, _ := openTestAppWithPath(t) + ctx := context.Background() + + assert.NoError(t, a.SetWherehouseName(ctx, strings.Repeat("x", 255))) +} diff --git a/internal/app/output.go b/internal/app/output.go index 7f46bec..82cee6a 100644 --- a/internal/app/output.go +++ b/internal/app/output.go @@ -206,3 +206,19 @@ func ToBulkAddOutput(r BulkAddResult) BulkAddOutput { Warnings: warnings, } } + +// InfoOutput is the --json output shape for the info command. +type InfoOutput struct { + Name string `json:"name"` + Database string `json:"database"` + Entities map[string]int `json:"entities"` +} + +// ToInfoOutput projects an InfoResult into the info command's JSON output shape. +func ToInfoOutput(r InfoResult) InfoOutput { + return InfoOutput{ + Name: r.Name, + Database: r.DatabasePath, + Entities: r.EntityCounts, + } +} diff --git a/internal/app/requests.go b/internal/app/requests.go index 63d8648..9c52f47 100644 --- a/internal/app/requests.go +++ b/internal/app/requests.go @@ -8,9 +8,10 @@ type CreateEntityRequest struct { Locked bool Discrete bool // ParentPath is a colon-separated path, e.g. "Garage:Toolbox". Empty means root-level. - ParentPath string - ActorID string - Note string + ParentPath string + ActorID string + Note string + CreateParents bool } // RenameEntityRequest is the input for renaming an entity. diff --git a/internal/app/results.go b/internal/app/results.go index fe70b2e..c7463ba 100644 --- a/internal/app/results.go +++ b/internal/app/results.go @@ -33,6 +33,13 @@ type HistoryResult struct { Note string } +// InfoResult is the output of GetInfo — database name, path, and entity counts by status. +type InfoResult struct { + Name string + DatabasePath string + EntityCounts map[string]int +} + // FindResult pairs an EntityResult with its fuzzy-match distance. type FindResult struct { Entity EntityResult diff --git a/internal/inventory/entity_status.go b/internal/inventory/entity_status.go index 6a716f8..0fc04d7 100644 --- a/internal/inventory/entity_status.go +++ b/internal/inventory/entity_status.go @@ -9,13 +9,20 @@ import "fmt" //nolint:recvcheck // Value() requires value receiver; Scan() requires pointer receiver. type EntityStatus int -//nolint:revive // linecomment strings serve as the stringer output; no separate doc needed const ( - EntityStatusOk EntityStatus = iota + 1 // ok - EntityStatusBorrowed // borrowed - EntityStatusMissing // missing - EntityStatusLoaned // loaned - EntityStatusRemoved // removed + // EntityStatusOk means the entity is at its known location and available. + EntityStatusOk EntityStatus = iota + 1 // ok + // EntityStatusBorrowed means an external item brought into the inventory temporarily. + // The only valid transition out of this status is return, which sets the entity to removed. + EntityStatusBorrowed // borrowed + // EntityStatusMissing means the entity's location is unknown. + EntityStatusMissing // missing + // EntityStatusLoaned means the entity has been given out to someone else. + // The entity still exists in the inventory; no new entity is created. + EntityStatusLoaned // loaned + // EntityStatusRemoved means the entity has been soft-deleted from the inventory. + // Removed entities remain in the projection but are excluded from normal queries. + EntityStatusRemoved // removed ) var entityStatusByName = map[string]EntityStatus{ diff --git a/internal/inventory/entity_status_sql.go b/internal/inventory/entity_status_sql.go index fc341a0..065e365 100644 --- a/internal/inventory/entity_status_sql.go +++ b/internal/inventory/entity_status_sql.go @@ -5,12 +5,12 @@ import ( "fmt" ) -//nolint:revive // implements driver.Valuer; comment on interface is sufficient +// Value implements [driver.Valuer], serializing the status as its string name. func (e EntityStatus) Value() (driver.Value, error) { return e.String(), nil } -//nolint:revive // implements sql.Scanner; comment on interface is sufficient +// Scan implements [sql.Scanner], parsing a string column back into an EntityStatus. func (e *EntityStatus) Scan(src any) error { s, ok := src.(string) if !ok { diff --git a/internal/inventory/errors.go b/internal/inventory/errors.go index ad7e7cb..2bc55c5 100644 --- a/internal/inventory/errors.go +++ b/internal/inventory/errors.go @@ -2,8 +2,9 @@ package inventory import "errors" -//nolint:revive // sentinel errors; names are self-documenting var ( + // ErrEntityNotFound is returned when a query finds no matching entity. ErrEntityNotFound = errors.New("entity not found") - ErrEventNotFound = errors.New("event not found") + // ErrEventNotFound is returned when a query finds no matching event. + ErrEventNotFound = errors.New("event not found") ) diff --git a/internal/inventory/event_type.go b/internal/inventory/event_type.go index 84d30f2..5fb18de 100644 --- a/internal/inventory/event_type.go +++ b/internal/inventory/event_type.go @@ -9,21 +9,34 @@ import "fmt" //nolint:recvcheck // Value() requires value receiver; Scan() requires pointer receiver. type EventType int -//nolint:revive // linecomment strings serve as the stringer output; no separate doc needed const ( - EntityCreatedEvent EventType = iota + 1 // entity.created - EntityRenamedEvent // entity.renamed - EntityReparentedEvent // entity.reparented - EntityPathChangedEvent // entity.path_changed - EntityStatusChangedEvent // entity.status_changed - EntityRemovedEvent // entity.removed - EntityTagAddedEvent // entity.tag_added - EntityTagRemovedEvent // entity.tag_removed - EntityLockedEvent // entity.locked - EntityUnlockedEvent // entity.unlocked - EntityDiscreteSetEvent // entity.discrete_set - EntityDiscreteClearedEvent // entity.discrete_cleared - EntityBorrowedEvent // entity.borrowed + // EntityCreatedEvent records that a new entity was added to the inventory. + EntityCreatedEvent EventType = iota + 1 // entity.created + // EntityRenamedEvent records that an entity's display name changed. + EntityRenamedEvent // entity.renamed + // EntityReparentedEvent records that an entity was moved to a new parent. + EntityReparentedEvent // entity.reparented + // EntityPathChangedEvent records a derived path update propagated from an ancestor reparent. + EntityPathChangedEvent // entity.path_changed + // EntityStatusChangedEvent records that an entity's status (and optional context) changed. + EntityStatusChangedEvent // entity.status_changed + // EntityRemovedEvent records that an entity was soft-deleted. The entity remains in the projection. + EntityRemovedEvent // entity.removed + // EntityTagAddedEvent records that a tag was applied to an entity. + EntityTagAddedEvent // entity.tag_added + // EntityTagRemovedEvent records that a tag was removed from an entity. + EntityTagRemovedEvent // entity.tag_removed + // EntityLockedEvent records that an entity's locked flag was set to true. + EntityLockedEvent // entity.locked + // EntityUnlockedEvent records that an entity's locked flag was set to false. + EntityUnlockedEvent // entity.unlocked + // EntityDiscreteSetEvent records that an entity's discrete flag was set to true. + EntityDiscreteSetEvent // entity.discrete_set + // EntityDiscreteClearedEvent records that an entity's discrete flag was set to false. + EntityDiscreteClearedEvent // entity.discrete_cleared + // EntityBorrowedEvent records that a new entity was created directly in borrowed status. + // This is atomic — there is no preceding EntityCreatedEvent. + EntityBorrowedEvent // entity.borrowed ) var eventTypeByName = map[string]EventType{ diff --git a/internal/inventory/event_type_sql.go b/internal/inventory/event_type_sql.go index 5d2db56..602189a 100644 --- a/internal/inventory/event_type_sql.go +++ b/internal/inventory/event_type_sql.go @@ -5,12 +5,12 @@ import ( "fmt" ) -//nolint:revive // implements driver.Valuer; comment on interface is sufficient +// Value implements [driver.Valuer], serializing the event type as its string name. func (e EventType) Value() (driver.Value, error) { return e.String(), nil } -//nolint:revive // implements sql.Scanner; comment on interface is sufficient +// Scan implements [sql.Scanner], parsing a string column back into an EventType. func (e *EventType) Scan(src any) error { s, ok := src.(string) if !ok { diff --git a/internal/store/metadata.go b/internal/store/metadata.go index c9d797f..7c81729 100644 --- a/internal/store/metadata.go +++ b/internal/store/metadata.go @@ -21,6 +21,40 @@ func (s *Store) GetMetadata(ctx context.Context, key string) (string, error) { return val, nil } +// CountEntitiesByStatus returns the count of entities per status across all statuses. +// All five known statuses are always present in the returned map (zero-filled if absent). +func (s *Store) CountEntitiesByStatus(ctx context.Context) (map[string]int, error) { + const query = `SELECT status, COUNT(*) FROM entities_current GROUP BY status` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("count entities by status: %w", err) + } + defer rows.Close() + + counts := map[string]int{ + "ok": 0, "missing": 0, "borrowed": 0, "loaned": 0, "removed": 0, + } + for rows.Next() { + var status string + var count int + if scanErr := rows.Scan(&status, &count); scanErr != nil { + return nil, fmt.Errorf("scan entity status count: %w", scanErr) + } + counts[status] = count + } + return counts, rows.Err() +} + +// DeleteMetadata removes a key from the schema_metadata table. No-op if absent. +func (s *Store) DeleteMetadata(ctx context.Context, key string) error { + const query = `DELETE FROM schema_metadata WHERE key = ?` + _, err := s.db.ExecContext(ctx, query, key) + if err != nil { + return fmt.Errorf("delete metadata %q: %w", key, err) + } + return nil +} + // SetMetadata upserts a key/value pair in the schema_metadata table. func (s *Store) SetMetadata(ctx context.Context, key, value string) error { const query = ` diff --git a/internal/store/metadata_test.go b/internal/store/metadata_test.go index b59aa95..19160f2 100644 --- a/internal/store/metadata_test.go +++ b/internal/store/metadata_test.go @@ -2,11 +2,14 @@ package store_test import ( "context" + "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/asphaltbuffet/wherehouse/internal/inventory" "github.com/asphaltbuffet/wherehouse/internal/store" ) @@ -21,6 +24,70 @@ func TestMetadataRoundtrip(t *testing.T) { assert.Equal(t, "1", val) } +func TestStorePath(t *testing.T) { + path := filepath.Join(t.TempDir(), "myinventory.db") + s, err := store.Open(store.Config{Path: path, AutoMigrate: true}) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + assert.Equal(t, path, s.Path()) +} + +func TestCountEntitiesByStatus_Empty(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + counts, err := s.CountEntitiesByStatus(ctx) + require.NoError(t, err) + assert.Equal(t, 0, counts["ok"]) + assert.Equal(t, 0, counts["missing"]) + assert.Equal(t, 0, counts["borrowed"]) + assert.Equal(t, 0, counts["loaned"]) + assert.Equal(t, 0, counts["removed"]) +} + +func TestCountEntitiesByStatus_WithEntities(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + insertEntity := func(id, status string) { + e := &inventory.Entity{ + EntityID: id, + DisplayName: id, + CanonicalName: id, + FullPathDisplay: id, + FullPathCanonical: id, + Depth: 0, + Status: inventory.EntityStatusOk, + LastEventID: 1, + UpdatedAt: time.Now(), + } + switch status { + case "missing": + e.Status = inventory.EntityStatusMissing + case "removed": + e.Status = inventory.EntityStatusRemoved + } + err := s.ExecInTransaction(ctx, func(tx store.Tx) error { + return s.InsertEntityTx(ctx, tx, e) + }) + require.NoError(t, err) + } + + insertEntity("e1", "ok") + insertEntity("e2", "ok") + insertEntity("e3", "missing") + insertEntity("e4", "removed") + + counts, err := s.CountEntitiesByStatus(ctx) + require.NoError(t, err) + assert.Equal(t, 2, counts["ok"]) + assert.Equal(t, 1, counts["missing"]) + assert.Equal(t, 1, counts["removed"]) + assert.Equal(t, 0, counts["borrowed"]) + assert.Equal(t, 0, counts["loaned"]) +} + func TestGetMetadata_Missing(t *testing.T) { s := openTestStore(t) ctx := context.Background() diff --git a/internal/store/store.go b/internal/store/store.go index 1d9b4c9..e1848ab 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -69,6 +69,11 @@ func (s *Store) Close() error { return s.db.Close() } +// Path returns the absolute filesystem path of the database file. +func (s *Store) Path() string { + return s.cfg.Path +} + // DB returns the underlying [sql.DB]. func (s *Store) DB() *sql.DB { return s.db diff --git a/internal/tui/confirm.go b/internal/tui/confirm.go index 2bb6f97..2a3596e 100644 --- a/internal/tui/confirm.go +++ b/internal/tui/confirm.go @@ -35,6 +35,7 @@ type confirmModel struct { func newConfirmModel(kind confirmKind, entity app.EntityResult, a App, st *styles.Styles) confirmModel { note := textinput.New() note.Placeholder = notePlaceholder + note.Focus() return confirmModel{ kind: kind, entity: entity, @@ -62,9 +63,9 @@ func (c confirmModel) Update(msg tea.Msg) (confirmModel, tea.Cmd) { return c, nil } switch kMsg.String() { - case "y", "enter": + case "enter": return c, c.submitCmd() - case "n", "esc": + case "esc": return c, func() tea.Msg { return confirmCancelledMsg{} } } // All other printable chars feed the note input. @@ -140,7 +141,7 @@ func (c confirmModel) submitCmd() tea.Cmd { func (c confirmModel) View(width int) string { prompt := fmt.Sprintf("mark %q as %s?", c.entity.FullPathDisplay, c.targetStatusLabel()) noteView := c.note.View() - help := c.st.Muted().Render("[y/enter] confirm [n/esc] cancel") + help := c.st.Muted().Render("[enter] confirm [esc] cancel") content := strings.Join([]string{prompt, noteView, help}, "\n") return lipgloss.NewStyle().Width(width).Render(content) } diff --git a/internal/tui/delegate.go b/internal/tui/delegate.go index 5370bc9..99748f0 100644 --- a/internal/tui/delegate.go +++ b/internal/tui/delegate.go @@ -55,5 +55,5 @@ func (d delegate) Render(w io.Writer, m list.Model, index int, listItem list.Ite statusTag = " " + d.st.Muted().Render(fmt.Sprintf("[%s]", i.result.Status.String())) } - fmt.Fprintln(w, prefix+" "+name+statusTag) + fmt.Fprint(w, prefix+" "+name+statusTag) } diff --git a/internal/tui/history.go b/internal/tui/history.go index 8ee206d..6d3f29e 100644 --- a/internal/tui/history.go +++ b/internal/tui/history.go @@ -7,7 +7,6 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/asphaltbuffet/wherehouse/internal/app" "github.com/asphaltbuffet/wherehouse/internal/styles" @@ -22,26 +21,33 @@ type historyModel struct { items []app.HistoryResult appRef App st *styles.Styles + gen int ready bool err error } -func newHistoryModel(entity app.EntityResult, a App, st *styles.Styles) historyModel { +func newHistoryModel( + entity app.EntityResult, a App, st *styles.Styles, paneWidth, paneHeight, gen int, +) historyModel { + vpHeight := max(0, paneHeight-historyUIOverhead) return historyModel{ - entity: entity, - appRef: a, - st: st, + entity: entity, + appRef: a, + st: st, + gen: gen, + viewport: viewport.New(viewport.WithWidth(paneWidth), viewport.WithHeight(vpHeight)), } } func (h historyModel) loadCmd() tea.Cmd { entity := h.entity a := h.appRef + gen := h.gen return func() tea.Msg { items, err := a.GetHistory(context.Background(), app.GetHistoryRequest{ EntityID: entity.EntityID, }) - return historyLoadedMsg{entity: entity, items: items, err: err} + return historyLoadedMsg{entity: entity, items: items, err: err, gen: gen} } } @@ -58,20 +64,9 @@ func (h historyModel) Update(msg tea.Msg) (historyModel, tea.Cmd) { return h, nil case tea.KeyPressMsg: - switch msg.String() { - case "q", "esc": - return h, func() tea.Msg { return historyCancelledMsg{} } - } var cmd tea.Cmd h.viewport, cmd = h.viewport.Update(msg) return h, cmd - - case tea.WindowSizeMsg: - h.viewport = viewport.New(viewport.WithWidth(msg.Width), viewport.WithHeight(msg.Height-historyUIOverhead)) - if h.ready { - h.viewport.SetContent(h.renderContent()) - } - return h, nil } var cmd tea.Cmd @@ -79,8 +74,6 @@ func (h historyModel) Update(msg tea.Msg) (historyModel, tea.Cmd) { return h, cmd } -type historyCancelledMsg struct{} - func (h historyModel) renderContent() string { if h.err != nil { return fmt.Sprintf("error loading history: %v", h.err) @@ -90,28 +83,36 @@ func (h historyModel) renderContent() string { } var sb strings.Builder for _, ev := range h.items { - line := fmt.Sprintf("%s %-30s %s", + sb.WriteString(fmt.Sprintf("%s %s\n %s", ev.TimestampUTC, ev.EventType.String(), ev.ActorUserID, - ) + )) if ev.Note != "" { - line += " " + ev.Note + sb.WriteString(" " + ev.Note) } - sb.WriteString(line) - sb.WriteString("\n") + sb.WriteString("\n\n") } return sb.String() } -func (h historyModel) View(width, height int) string { - title := h.st.TUIDetailLabel().Render("history: " + h.entity.FullPathDisplay) - help := h.st.Muted().Render("[q/esc] back") - content := h.viewport.View() +func (h historyModel) viewportView() string { if h.err != nil { - content = h.st.DangerText().Render(h.err.Error()) + return h.st.DangerText().Render(h.err.Error()) + } + if !h.ready { + return h.st.Muted().Render("loading…") + } + return h.viewport.View() +} + +// Resize returns a copy of h with updated viewport dimensions, preserving loaded content. +func (h historyModel) Resize(w, height int) historyModel { + vpHeight := max(0, height-historyUIOverhead) + h.viewport.SetWidth(w) + h.viewport.SetHeight(vpHeight) + if h.ready { + h.viewport.SetContent(h.renderContent()) } - return lipgloss.NewStyle().Width(width).Height(height).Render( - strings.Join([]string{title, content, help}, "\n"), - ) + return h } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 3bfb86d..76d449d 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -21,6 +21,7 @@ type historyLoadedMsg struct { entity app.EntityResult items []app.HistoryResult err error + gen int } // scryResultsMsg carries FindEntities results for the scry view. diff --git a/internal/tui/model.go b/internal/tui/model.go index 436f5c4..963a556 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -27,10 +27,18 @@ const ( modeBrowse tuiMode = iota // two-pane navigator (default) modeForm // text-input form for add/loan/borrow modeConfirm // y/n prompt for lost/found/return - modeHistory // scrollable event timeline modeScry // inventory-wide search ) +// rightPaneKind controls what the right pane displays in modeBrowse. +type rightPaneKind int + +const ( + rightPaneHidden rightPaneKind = iota // right pane not shown; nav expands to full width + rightPaneDetail // entity detail view + rightPaneHistory // entity event history +) + // borderWidth is the total horizontal space consumed by a single rounded border (left + right). const borderWidth = 2 @@ -74,6 +82,9 @@ type keyMap struct { Found key.Binding History key.Binding Scry key.Binding + Detail key.Binding + PgUp key.Binding + PgDown key.Binding } func defaultKeyMap() keyMap { @@ -138,12 +149,24 @@ func defaultKeyMap() keyMap { key.WithKeys("s"), key.WithHelp("s", "scry"), ), + Detail: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "detail"), + ), + PgUp: key.NewBinding( + key.WithKeys("pgup"), + key.WithHelp("pgup", "scroll up"), + ), + PgDown: key.NewBinding( + key.WithKeys("pgdown"), + key.WithHelp("pgdn", "scroll down"), + ), } } // ShortHelp implements help.KeyMap. func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.Down, k.DrillIn, k.DrillOut, k.Scry, k.History, k.Help, k.Quit} + return []key.Binding{k.Up, k.Down, k.DrillIn, k.DrillOut, k.Scry, k.Detail, k.Help, k.Quit} } // FullHelp implements help.KeyMap. @@ -153,7 +176,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.DrillIn, k.DrillOut, k.Filter}, {k.Add, k.Loan, k.Borrow}, {k.Lost, k.Return, k.Found}, - {k.History, k.Scry, k.Help, k.Quit}, + {k.History, k.Scry, k.Detail, k.Help, k.Quit}, } } @@ -201,7 +224,8 @@ type Model struct { termHeight int err error mode tuiMode - errMsg string // transient detail-pane error, cleared on next action + 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 @@ -221,6 +245,12 @@ func New(a App) Model { 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 @@ -251,8 +281,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateForm(msg) case modeConfirm: return m.updateConfirm(msg) - case modeHistory: - return m.updateHistory(msg) case modeScry: return m.updateScry(msg) } @@ -261,66 +289,90 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 + return m, tea.Quit, true } m.pathStack = nil m.parentStack = nil m = m.loadLevel(msg.items, "") - return m, nil + m, histCmd = m.reloadHistoryCmd() + return m, histCmd, true case childrenLoadedMsg: if msg.err != nil { m.err = msg.err - return m, tea.Quit + return m, tea.Quit, true } m.pathStack = append(m.pathStack, msg.parentPath) m.parentStack = append(m.parentStack, msg.parentID) m = m.loadLevel(msg.items, "") - return m, nil + m, histCmd = m.reloadHistoryCmd() + return m, histCmd, true case levelRestoredMsg: if msg.err != nil { m.err = msg.err - return m, tea.Quit + return m, tea.Quit, true } m.pathStack = msg.pathStack m.parentStack = msg.parentStack m = m.loadLevel(msg.items, "") - return m, nil + m, histCmd = m.reloadHistoryCmd() + return m, histCmd, true case childRefreshMsg: if msg.err != nil { m.errMsg = fmt.Sprintf("refresh failed: %v", msg.err) - return m, nil + return m, nil, true } m = m.loadLevel(msg.items, msg.targetEntityID) - return m, nil + m, histCmd = m.reloadHistoryCmd() + return m, histCmd, true case scryNavigatedMsg: if msg.err != nil { m.errMsg = fmt.Sprintf("navigate failed: %v", msg.err) - return m, nil + return m, nil, true } m.mode = modeBrowse m.pathStack = msg.pathStack m.parentStack = msg.parentStack m = m.loadLevel(msg.items, msg.targetEntityID) - return m, nil - - case tea.KeyPressMsg: - return m.handleKey(msg) - - case tea.WindowSizeMsg: - return m.handleWindowSize(msg), nil + m, histCmd = m.reloadHistoryCmd() + return m, histCmd, true } - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd + return m, nil, false } func (m Model) handleWindowSize(msg tea.WindowSizeMsg) Model { @@ -328,6 +380,9 @@ func (m Model) handleWindowSize(msg tea.WindowSizeMsg) Model { 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()) + } return m } @@ -372,35 +427,12 @@ func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m, m.refreshCmd(msg.result.EntityID) - case tea.KeyPressMsg: - s := msg.String() - if s == "n" || s == keyEsc { - m.mode = modeBrowse - return m, nil - } } var cmd tea.Cmd m.confirm, cmd = m.confirm.Update(msg) return m, cmd } -func (m Model) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case historyCancelledMsg: - m.mode = modeBrowse - return m, nil - case tea.KeyPressMsg: - s := msg.String() - if s == "q" || s == keyEsc { - m.mode = modeBrowse - return m, nil - } - } - var cmd tea.Cmd - m.history, cmd = m.history.Update(msg) - return m, cmd -} - func (m Model) updateScry(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case scryCancelledMsg: @@ -477,12 +509,23 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) return m, nil + case key.Matches(msg, m.keys.PgUp), key.Matches(msg, m.keys.PgDown): + if m.rightPane != rightPaneHistory { + return m, nil + } + var cmd 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.DrillOut): return m.drillUp() + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down): + return m.handleNavKey(msg) + case key.Matches(msg, m.keys.Add): return m.openAdd() @@ -506,6 +549,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Scry): return m.openScry() + + case key.Matches(msg, m.keys.Detail): + return m.toggleDetail() } var cmd tea.Cmd @@ -519,6 +565,44 @@ func (m Model) selectedItem() (item, bool) { return sel, ok } +func (m Model) toggleDetail() (tea.Model, tea.Cmd) { + if _, ok := m.selectedItem(); !ok { + return m, nil + } + if m.rightPane == rightPaneDetail { + m.rightPane = rightPaneHidden + } else { + m.rightPane = rightPaneDetail + } + m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) + 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) + } + return m, listCmd +} + +// 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() + if !ok { + return m, nil + } + m.history = newHistoryModel(sel.result, 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 @@ -621,15 +705,22 @@ func (m Model) openHistory() (tea.Model, tea.Cmd) { if !ok { return m.gateError("no entity selected") } + if m.rightPane == rightPaneHistory { + m.rightPane = rightPaneHidden + m.list.SetSize(m.navPaneInnerWidth(), m.listHeight()) + return m, nil + } m.errMsg = "" - m.history = newHistoryModel(sel.result, m.app, m.st) - m.mode = modeHistory + 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) return m, m.history.loadCmd() } func (m Model) openScry() (tea.Model, tea.Cmd) { m.errMsg = "" m.scry = newScryModel(m.app, m.st) + m.scry.results.SetSize(m.termWidth, m.termHeight-scryUIOverhead) m.mode = modeScry return m, nil } @@ -646,15 +737,21 @@ func (m Model) View() tea.View { switch m.mode { case modeBrowse: header := m.renderHeader() - body := lipgloss.JoinHorizontal(lipgloss.Top, m.renderNavPane(), m.renderDetailPane()) + var body string + switch m.rightPane { + case rightPaneDetail: + body = lipgloss.JoinHorizontal(lipgloss.Top, m.renderNavPane(), m.renderDetailPane()) + case rightPaneHistory: + body = lipgloss.JoinHorizontal(lipgloss.Top, m.renderNavPane(), m.renderHistoryPane()) + case rightPaneHidden: + body = m.renderNavPane() + } helpBar := m.renderHelp() content = lipgloss.JoinVertical(lipgloss.Left, header, body, helpBar) case modeForm: content = m.form.View(m.termWidth) case modeConfirm: content = m.confirm.View(m.termWidth) - case modeHistory: - content = m.history.View(m.termWidth, m.termHeight) case modeScry: content = m.scry.View(m.termWidth, m.termHeight) } @@ -678,6 +775,19 @@ func (m Model) ItemCount() int { return len(m.list.Items()) } // CursorIndex returns the index of the currently highlighted item. func (m Model) CursorIndex() int { return m.list.Index() } +// RightPane returns the current right pane state: "hidden", "detail", or "history". +func (m Model) RightPane() string { + switch m.rightPane { + case rightPaneDetail: + return "detail" + case rightPaneHistory: + return "history" + case rightPaneHidden: + return "hidden" + } + return "hidden" +} + // Mode returns the current mode name for test assertions. func (m Model) Mode() string { switch m.mode { @@ -687,8 +797,6 @@ func (m Model) Mode() string { return "form" case modeConfirm: return "confirm" - case modeHistory: - return "history" case modeScry: return "scry" default: @@ -702,9 +810,15 @@ func (m Model) ErrMsg() string { return m.errMsg } // FormKind returns the active form kind name for test assertions. func (m Model) FormKind() string { return m.form.kindName() } +// ConfirmNote returns the current note field value in modeConfirm for test assertions. +func (m Model) ConfirmNote() string { return m.confirm.note.Value() } + // --- layout helpers --- func (m Model) navPaneWidth() int { + if m.rightPane == rightPaneHidden { + return m.termWidth + } w := max(int(float64(m.termWidth)*navWidthRatio), navPaneMinWidth) return w } @@ -772,6 +886,15 @@ func (m Model) renderDetailPane() string { Render(inner) } +func (m Model) renderHistoryPane() string { + title := m.st.TUIDetailLabel().Render("history: " + m.history.entity.FullPathDisplay) + body := strings.Join([]string{title, m.history.viewportView()}, "\n") + return m.st.TUIDetailBorder(). + Width(m.detailPaneWidth()). + Height(m.paneHeight()). + Render(body) +} + func (m Model) buildDetailContent() string { var prefix string if m.errMsg != "" { diff --git a/internal/tui/scry.go b/internal/tui/scry.go index 5ae2bb5..f0a8c94 100644 --- a/internal/tui/scry.go +++ b/internal/tui/scry.go @@ -45,6 +45,7 @@ func newScryModel(a App, st *styles.Styles) scryModel { l.SetShowTitle(false) l.SetShowFilter(false) l.SetShowStatusBar(false) + l.SetShowHelp(false) l.DisableQuitKeybindings() return scryModel{ diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 0b3901a..7cc5336 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -16,25 +16,26 @@ import ( // fakeTUIApp implements tui.App with controllable return values. type fakeTUIApp struct { - roots []app.EntityResult - children map[string][]app.EntityResult - err error - created []app.EntityResult - createErr error - loaned []app.EntityResult - loanErr error - borrowed []app.EntityResult - borrowErr error - lost []app.EntityResult - lostErr error - returned []app.EntityResult - returnErr error - found []app.EntityResult - foundErr error - history []app.HistoryResult - historyErr error - findResults []app.FindResult - findErr error + roots []app.EntityResult + children map[string][]app.EntityResult + err error + created []app.EntityResult + createErr error + loaned []app.EntityResult + loanErr error + borrowed []app.EntityResult + borrowErr error + lost []app.EntityResult + lostErr error + lastLostReqs []app.ChangeStatusRequest + returned []app.EntityResult + returnErr error + found []app.EntityResult + foundErr error + history []app.HistoryResult + historyErr error + findResults []app.FindResult + findErr error } func (f *fakeTUIApp) GetRootEntities(_ context.Context) ([]app.EntityResult, error) { @@ -60,7 +61,8 @@ func (f *fakeTUIApp) BorrowEntities(_ context.Context, _ []app.BorrowEntityReque return f.borrowed, f.borrowErr } -func (f *fakeTUIApp) MarkLost(_ context.Context, _ []app.ChangeStatusRequest) ([]app.EntityResult, error) { +func (f *fakeTUIApp) MarkLost(_ context.Context, reqs []app.ChangeStatusRequest) ([]app.EntityResult, error) { + f.lastLostReqs = reqs return f.lost, f.lostErr } @@ -84,6 +86,31 @@ func keyMsg(k string) tea.KeyPressMsg { return tea.KeyPressMsg{Text: k} } +// feedBatch executes cmd and feeds all resulting messages back into m. +// Handles both single-message cmds and tea.BatchMsg transparently. +func feedBatch(t *testing.T, m tui.Model, cmd tea.Cmd) tui.Model { + t.Helper() + if cmd == nil { + return m + } + msg := cmd() + switch msg := msg.(type) { + case tea.BatchMsg: + for _, c := range msg { + if c == nil { + continue + } + inner := c() + updated, _ := m.Update(inner) + m = updated.(tui.Model) + } + default: + updated, _ := m.Update(msg) + m = updated.(tui.Model) + } + return m +} + func entityResult(id, name, path string, hasChildren bool) app.EntityResult { return app.EntityResult{ EntityID: id, @@ -452,7 +479,7 @@ func TestModel_ConfirmMode(t *testing.T) { assert.Nil(t, cmd) }) - t.Run("n cancels confirm and returns to browse", func(t *testing.T) { + t.Run("n goes into note field, does not cancel", func(t *testing.T) { f := &fakeTUIApp{roots: []app.EntityResult{okEntity}} m := loadedModelFake(t, f) @@ -460,7 +487,8 @@ func TestModel_ConfirmMode(t *testing.T) { require.Equal(t, "confirm", updated.(tui.Model).Mode()) updated2, _ := updated.(tui.Model).Update(keyMsg("n")) - assert.Equal(t, "browse", updated2.(tui.Model).Mode()) + assert.Equal(t, "confirm", updated2.(tui.Model).Mode()) + assert.Equal(t, "n", updated2.(tui.Model).ConfirmNote()) }) t.Run("esc cancels confirm and returns to browse", func(t *testing.T) { @@ -470,11 +498,63 @@ func TestModel_ConfirmMode(t *testing.T) { updated, _ := m.Update(keyMsg("x")) require.Equal(t, "confirm", updated.(tui.Model).Mode()) - updated2, _ := updated.(tui.Model).Update(keyMsg("esc")) - assert.Equal(t, "browse", updated2.(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()) + assert.Equal(t, "browse", updated3.(tui.Model).Mode()) + }) + + t.Run("typing a letter puts it in the note field", func(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()) + }) + + t.Run("y goes into note field, does not submit", func(t *testing.T) { + f := &fakeTUIApp{roots: []app.EntityResult{okEntity}} + m := loadedModelFake(t, f) + + updated, _ := m.Update(keyMsg("x")) + require.Equal(t, "confirm", updated.(tui.Model).Mode()) + + updated2, _ := updated.(tui.Model).Update(keyMsg("y")) + assert.Equal(t, "confirm", updated2.(tui.Model).Mode()) + assert.Equal(t, "y", updated2.(tui.Model).ConfirmNote()) + }) + + t.Run("enter submits note content to app", func(t *testing.T) { + refreshed := entityResultWithStatus("e1", "Wrench", "Garage:Wrench", inventory.EntityStatusMissing, false) + f := &fakeTUIApp{ + roots: []app.EntityResult{okEntity}, + lost: []app.EntityResult{refreshed}, + } + 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 + + require.Len(t, f.lastLostReqs, 1) + assert.Equal(t, "mia", f.lastLostReqs[0].Note) }) - t.Run("y submits lost and refreshes list", func(t *testing.T) { + t.Run("enter submits lost and refreshes list", func(t *testing.T) { refreshed := entityResultWithStatus("e1", "Wrench", "Garage:Wrench", inventory.EntityStatusMissing, false) f := &fakeTUIApp{ roots: []app.EntityResult{okEntity}, @@ -486,8 +566,8 @@ func TestModel_ConfirmMode(t *testing.T) { updated, _ := m.Update(keyMsg("x")) require.Equal(t, "confirm", updated.(tui.Model).Mode()) - // Press y — fires submitCmd. - updated2, submitCmd := updated.(tui.Model).Update(keyMsg("y")) + // Press enter — fires submitCmd. + updated2, submitCmd := updated.(tui.Model).Update(keyMsg("enter")) require.NotNil(t, submitCmd) // Execute submitCmd → actionDoneMsg. @@ -555,10 +635,44 @@ func TestModel_FormMode(t *testing.T) { }) } -func TestModel_HistoryMode(t *testing.T) { +func TestModel_HistoryRendersEvents(t *testing.T) { entity := entityResultWithStatus("e1", "Wrench", "Garage:Wrench", inventory.EntityStatusOk, false) + f := &fakeTUIApp{ + roots: []app.EntityResult{entity}, + history: []app.HistoryResult{ + { + EventID: 34, + ActorUserID: "alice", + EventType: inventory.EntityCreatedEvent, + TimestampUTC: "2026-05-26T02:40:58Z", + }, + }, + } + 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()) - t.Run("H opens modeHistory and fires load cmd", func(t *testing.T) { + histMsg := loadCmd() + afterLoad, _ := m2.Update(histMsg) + m3 := afterLoad.(tui.Model) + + view := m3.View().Content + assert.Contains(t, view, "alice", "rendered history should include actor name") + assert.Contains(t, view, "2026-05-26T02:40:58Z", "rendered history should include timestamp") + assert.Contains(t, view, "entity.created", "rendered history should include event type") +} + +func TestModel_HistoryPane(t *testing.T) { + entity := entityResultWithStatus("e1", "Wrench", "Garage:Wrench", inventory.EntityStatusOk, false) + + t.Run("H opens history right pane and fires load cmd", func(t *testing.T) { f := &fakeTUIApp{ roots: []app.EntityResult{entity}, history: []app.HistoryResult{{EventID: 1, ActorUserID: "alice"}}, @@ -568,16 +682,17 @@ func TestModel_HistoryMode(t *testing.T) { updated, loadCmd := m.Update(keyMsg("H")) m2 := updated.(tui.Model) - assert.Equal(t, "history", m2.Mode()) + assert.Equal(t, "history", m2.RightPane()) + assert.Equal(t, "browse", m2.Mode()) require.NotNil(t, loadCmd) - // Execute load cmd. + // Execute load cmd — still in browse mode, history pane open. histMsg := loadCmd() updated2, _ := m2.Update(histMsg) - assert.Equal(t, "history", updated2.(tui.Model).Mode()) + assert.Equal(t, "history", updated2.(tui.Model).RightPane()) }) - t.Run("esc from history returns to browse", func(t *testing.T) { + t.Run("H again hides history pane", func(t *testing.T) { f := &fakeTUIApp{ roots: []app.EntityResult{entity}, history: []app.HistoryResult{}, @@ -585,11 +700,12 @@ func TestModel_HistoryMode(t *testing.T) { m := loadedModelFake(t, f) updated, loadCmd := m.Update(keyMsg("H")) - require.Equal(t, "history", updated.(tui.Model).Mode()) + require.Equal(t, "history", updated.(tui.Model).RightPane()) histMsg := loadCmd() updated2, _ := updated.(tui.Model).Update(histMsg) - updated3, _ := updated2.(tui.Model).Update(keyMsg("esc")) + updated3, _ := updated2.(tui.Model).Update(keyMsg("H")) + assert.Equal(t, "hidden", updated3.(tui.Model).RightPane()) assert.Equal(t, "browse", updated3.(tui.Model).Mode()) }) } @@ -617,6 +733,59 @@ func TestModel_ScryMode(t *testing.T) { }) } +func TestModel_ScryShowsResults(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) + + // 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") +} + +func TestModel_ScryNavigate(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) + + // 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()) +} + 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