feat(ui): 条目管理增强 — 重命名/删除/重排序

每行右侧增加 ⋯ 按钮,弹出菜单支持重命名、删除、上移、下移。
- renameEntry / deleteEntry / moveEntry 三个 appState 方法
- rowActionMenu 弹出菜单 + renameDialog + confirmDeleteDialog
- 删除自动注销快捷键,所有操作触发配置保存
- 保留顶栏 Add/Remove 全局按钮不变
This commit is contained in:
2026-06-12 09:19:07 +08:00
parent c0dd3b9028
commit 352b1d24b8
7 changed files with 461 additions and 1 deletions
+38
View File
@@ -465,3 +465,41 @@ other = "Beim Schließen in den Tray minimieren"
[hint_minimize_to_tray]
other = "Schließen des Fensters versteckt es im System-Tray statt das Programm zu beenden."
# --- Eintragsaktionen (umbenennen / löschen / verschieben) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "Löschen"
[dialog_row_actions_title]
other = "Eintragsaktionen"
[action_rename]
other = "Umbenennen..."
[action_delete]
other = "Löschen..."
[action_move_up]
other = "Nach oben"
[action_move_down]
other = "Nach unten"
[dialog_rename_title]
other = "Eintrag umbenennen"
[label_entry_name]
other = "Neuer Name"
[msg_empty_name]
other = "Name darf nicht leer sein."
[dialog_confirm_delete_title]
other = "Löschen bestätigen"
[msg_confirm_delete]
other = "Eintrag \"%s\" wirklich löschen? Dies kann nicht rückgängig gemacht werden."
+38
View File
@@ -467,3 +467,41 @@ other = "Minimize to tray when closing the window"
[hint_minimize_to_tray]
other = "Closing the window hides it to the system tray instead of exiting."
# --- Entry actions (rename / delete / reorder) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "Delete"
[dialog_row_actions_title]
other = "Entry actions"
[action_rename]
other = "Rename..."
[action_delete]
other = "Delete..."
[action_move_up]
other = "Move up"
[action_move_down]
other = "Move down"
[dialog_rename_title]
other = "Rename entry"
[label_entry_name]
other = "New name"
[msg_empty_name]
other = "Name cannot be empty."
[dialog_confirm_delete_title]
other = "Confirm delete"
[msg_confirm_delete]
other = "Delete the entry \"%s\"? This cannot be undone."
+38
View File
@@ -465,3 +465,41 @@ other = "关闭窗口时最小化到托盘"
[hint_minimize_to_tray]
other = "勾选后点击窗口关闭按钮将隐藏到系统托盘而非退出程序。"
# --- 条目操作(重命名 / 删除 / 重排序) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "删除"
[dialog_row_actions_title]
other = "条目操作"
[action_rename]
other = "重命名..."
[action_delete]
other = "删除..."
[action_move_up]
other = "上移"
[action_move_down]
other = "下移"
[dialog_rename_title]
other = "重命名条目"
[label_entry_name]
other = "新名称"
[msg_empty_name]
other = "名称不能为空。"
[dialog_confirm_delete_title]
other = "确认删除"
[msg_confirm_delete]
other = "确定删除条目 \"%s\"?此操作不可撤销。"
+128 -1
View File
@@ -73,6 +73,8 @@ type entry struct {
hotkeyBtn widget.Clickable
// copyBtn copies the current code to the clipboard.
copyBtn widget.Clickable
// moreBtn opens the per-row ⋯ popup (rename / delete / move).
moreBtn widget.Clickable
}
type appState struct {
@@ -97,6 +99,15 @@ type appState struct {
prefsDialog *preferencesDialog
aboutDialog *aboutDialog
// Per-row action popup state. rowMenu owns the visible popup;
// renameDlg / confirmDelDlg are the follow-up modals it spawns.
// rowTargetIdx is the entry index the popup applies to; it is
// re-resolved on each frame in case the entries slice mutated.
rowMenu *rowActionMenu
renameDlg *renameDialog
confirmDelDlg *confirmDeleteDialog
rowTargetIdx int
store *store
saveErr string // surfaced in the top bar
@@ -298,6 +309,57 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) {
}
}
// renameEntry sets a new display name for the entry at idx. Empty names
// are rejected by the caller; here we trust the input and only guard
// the index. Pushes a save on success.
func (st *appState) renameEntry(idx int, name string) {
st.mu.Lock()
if idx < 0 || idx >= len(st.entries) {
st.mu.Unlock()
return
}
st.entries[idx].Name = name
st.mu.Unlock()
st.store.Push()
}
// deleteEntry removes the entry at idx, unregistering its hotkey if
// any. Bounds-checked; out-of-range indexes are a silent no-op so a
// stale popup that survived a concurrent mutation cannot panic.
func (st *appState) deleteEntry(idx int) {
const fn = "internal.ui.appState.deleteEntry"
st.mu.Lock()
if idx < 0 || idx >= len(st.entries) {
st.mu.Unlock()
return
}
en := st.entries[idx]
if en.hotkeyID != 0 && st.hkMgr != nil {
if err := st.hkMgr.Unregister(en.hotkeyID); err != nil {
global.Log.WithField("func", fn).WithError(err).
Warn("unregister hotkey during delete failed")
}
en.hotkeyID = 0
}
st.entries = append(st.entries[:idx], st.entries[idx+1:]...)
st.mu.Unlock()
st.store.Push()
}
// moveEntry swaps the entry at idx with its neighbour delta steps away
// (typically ±1). Bounds-checked. Pushes a save on a successful swap.
func (st *appState) moveEntry(idx, delta int) {
st.mu.Lock()
j := idx + delta
if idx < 0 || idx >= len(st.entries) || j < 0 || j >= len(st.entries) {
st.mu.Unlock()
return
}
st.entries[idx], st.entries[j] = st.entries[j], st.entries[idx]
st.mu.Unlock()
st.store.Push()
}
func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions {
// Auto-lock check runs before any input is dispatched so a user who
// returns mid-frame still has to type the passphrase before they can
@@ -345,7 +407,8 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
var tradesTarget *entry
var hotkeyTarget *entry
var copyTarget *entry
for _, en := range st.entries {
moreTargetIdx := -1
for i, en := range st.entries {
if en.Auth.Name() == "steam" {
if en.tradesBtn.Clicked(gtx) {
tradesTarget = en
@@ -360,6 +423,10 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
copyTarget = en
st.lastActivity = time.Now()
}
if en.moreBtn.Clicked(gtx) {
moreTargetIdx = i
st.lastActivity = time.Now()
}
if en.Auth.Name() == "hotp" {
if en.click.Clicked(gtx) {
if code, err := en.Auth.CurrentCode(); err == nil {
@@ -393,6 +460,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if copyTarget != nil {
st.copyCodeToClipboard(copyTarget, w)
}
if moreTargetIdx >= 0 {
st.mu.Lock()
total := len(st.entries)
st.mu.Unlock()
st.rowTargetIdx = moreTargetIdx
st.rowMenu = newRowActionMenu(moreTargetIdx, total)
}
// Password retry / first-decrypt loop.
if st.pwDialog != nil {
@@ -558,6 +632,55 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
})
}
// Per-row action popup. Must be resolved before the entry list
// renders so the popup overlays the main UI without interference.
if st.rowMenu != nil {
action, closed := st.rowMenu.Pick(gtx)
if closed {
idx := st.rowTargetIdx
st.rowMenu = nil
switch action {
case rowActionRename:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
st.renameDlg = newRenameDialog(st.entries[idx].Name)
}
st.mu.Unlock()
case rowActionDelete:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
st.confirmDelDlg = newConfirmDeleteDialog(st.entries[idx].Name)
}
st.mu.Unlock()
case rowActionMoveUp:
st.moveEntry(idx, -1)
case rowActionMoveDown:
st.moveEntry(idx, +1)
}
w.Invalidate()
} else {
return st.rowMenu.Layout(gtx, th)
}
}
if st.renameDlg != nil {
return st.renameDlg.Layout(gtx, th, func(name string, cancel bool) {
if !cancel {
st.renameEntry(st.rowTargetIdx, name)
}
st.renameDlg = nil
w.Invalidate()
})
}
if st.confirmDelDlg != nil {
return st.confirmDelDlg.Layout(gtx, th, func(confirmed bool) {
if confirmed {
st.deleteEntry(st.rowTargetIdx)
}
st.confirmDelDlg = nil
w.Invalidate()
})
}
if st.tradesDialog != nil {
return st.tradesDialog.Layout(gtx, th)
}
@@ -647,6 +770,10 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.moreBtn, i18n.T("btn_more")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code)
lbl.Color = activePalette.RingFg
+48
View File
@@ -0,0 +1,48 @@
package ui
import (
"fmt"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// confirmDeleteDialog asks the user to confirm deletion of a specific
// entry. The dialog displays the target name verbatim in the prompt so
// the user can sanity-check what they are about to lose.
type confirmDeleteDialog struct {
name string
okBtn widget.Clickable
cancelBtn widget.Clickable
}
func newConfirmDeleteDialog(name string) *confirmDeleteDialog {
return &confirmDeleteDialog{name: name}
}
func (d *confirmDeleteDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(confirmed bool),
) layout.Dimensions {
if d.cancelBtn.Clicked(gtx) {
onDone(false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.okBtn.Clicked(gtx) {
onDone(true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body1(th,
fmt.Sprintf(i18n.T("msg_confirm_delete"), d.name)).Layout),
)
}
return modalCard(gtx, th, i18n.T("dialog_confirm_delete_title"),
i18n.T("btn_delete"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancelBtn, body)
}
+54
View File
@@ -0,0 +1,54 @@
package ui
import (
"strings"
"gioui.org/layout"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
type renameDialog struct {
ed widget.Editor
okBtn widget.Clickable
cancel widget.Clickable
errMsg string
}
func newRenameDialog(currentName string) *renameDialog {
d := &renameDialog{}
d.ed.SingleLine = true
d.ed.SetText(currentName)
return d
}
func (d *renameDialog) Layout(
gtx layout.Context, th *material.Theme,
onDone func(string, bool),
) layout.Dimensions {
if d.cancel.Clicked(gtx) {
onDone("", true)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
if d.okBtn.Clicked(gtx) {
name := strings.TrimSpace(d.ed.Text())
if name == "" {
d.errMsg = i18n.T("msg_empty_name")
} else {
onDone(name, false)
return layout.Dimensions{Size: gtx.Constraints.Max}
}
}
body := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(th, i18n.T("label_entry_name"), &d.ed, "")),
layout.Rigid(errorLabel(th, d.errMsg)),
)
}
return modalCard(gtx, th, i18n.T("dialog_rename_title"),
i18n.T("btn_ok"), i18n.T("btn_cancel"),
&d.okBtn, &d.cancel, body)
}
+117
View File
@@ -0,0 +1,117 @@
package ui
import (
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
// rowAction is what the per-row ⋯ popup returns to the caller.
type rowAction int
const (
rowActionNone rowAction = iota
rowActionRename
rowActionDelete
rowActionMoveUp
rowActionMoveDown
)
// rowActionMenu is the small popup opened by the ⋯ button on each entry
// row. It carries the target entry index so the caller knows which row
// the chosen action applies to. moveUp/moveDown are disabled (rendered
// but inert) when the index is at a boundary.
type rowActionMenu struct {
idx int
canMoveUp bool
canMoveDown bool
renameBtn widget.Clickable
deleteBtn widget.Clickable
moveUpBtn widget.Clickable
moveDownBtn widget.Clickable
cancelBtn widget.Clickable
}
func newRowActionMenu(idx, total int) *rowActionMenu {
return &rowActionMenu{
idx: idx,
canMoveUp: idx > 0,
canMoveDown: idx < total-1,
}
}
// Pick reports the user's choice and whether the menu should close.
func (m *rowActionMenu) Pick(gtx layout.Context) (rowAction, bool) {
switch {
case m.renameBtn.Clicked(gtx):
return rowActionRename, true
case m.deleteBtn.Clicked(gtx):
return rowActionDelete, true
case m.moveUpBtn.Clicked(gtx):
if !m.canMoveUp {
return rowActionNone, false
}
return rowActionMoveUp, true
case m.moveDownBtn.Clicked(gtx):
if !m.canMoveDown {
return rowActionNone, false
}
return rowActionMoveDown, true
case m.cancelBtn.Clicked(gtx):
return rowActionNone, true
}
return rowActionNone, false
}
func (m *rowActionMenu) Layout(gtx layout.Context, th *material.Theme) layout.Dimensions {
fillBackground(gtx, activePalette.ScrimBg)
row := func(btn *widget.Clickable, label string, enabled bool) layout.FlexChild {
return layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(4), Bottom: unit.Dp(4)}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
b := material.Button(th, btn, label)
if !enabled {
// Visually dim the disabled state; Pick also
// refuses to dispatch the action so clicks
// are inert beyond the colour change.
b.Background = activePalette.RingBg
b.Color = activePalette.MutedFg
}
return b.Layout(gtx)
})
})
}
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Max.X = gtx.Dp(260)
return widget.Border{
Color: activePalette.DialogBorder,
CornerRadius: unit.Dp(4),
Width: unit.Dp(1),
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
fillBackground(gtx, activePalette.DialogBg)
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(th, i18n.T("dialog_row_actions_title")).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
row(&m.renameBtn, i18n.T("action_rename"), true),
row(&m.deleteBtn, i18n.T("action_delete"), true),
row(&m.moveUpBtn, i18n.T("action_move_up"), m.canMoveUp),
row(&m.moveDownBtn, i18n.T("action_move_down"), m.canMoveDown),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(material.Button(th, &m.cancelBtn, i18n.T("btn_cancel")).Layout),
)
}),
)
})
})
})
}