feat: sticky slideover Save + uncategorised-view polish + regression plan

- Slideover Save action row is now sticky at the bottom of the scrolling
  body (background + top border) so it stays reachable on entries with
  many custom fields or attachments — was previously buried below the
  fold.
- Uncategorised view (state.view = 'folder:All'):
  - Header title reads "(no folder)" instead of the ambiguous "All".
  - Sidebar pseudo-entry stays visible whenever the vault has at least
    one real folder, so it can be used as a drag drop-target to move
    entries out of a folder even when its own count is 0.
  - Empty state gets its own copy ("No uncategorised entries" + hint
    to drop entries here to uncategorise) instead of the generic
    "Folder is empty".
- TEST_REGRESSION.md checked in — 16-section list scoped to what the
  recent Esc / drag / sync / import work touched, so post-commit
  regressions can be walked through methodically instead of poking
  the app ad-hoc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-01 13:04:45 +01:00
parent 4cb45ec63a
commit fbfc24a4d5
4 changed files with 191 additions and 5 deletions
+24 -4
View File
@@ -2344,6 +2344,7 @@ function viewTitle() {
if (state.view === 'authenticator') return 'Authenticator';
if (state.view === 'health') return 'Vault health';
if (state.view === 'audit') return 'Audit log';
if (state.view === 'folder:All') return '(no folder)';
if (state.view.startsWith('folder:')) return state.view.slice(7);
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
return 'Items';
@@ -2484,7 +2485,12 @@ function renderSidebar() {
// No folder icon — visually communicates "this is the absence of a
// folder, not a folder". Drag target so users can quickly uncategorise.
const uncatCount = state.entries.filter(e => !e.folder || e.folder === 'All').length;
if (uncatCount > 0) {
// Show the pseudo-entry whenever the user has real folders in the
// sidebar — it doubles as a drag target to uncategorise entries.
// Also stays visible in the currently-viewed folder:All so a mid-
// action move-out doesn't strand the user.
const hasRealFolders = state.folders.some(f => f && f.name && f.name !== 'All');
if (uncatCount > 0 || state.view === 'folder:All' || hasRealFolders) {
const key = 'folder:All';
const item = el('button', {
class: 'nav-item is-uncategorized' + (state.view === key ? ' is-active' : ''),
@@ -3120,6 +3126,10 @@ function showEmptyState() {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No favorites yet';
msg.innerHTML = 'Click the <b>★</b> on any entry to add it to favorites.';
} else if (state.view === 'folder:All') {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No uncategorised entries';
msg.innerHTML = 'Every entry currently belongs to a folder. Drag one here to remove it from its folder.';
} else if (state.view.startsWith('folder:')) {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'Folder is empty';
@@ -4161,7 +4171,8 @@ async function batchDelete() {
toast(ids.length + ' moved to trash');
await loadEntries();
await loadTrash();
clearChecked();
state.checked.clear();
render(); // full render so sidebar counts (Trash, folders, tags) refresh
}
async function batchRestore() {
@@ -4175,7 +4186,8 @@ async function batchRestore() {
toast(ids.length + ' restored');
await loadEntries();
await loadTrash();
clearChecked();
state.checked.clear();
render();
}
async function batchPermDelete() {
@@ -4195,7 +4207,8 @@ async function batchPermDelete() {
}
toast(ids.length + ' deleted permanently');
await loadTrash();
clearChecked();
state.checked.clear();
render();
}
function renderBatchBar() {
@@ -10597,6 +10610,13 @@ async function init() {
if (e.key !== 'Escape') return;
if (!$('#settingsPanel').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
// When the search box has focus AND a query, the input's own
// handler clears the query (and stops propagation) — let it run.
// Capture phase fires document handlers BEFORE element handlers,
// so without this guard we'd close Settings before the search
// input ever saw the Esc.
const si = $('#settingsSearch');
if (si && si === document.activeElement && si.value) return;
// Don't let the global Esc-fallback handler fire on the same
// keystroke — its slideover-close branch would pop the discard
// confirm in the background while Settings closes, surprising