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
+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