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:
parent
b8cec96256
commit
dea8b27fba
4 changed files with 211 additions and 14 deletions
|
|
@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue