1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(frontend): j/k keyboard navigation for entries; test avatar cached_bytes

App.jsx — j/k handler:
- Fires on keydown when not focused on INPUT/TEXTAREA/SELECT/contenteditable
- Ignores meta/ctrl/alt modifier combos
- Uses DOM order (#entries-body [data-entry-uid]) so expanded child rows
  participate, matching the shift-range selection logic
- Resolves target entry via entryCacheRef → root entries array →
  fetchEntryDetail(..).summary on cache miss
- Scrolls target into view (block: nearest); updates lastAnchorIndexRef
  so subsequent shift-click ranges start from the keyboard-navigated row

archive.rs — cached_bytes_excludes_avatar_blobs test:
- Two tweet entries sharing an avatar blob (100B) and a media blob (900B)
- Asserts refresh_entry_cached_bytes sets cached_bytes = 900 (not 1000)
- Asserts list_root_entries summary: cached_bytes=900, cacheable_bytes=900,
  total_artifact_bytes=1000 — SIZE includes avatar, % cached denominator
  and numerator both exclude it
This commit is contained in:
TheGeneralist 2026-07-21 15:49:05 +02:00
parent b8cec96256
commit dea8b27fba
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 211 additions and 14 deletions

View file

@ -1853,4 +1853,151 @@ mod tests {
assert!(!urls_a.contains(vid_b), "should not include children of other playlists");
}
#[test]
fn cached_bytes_excludes_avatar_blobs() {
let (conn, user_id, run_id) = make_tag_test_db();
// entry_a is older (created first, gets the lower id)
let entry_a = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Tweet A",
"https://twitter.com/user/status/1",
);
// entry_b is newer (created second, higher id — tiebreak on id when archived_at is equal)
let entry_b = make_entry_in_db(
&conn,
user_id,
run_id,
None,
None,
"Tweet B",
"https://twitter.com/user/status/2",
);
// Shared avatar blob: 100 bytes
let avatar_blob_id = database::upsert_blob(
&conn,
&database::BlobRecord {
sha256: "avatar_sha256_test".to_string(),
byte_size: 100,
mime_type: Some("image/jpeg".to_string()),
extension: Some("jpg".to_string()),
raw_relpath: "raw/av/at/avatar.jpg".to_string(),
},
)
.unwrap();
// Shared media blob: 900 bytes
let media_blob_id = database::upsert_blob(
&conn,
&database::BlobRecord {
sha256: "media_sha256_test".to_string(),
byte_size: 900,
mime_type: Some("image/png".to_string()),
extension: Some("png".to_string()),
raw_relpath: "raw/me/di/media.png".to_string(),
},
)
.unwrap();
// Attach both artifacts to entry_a
database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry_a.id,
artifact_role: "avatar".to_string(),
storage_area: "raw".to_string(),
relpath: "raw/av/at/avatar.jpg".to_string(),
blob_id: Some(avatar_blob_id),
logical_path: None,
metadata_json: None,
},
)
.unwrap();
database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry_a.id,
artifact_role: "primary_media".to_string(),
storage_area: "raw".to_string(),
relpath: "raw/me/di/media.png".to_string(),
blob_id: Some(media_blob_id),
logical_path: None,
metadata_json: None,
},
)
.unwrap();
// Attach both artifacts to entry_b (same blobs — simulates shared avatar/media)
database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry_b.id,
artifact_role: "avatar".to_string(),
storage_area: "raw".to_string(),
relpath: "raw/av/at/avatar.jpg".to_string(),
blob_id: Some(avatar_blob_id),
logical_path: None,
metadata_json: None,
},
)
.unwrap();
database::add_entry_artifact(
&conn,
&database::NewArtifact {
entry_id: entry_b.id,
artifact_role: "primary_media".to_string(),
storage_area: "raw".to_string(),
relpath: "raw/me/di/media.png".to_string(),
blob_id: Some(media_blob_id),
logical_path: None,
metadata_json: None,
},
)
.unwrap();
// Recompute cached_bytes for entry_b.
// The query excludes avatar-role artifacts and counts only non-avatar blobs
// that are already held by an earlier entry. entry_a (lower id) owns the
// same media blob, so cached_bytes for entry_b should be 900, not 1000.
database::refresh_entry_cached_bytes(&conn, entry_b.id).unwrap();
// --- Assert raw DB value ---
let cached_bytes_db: i64 = conn
.query_row(
"SELECT cached_bytes FROM archived_entries WHERE id = ?1",
[entry_b.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(
cached_bytes_db, 900,
"cached_bytes should be 900 (media only); avatar blob must be excluded"
);
// --- Assert list_root_entries summary for entry_b ---
let entries = list_root_entries(&conn, 12).unwrap(); // 12 = ADMIN bits
let summary_b = entries
.iter()
.find(|e| e.entry_uid == entry_b.entry_uid)
.expect("entry_b must appear in list_root_entries");
assert_eq!(
summary_b.cached_bytes, 900,
"summary cached_bytes must equal 900 (precomputed, avatar excluded)"
);
assert_eq!(
summary_b.cacheable_bytes, 900,
"cacheable_bytes (non-avatar total) must be 900 for entry_b"
);
assert_eq!(
summary_b.total_artifact_bytes, 1000,
"total_artifact_bytes must be 1000 (avatar 100 + media 900)"
);
}
}

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-C9dla3pB.js"></script>
<script type="module" crossorigin src="/assets/index-B0h3YYO-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8ic-z4p.css">
</head>
<body>

View file

@ -450,6 +450,56 @@ export default function App() {
return () => document.removeEventListener('keydown', handler)
}, [view])
// j/k: navigate entries down/up when not focused on an input element.
useEffect(() => {
const handler = (e) => {
// Ignore when a modifier is held (don't steal browser/app shortcuts)
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key !== 'j' && e.key !== 'k') return
// Ignore when focus is on any editable target
const tag = document.activeElement?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (document.activeElement?.isContentEditable) return
const allNodes = [...document.querySelectorAll('#entries-body [data-entry-uid]')]
if (allNodes.length === 0) return
const [currentUid] = selectedUids.size === 1 ? selectedUids : [null]
const currentIdx = currentUid
? allNodes.findIndex(n => n.dataset.entryUid === currentUid)
: -1
const nextIdx = e.key === 'j'
? Math.min(currentIdx + 1, allNodes.length - 1)
: Math.max(currentIdx - 1, 0)
if (nextIdx === currentIdx && currentIdx !== -1) return
const nextNode = allNodes[nextIdx < 0 ? 0 : nextIdx]
const nextUid = nextNode.dataset.entryUid
lastAnchorIndexRef.current = nextUid
setSelectedUids(new Set([nextUid]))
// Resolve entry object: cache root entries array server fetch
const cached = entryCacheRef.current.get(nextUid)
?? entries.find(x => x.entry_uid === nextUid)
?? null
if (cached) {
selectEntry(cached)
} else {
fetchEntryDetail(archiveId, nextUid)
.then(det => { if (det?.summary) selectEntry(det.summary) })
.catch(() => {})
}
nextNode.scrollIntoView({ block: 'nearest' })
e.preventDefault()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [selectedUids, entries, selectEntry, archiveId])
// After switching to archive view via K, focus the search input once rendered.
useEffect(() => {
if (view === 'archive' && pendingSearchFocus.current) {