Merge commit 'cffe05a' into merge-main-13-seg13-copy-reveal
# Conflicts: # main.go
This commit is contained in:
@@ -118,12 +118,14 @@ type ui struct {
|
|||||||
masterKeyKeyFileOnly widget.Clickable
|
masterKeyKeyFileOnly widget.Clickable
|
||||||
masterKeyComposite widget.Clickable
|
masterKeyComposite widget.Clickable
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
|
historyClicks []widget.Clickable
|
||||||
breadcrumbs []widget.Clickable
|
breadcrumbs []widget.Clickable
|
||||||
groupClicks []widget.Clickable
|
groupClicks []widget.Clickable
|
||||||
state appstate.State
|
state appstate.State
|
||||||
masterKeyMode vault.MasterKeyMode
|
masterKeyMode vault.MasterKeyMode
|
||||||
visible []entry
|
visible []entry
|
||||||
currentPath []string
|
currentPath []string
|
||||||
|
selectedHistoryIndex int
|
||||||
showPassword bool
|
showPassword bool
|
||||||
togglePassword widget.Clickable
|
togglePassword widget.Clickable
|
||||||
phoneSplit widget.Float
|
phoneSplit widget.Float
|
||||||
@@ -138,7 +140,7 @@ type ui struct {
|
|||||||
loadingMessage string
|
loadingMessage string
|
||||||
statusMessage string
|
statusMessage string
|
||||||
errorMessage string
|
errorMessage string
|
||||||
keyboardFocus focusID
|
keyboardFocus focusID
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -205,8 +207,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
|
|||||||
detailList: widget.List{
|
detailList: widget.List{
|
||||||
List: layout.List{Axis: layout.Vertical},
|
List: layout.List{Axis: layout.Vertical},
|
||||||
},
|
},
|
||||||
state: appstate.State{},
|
state: appstate.State{},
|
||||||
masterKeyMode: vault.MasterKeyModePasswordOnly,
|
masterKeyMode: vault.MasterKeyModePasswordOnly,
|
||||||
|
selectedHistoryIndex: -1,
|
||||||
}
|
}
|
||||||
u.state.Session = sess
|
u.state.Session = sess
|
||||||
u.phoneSplit.Value = 0.46
|
u.phoneSplit.Value = 0.46
|
||||||
@@ -305,6 +308,13 @@ func (u *ui) selectedEntry() (entry, bool) {
|
|||||||
return entry{}, false
|
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) {
|
func (u *ui) currentMasterKey() (vault.MasterKey, error) {
|
||||||
password := u.masterPassword.Text()
|
password := u.masterPassword.Text()
|
||||||
path := strings.TrimSpace(u.keyFilePath.Text())
|
path := strings.TrimSpace(u.keyFilePath.Text())
|
||||||
@@ -1057,6 +1067,8 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
},
|
},
|
||||||
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
||||||
|
u.historyPanel,
|
||||||
|
layout.Spacer{Height: unit.Dp(12)}.Layout,
|
||||||
u.entryEditorPanel,
|
u.entryEditorPanel,
|
||||||
}
|
}
|
||||||
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
|
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 {
|
func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
|
||||||
if u.state.Section == appstate.SectionRecycleBin {
|
if u.state.Section == appstate.SectionRecycleBin {
|
||||||
lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin")
|
lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin")
|
||||||
|
|||||||
+77
-1
@@ -884,7 +884,21 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
|
|||||||
u.filter()
|
u.filter()
|
||||||
u.state.SelectedEntryID = "vault-console"
|
u.state.SelectedEntryID = "vault-console"
|
||||||
u.loadSelectedEntryIntoEditor()
|
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 {
|
if err := u.restoreSelectedHistoryAction(); err != nil {
|
||||||
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
|
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) {
|
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+47
-2
@@ -13,6 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (u *ui) loadSelectedEntryIntoEditor() {
|
func (u *ui) loadSelectedEntryIntoEditor() {
|
||||||
|
u.selectedHistoryIndex = -1
|
||||||
|
u.historyIndex.SetText("")
|
||||||
|
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if !ok {
|
if !ok {
|
||||||
u.entryID.SetText("")
|
u.entryID.SetText("")
|
||||||
@@ -44,6 +47,33 @@ func (u *ui) loadSelectedEntryIntoEditor() {
|
|||||||
u.exportAttachmentPath.SetText("")
|
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 {
|
func (u *ui) saveEntryAction() error {
|
||||||
entry, err := u.editorEntry()
|
entry, err := u.editorEntry()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -223,9 +253,9 @@ func (u *ui) removeAttachmentAction() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) restoreSelectedHistoryAction() error {
|
func (u *ui) restoreSelectedHistoryAction() error {
|
||||||
index, err := strconv.Atoi(strings.TrimSpace(u.historyIndex.Text()))
|
index, err := u.selectedHistoryVersionIndex()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid history index: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
|
if err := u.state.RestoreSelectedEntryVersion(index); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -235,6 +265,21 @@ func (u *ui) restoreSelectedHistoryAction() error {
|
|||||||
return nil
|
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 {
|
func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
|
||||||
model, err := u.state.Session.Current()
|
model, err := u.state.Session.Current()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user