diff --git a/main.go b/main.go index 9be7f72..2ff81c1 100644 --- a/main.go +++ b/main.go @@ -118,12 +118,14 @@ type ui struct { masterKeyKeyFileOnly widget.Clickable masterKeyComposite widget.Clickable entryClicks []widget.Clickable + historyClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable state appstate.State masterKeyMode vault.MasterKeyMode visible []entry currentPath []string + selectedHistoryIndex int showPassword bool togglePassword widget.Clickable phoneSplit widget.Float @@ -138,7 +140,7 @@ type ui struct { loadingMessage string statusMessage string errorMessage string - keyboardFocus focusID + keyboardFocus focusID } var ( @@ -205,8 +207,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, - state: appstate.State{}, - masterKeyMode: vault.MasterKeyModePasswordOnly, + state: appstate.State{}, + masterKeyMode: vault.MasterKeyModePasswordOnly, + selectedHistoryIndex: -1, } u.state.Session = sess u.phoneSplit.Value = 0.46 @@ -305,6 +308,13 @@ func (u *ui) selectedEntry() (entry, bool) { return entry{}, false } +func (u *ui) ensureHistoryClickables() { + history := u.visibleHistory() + if len(u.historyClicks) < len(history) { + u.historyClicks = make([]widget.Clickable, len(history)) + } +} + func (u *ui) currentMasterKey() (vault.MasterKey, error) { password := u.masterPassword.Text() path := strings.TrimSpace(u.keyFilePath.Text()) @@ -1057,6 +1067,8 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(12)}.Layout, + u.historyPanel, + layout.Spacer{Height: unit.Dp(12)}.Layout, u.entryEditorPanel, } return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { @@ -1091,6 +1103,118 @@ func (u *ui) banner(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions { + history := u.visibleHistory() + u.ensureHistoryClickables() + + children := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "History") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + } + + if len(history) == 0 { + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "No history for this entry yet.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + })) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + } + + for i := range history { + index := i + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.historyRow(gtx, &u.historyClicks[index], index, history[index]) + })) + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + + if selected, ok := u.selectedHistoryEntry(); ok { + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Selected Version") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(detailLine(u.theme, "Path", strings.Join(selected.Path, " / "))), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(detailLine(u.theme, "Username", selected.Username)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(detailLine(u.theme, "URL", selected.URL)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Body2(u.theme, selected.Notes) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + } + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + +func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int, item entry) layout.Dimensions { + for click.Clicked(gtx) { + _ = u.selectHistoryVersion(index) + } + + return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + row := func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), fmt.Sprintf("Version %d", index)) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), item.Username) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), item.URL) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Body2(u.theme, item.Notes) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + } + + if index == u.selectedHistoryIndex { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + size := gtx.Constraints.Min + if size.X == 0 { + size.X = gtx.Constraints.Max.X + } + if size.Y == 0 { + size.Y = gtx.Constraints.Max.Y + } + paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + return layout.Dimensions{Size: size} + }), + layout.Stacked(row), + ) + } + + return layout.Background{}.Layout(gtx, fill(panelColor), row) + }) +} + func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin") diff --git a/main_test.go b/main_test.go index c1c9c56..e1cba08 100644 --- a/main_test.go +++ b/main_test.go @@ -884,7 +884,21 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() - u.historyIndex.SetText("0") + + history := u.visibleHistory() + if len(history) != 1 { + t.Fatalf("len(visibleHistory()) = %d, want 1", len(history)) + } + if history[0].Password != "token-1" { + t.Fatalf("visibleHistory()[0].Password = %q, want %q", history[0].Password, "token-1") + } + + if err := u.selectHistoryVersion(0); err != nil { + t.Fatalf("selectHistoryVersion(0) error = %v", err) + } + if got := u.historyIndex.Text(); got != "0" { + t.Fatalf("historyIndex.Text() = %q, want %q", got, "0") + } if err := u.restoreSelectedHistoryAction(); err != nil { t.Fatalf("restoreSelectedHistoryAction() error = %v", err) @@ -895,6 +909,68 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { } } +func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + History: []vault.Entry{ + { + ID: "vault-console-h1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + Notes: "previous token", + }, + { + ID: "vault-console-h0", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-0", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + Notes: "oldest token", + }, + }, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + + history := u.visibleHistory() + if len(history) != 2 { + t.Fatalf("len(visibleHistory()) = %d, want 2", len(history)) + } + if history[1].Notes != "oldest token" { + t.Fatalf("visibleHistory()[1].Notes = %q, want %q", history[1].Notes, "oldest token") + } + + if err := u.selectHistoryVersion(1); err != nil { + t.Fatalf("selectHistoryVersion(1) error = %v", err) + } + + selected, ok := u.selectedHistoryEntry() + if !ok { + t.Fatal("selectedHistoryEntry() ok = false, want true") + } + if selected.Password != "token-0" { + t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0") + } +} + func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { t.Parallel() diff --git a/ui_editor.go b/ui_editor.go index 111b264..4a90f84 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -13,6 +13,9 @@ import ( ) func (u *ui) loadSelectedEntryIntoEditor() { + u.selectedHistoryIndex = -1 + u.historyIndex.SetText("") + item, ok := u.selectedEntry() if !ok { u.entryID.SetText("") @@ -44,6 +47,33 @@ func (u *ui) loadSelectedEntryIntoEditor() { u.exportAttachmentPath.SetText("") } +func (u *ui) visibleHistory() []vault.Entry { + item, ok := u.selectedEntry() + if !ok || len(item.History) == 0 { + return nil + } + return append([]vault.Entry(nil), item.History...) +} + +func (u *ui) selectedHistoryEntry() (vault.Entry, bool) { + history := u.visibleHistory() + if u.selectedHistoryIndex < 0 || u.selectedHistoryIndex >= len(history) { + return vault.Entry{}, false + } + return history[u.selectedHistoryIndex], true +} + +func (u *ui) selectHistoryVersion(index int) error { + history := u.visibleHistory() + if index < 0 || index >= len(history) { + return fmt.Errorf("history index %d out of range", index) + } + + u.selectedHistoryIndex = index + u.historyIndex.SetText(strconv.Itoa(index)) + return nil +} + func (u *ui) saveEntryAction() error { entry, err := u.editorEntry() if err != nil { @@ -223,9 +253,9 @@ func (u *ui) removeAttachmentAction() error { } func (u *ui) restoreSelectedHistoryAction() error { - index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text())) + index, err := u.selectedHistoryVersionIndex() if err != nil { - return fmt.Errorf("invalid history index: %w", err) + return err } if err := u.state.RestoreSelectedEntryVersion(index); err != nil { return err @@ -235,6 +265,21 @@ func (u *ui) restoreSelectedHistoryAction() error { return nil } +func (u *ui) selectedHistoryVersionIndex() (int, error) { + text := strings.TrimSpace(u.historyIndex.Text()) + if text != "" { + index, err := strconv.Atoi(text) + if err != nil { + return 0, fmt.Errorf("invalid history index: %w", err) + } + return index, nil + } + if u.selectedHistoryIndex >= 0 { + return u.selectedHistoryIndex, nil + } + return 0, fmt.Errorf("no history version selected") +} + func (u *ui) copySelectedFieldAction(target clipboard.Target) error { model, err := u.state.Session.Current() if err != nil {