-
Notifications
You must be signed in to change notification settings - Fork 0
Render actual images as thumbnails using blob URLs #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -124,13 +124,14 @@ | |||||||||||||||||||||||||||
| <!-- Thumbnail or icon --> | ||||||||||||||||||||||||||||
| <div class="file-card-preview"> | ||||||||||||||||||||||||||||
| <img | ||||||||||||||||||||||||||||
| v-if="item.type === 'file' && isMediaFile(item.name)" | ||||||||||||||||||||||||||||
| :src="getThumbnailUrl(item, bucket)" | ||||||||||||||||||||||||||||
| v-if="item.type === 'file' && isMediaFile(item.name) && getThumbnailUrlForItem(item)" | ||||||||||||||||||||||||||||
| :src="getThumbnailUrlForItem(item)" | ||||||||||||||||||||||||||||
| :alt="item.name" | ||||||||||||||||||||||||||||
| class="file-thumbnail-img" | ||||||||||||||||||||||||||||
| loading="lazy" | ||||||||||||||||||||||||||||
| @error="handleImageError($event, item)" | ||||||||||||||||||||||||||||
| @load="handleImageLoad($event, item)" | ||||||||||||||||||||||||||||
| @mouseenter="loadThumbnailBlobUrl(item)" | ||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||
| <q-icon | ||||||||||||||||||||||||||||
| v-else | ||||||||||||||||||||||||||||
|
|
@@ -249,13 +250,14 @@ | |||||||||||||||||||||||||||
| <div class="file-list-cell file-list-name"> | ||||||||||||||||||||||||||||
| <div class="file-list-name-content"> | ||||||||||||||||||||||||||||
| <img | ||||||||||||||||||||||||||||
| v-if="item.type === 'file' && isMediaFile(item.name) && showThumbnailsInList" | ||||||||||||||||||||||||||||
| :src="getThumbnailUrl(item, bucket)" | ||||||||||||||||||||||||||||
| v-if="item.type === 'file' && isMediaFile(item.name) && showThumbnailsInList && getThumbnailUrlForItem(item)" | ||||||||||||||||||||||||||||
| :src="getThumbnailUrlForItem(item)" | ||||||||||||||||||||||||||||
| :alt="item.name" | ||||||||||||||||||||||||||||
| class="file-list-thumbnail" | ||||||||||||||||||||||||||||
| loading="lazy" | ||||||||||||||||||||||||||||
| @error="handleImageError($event, item)" | ||||||||||||||||||||||||||||
| @load="handleImageLoad($event, item)" | ||||||||||||||||||||||||||||
| @mouseenter="loadThumbnailBlobUrl(item)" | ||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||
| <q-icon | ||||||||||||||||||||||||||||
| v-else | ||||||||||||||||||||||||||||
|
|
@@ -556,6 +558,7 @@ import { | |||||||||||||||||||||||||||
| decode, | ||||||||||||||||||||||||||||
| encode, | ||||||||||||||||||||||||||||
| getThumbnailUrl, | ||||||||||||||||||||||||||||
| getThumbnailBlobUrl, | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pipeline failure: Import sorting required. The linter is complaining that imports aren't sorted. You added Run the safe fix suggested by the linter to organize imports, or manually ensure alphabetical ordering. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| isMediaFile, | ||||||||||||||||||||||||||||
| ROOT_FOLDER, | ||||||||||||||||||||||||||||
| } from "../../appUtils"; | ||||||||||||||||||||||||||||
|
|
@@ -618,6 +621,7 @@ export default defineComponent({ | |||||||||||||||||||||||||||
| const touchTimer = ref(null); | ||||||||||||||||||||||||||||
| const touchStartTime = ref(0); | ||||||||||||||||||||||||||||
| const selectAll = ref(false); | ||||||||||||||||||||||||||||
| const thumbnailBlobUrls = ref({}); // Map of file key to blob URL | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Computed | ||||||||||||||||||||||||||||
| const selectedFolder = computed(() => { | ||||||||||||||||||||||||||||
|
|
@@ -684,12 +688,22 @@ export default defineComponent({ | |||||||||||||||||||||||||||
| // Methods | ||||||||||||||||||||||||||||
| const fetchFiles = async () => { | ||||||||||||||||||||||||||||
| loading.value = true; | ||||||||||||||||||||||||||||
| // Clear old thumbnail URLs when changing folders | ||||||||||||||||||||||||||||
| thumbnailBlobUrls.value = {}; | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| items.value = await apiHandler.fetchFile( | ||||||||||||||||||||||||||||
| props.bucket, | ||||||||||||||||||||||||||||
| selectedFolder.value, | ||||||||||||||||||||||||||||
| "/", | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| // Start loading thumbnails for media files in background | ||||||||||||||||||||||||||||
| const mediaFiles = items.value.filter( | ||||||||||||||||||||||||||||
| (item) => item.type === "file" && isMediaFile(item.name), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| // Load first 5 thumbnails immediately, rest on demand | ||||||||||||||||||||||||||||
| for (let i = 0; i < Math.min(5, mediaFiles.length); i++) { | ||||||||||||||||||||||||||||
| loadThumbnailBlobUrl(mediaFiles[i]); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||
| console.error("Failed to fetch files:", error); | ||||||||||||||||||||||||||||
| $q.notify({ | ||||||||||||||||||||||||||||
|
|
@@ -964,6 +978,21 @@ export default defineComponent({ | |||||||||||||||||||||||||||
| return "Folder"; | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const getThumbnailUrlForItem = (item) => { | ||||||||||||||||||||||||||||
| // Return blob URL if available, otherwise return empty string to show fallback | ||||||||||||||||||||||||||||
| return thumbnailBlobUrls.value[item.key] || ""; | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const loadThumbnailBlobUrl = async (item) => { | ||||||||||||||||||||||||||||
| if (thumbnailBlobUrls.value[item.key]) { | ||||||||||||||||||||||||||||
| return; // Already loaded | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const blobUrl = await getThumbnailBlobUrl(item, props.bucket); | ||||||||||||||||||||||||||||
| if (blobUrl) { | ||||||||||||||||||||||||||||
| thumbnailBlobUrls.value[item.key] = blobUrl; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const showContextMenu = (_event, item) => { | ||||||||||||||||||||||||||||
| currentItem.value = item; | ||||||||||||||||||||||||||||
| if (!isSelected(item)) { | ||||||||||||||||||||||||||||
|
|
@@ -1466,6 +1495,12 @@ export default defineComponent({ | |||||||||||||||||||||||||||
| onBeforeUnmount(() => { | ||||||||||||||||||||||||||||
| document.removeEventListener("keydown", handleKeyDown); | ||||||||||||||||||||||||||||
| bus?.off("fetchFiles", fetchFiles); | ||||||||||||||||||||||||||||
| // Clean up blob URLs to prevent memory leaks | ||||||||||||||||||||||||||||
| Object.values(thumbnailBlobUrls.value).forEach((blobUrl) => { | ||||||||||||||||||||||||||||
| if (blobUrl && blobUrl.startsWith("blob:")) { | ||||||||||||||||||||||||||||
| URL.revokeObjectURL(blobUrl); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
Comment on lines
+1498
to
1504
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cleanup logic is correct but incomplete. You're properly revoking blob URLs on component unmount (lines 1498-1504), which is essential for memory management as per the Web Blob API docs. HOWEVER, you're not revoking the old blob URLs when clearing the map at line 692 during folder navigation. This means:
Based on learnings about blob URL lifecycle management, this will accumulate memory until the page is closed or refreshed. See the earlier comment on lines 689-716 for the fix—you need to revoke before clearing the map. 🧰 Tools🪛 GitHub Actions: Build[error] 1500-1502: lint/complexity/useOptionalChain: Change to an optional chain. 🤖 Prompt for AI AgentsPipeline failure: Use optional chain instead of manual checks. The linter is complaining about lines 1500-1502. You're manually checking Apply this diff: // Clean up blob URLs to prevent memory leaks
Object.values(thumbnailBlobUrls.value).forEach((blobUrl) => {
- if (blobUrl && blobUrl.startsWith("blob:")) {
+ if (blobUrl?.startsWith("blob:")) {
URL.revokeObjectURL(blobUrl);
}
});📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: Build[error] 1500-1502: lint/complexity/useOptionalChain: Change to an optional chain. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Watch for folder changes | ||||||||||||||||||||||||||||
|
|
@@ -1499,6 +1534,7 @@ export default defineComponent({ | |||||||||||||||||||||||||||
| currentItem, | ||||||||||||||||||||||||||||
| showThumbnailsInList, | ||||||||||||||||||||||||||||
| selectAll, | ||||||||||||||||||||||||||||
| thumbnailBlobUrls, | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Computed | ||||||||||||||||||||||||||||
| selectedFolder, | ||||||||||||||||||||||||||||
|
|
@@ -1530,6 +1566,8 @@ export default defineComponent({ | |||||||||||||||||||||||||||
| handleImageError, | ||||||||||||||||||||||||||||
| handleImageLoad, | ||||||||||||||||||||||||||||
| getItemCount, | ||||||||||||||||||||||||||||
| getThumbnailUrlForItem, | ||||||||||||||||||||||||||||
| loadThumbnailBlobUrl, | ||||||||||||||||||||||||||||
| showContextMenu, | ||||||||||||||||||||||||||||
| handleDragStart, | ||||||||||||||||||||||||||||
| handleDragOver, | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Missing cache-busting parameters creates stale thumbnail issue.
The new
getThumbnailBlobUrlfunction doesn't include thedispositionor cache version parameters thatgetThumbnailUrluses (lines 515-523). This means:file.customMetadata["cache-version"]is completely ignoredThis is especially problematic since the codebase has a whole "Refresh Cache" feature (see
handleRefreshCache,handleBulkRefreshCachein FileBrowser.vue) that updates cache versions—but those updates won't affect blob URL thumbnails at all!Apply this diff to include cache-busting parameters:
export const getThumbnailBlobUrl = async (file, bucket) => { try { + const cacheVersion = file.customMetadata?.["cache-version"] || Date.now(); + const params = new URLSearchParams({ + v: cacheVersion.toString(), + disposition: "inline" + }); + - const response = await api.get(`/buckets/${bucket}/${encode(file.key)}`, { + const response = await api.get(`/buckets/${bucket}/${encode(file.key)}?${params.toString()}`, { responseType: "arraybuffer", }); const mimeType = file.httpMetadata?.contentType || inferMimeTypeFromFilename(file.name); const blob = new Blob([response.data], { type: mimeType }); return URL.createObjectURL(blob); } catch (error) { console.error("Failed to load thumbnail:", error); return null; } };📝 Committable suggestion
🤖 Prompt for AI Agents