feat(ui): 条目管理增强 — 重命名/删除/重排序
每行右侧增加 ⋯ 按钮,弹出菜单支持重命名、删除、上移、下移。 - renameEntry / deleteEntry / moveEntry 三个 appState 方法 - rowActionMenu 弹出菜单 + renameDialog + confirmDeleteDialog - 删除自动注销快捷键,所有操作触发配置保存 - 保留顶栏 Add/Remove 全局按钮不变
This commit is contained in:
+128
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user