diff --git a/.gitignore b/.gitignore index 44f16ac..f769464 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ **/out/ src/test/samples/env/ src/test/samples/folder* -.vscode-test-web/ \ No newline at end of file +.vscode-test-web/ +.history +.github +.vscode-test \ No newline at end of file diff --git a/package.json b/package.json index 2776646..3739818 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "watch-tests": "tsc -p . -w --outDir dist", "pretest": "npm run compile-tests && npm run compile && npm run lint", "lint": "eslint src --ext ts", - "test": "node ./out/test/runTest.js" + "test": "node ./dist/test/runTest.js" }, "devDependencies": { "@types/glob": "^7.2.0", diff --git a/src/custom_typings.ts b/src/custom_typings.ts index d8989a2..c0fab87 100644 --- a/src/custom_typings.ts +++ b/src/custom_typings.ts @@ -9,6 +9,7 @@ declare module 'custom_typings' { mtime: number, ctime: number, status: "" | "refresh", + loaded?: boolean, // Whether metadata has been loaded }; export type TFolder = { @@ -17,5 +18,7 @@ declare module 'custom_typings' { images: { [imageId: string]: TImage, }, + imageCount?: number, // Quick count without loading metadata + loaded?: boolean, // Whether metadata has been loaded for this folder }; } \ No newline at end of file diff --git a/src/gallery/gallery.ts b/src/gallery/gallery.ts index 80abaa4..3956003 100644 --- a/src/gallery/gallery.ts +++ b/src/gallery/gallery.ts @@ -39,6 +39,8 @@ export function deactivate() { class GalleryWebview { private gFolders: Record = {}; private customSorter: CustomSorter = new CustomSorter(); + private metadataLoadingInProgress: boolean = false; + private metadataProgress: { loaded: number; total: number } = { loaded: 0, total: 0 }; constructor(private readonly context: vscode.ExtensionContext) { } @@ -70,24 +72,65 @@ class GalleryWebview { ); const htmlProvider = new HTMLProvider(this.context, panel.webview); + + // Fast initial load - just folder structure const imageUris = await this.getImageUris(galleryFolder); - this.gFolders = await utils.getFolders(imageUris); + this.gFolders = await utils.getFoldersStructure(imageUris); this.gFolders = this.customSorter.sort(this.gFolders); panel.webview.html = htmlProvider.fullHTML(); - const imageSizeStat = utils.getImageSizeStat(this.gFolders); + // Send quick telemetry for initial load + const totalImageCount = Object.values(this.gFolders).reduce((sum, folder) => sum + (folder.imageCount || 0), 0); reporter.sendTelemetryEvent('gallery.createPanel', {}, { "duration": Date.now() - startTime, "folderCount": Object.keys(this.gFolders).length, - "imageCount": imageSizeStat.count, - "imageSizeMean": imageSizeStat.mean, - "imageSizeStd": imageSizeStat.std, + "imageCount": totalImageCount, + "imageSizeMean": 0, // Will be calculated when metadata loads + "imageSizeStd": 0, }); + return panel; } - public messageListener(message: Record, webview: vscode.Webview) { + private async loadMetadataForSort(folders: TFolder[], sortBy: "name" | "ext" | "size" | "ctime" | "mtime", ascending: boolean, webview: vscode.Webview) { + if (this.metadataLoadingInProgress) { return; } + + this.metadataLoadingInProgress = true; + let loaded = 0; + const total = folders.length; + + // Load folders one by one to show progress + for (const folder of folders) { + try { + this.gFolders[folder.id] = await utils.loadFolderMetadata(folder); + loaded++; + + // Send progress update + webview.postMessage({ + command: "POST.gallery.metadataProgress", + progress: { loaded, total } + }); + } catch (error) { + console.error(`Failed to load metadata for folder ${folder.id}:`, error); + loaded++; + } + } + + this.metadataLoadingInProgress = false; + + // Now sort with all metadata loaded + this.gFolders = this.customSorter.sort(this.gFolders, sortBy, ascending); + + // Send completion and updated content + webview.postMessage({ + command: "POST.gallery.metadataComplete" + }); + + this.messageListener({ command: "POST.gallery.requestContentDOMs" }, webview).catch(console.error); + } + + public async messageListener(message: Record, webview: vscode.Webview) { const telemetryPrefix = "gallery.messageListener"; switch (message.command) { case "POST.gallery.openImageViewer": @@ -106,26 +149,58 @@ class GalleryWebview { break; case "POST.gallery.requestSort": - this.gFolders = this.customSorter.sort(this.gFolders, message.valueName, message.ascending); + const needsMetadata = ["size", "ctime", "mtime"].includes(message.valueName); + const unloadedFolders = Object.values(this.gFolders).filter(folder => !folder.loaded); + + if (needsMetadata && unloadedFolders.length > 0) { + // Show busy indicator + webview.postMessage({ + command: "POST.gallery.sortBusy", + sortType: message.valueName, + progress: { loaded: 0, total: unloadedFolders.length } + }); + + // Load metadata for unloaded folders only + this.loadMetadataForSort(unloadedFolders, message.valueName, message.ascending, webview); + } else { + // Sort immediately + this.gFolders = this.customSorter.sort(this.gFolders, message.valueName, message.ascending); + } + reporter.sendTelemetryEvent(`${telemetryPrefix}.requestSort`, { 'valueName': this.customSorter.valueName, 'ascending': this.customSorter.ascending.toString(), }); // DO NOT BREAK HERE; FALL THROUGH TO UPDATE DOMS + case "POST.gallery.loadFolderMetadata": + const folderId = message.folderId; + if (this.gFolders[folderId] && !this.gFolders[folderId].loaded) { + try { + // Load metadata for this specific folder + this.gFolders[folderId] = await utils.loadFolderMetadata(this.gFolders[folderId]); + + // Send updated content for this folder + this.messageListener({ command: "POST.gallery.requestContentDOMs" }, webview).catch(console.error); + } catch (error) { + console.error(`Failed to load metadata for folder ${folderId}:`, error); + } + } + break; + case "POST.gallery.requestContentDOMs": - const htmlProvider = new HTMLProvider(this.context, webview); + const htmlProvider2 = new HTMLProvider(this.context, webview); const response: Record = {}; for (const [_idx, folder] of Object.values(this.gFolders).entries()) { response[folder.id] = { - status: "", - barHtml: htmlProvider.folderBarHTML(folder), - gridHtml: htmlProvider.imageGridHTML(folder, true), + status: folder.loaded ? "loaded" : "structure", + barHtml: htmlProvider2.folderBarHTML(folder), + gridHtml: htmlProvider2.imageGridHTML(folder, true), images: Object.fromEntries( Object.values(folder.images).map( image => [image.id, { status: image.status, - containerHtml: htmlProvider.singleImageHTML(image), + containerHtml: htmlProvider2.singleImageHTML(image), }] ) ), @@ -169,7 +244,7 @@ class GalleryWebview { } else { this.gFolders[folder.id] = folder; } - this.messageListener({ command: "POST.gallery.requestSort" }, webview); + this.messageListener({ command: "POST.gallery.requestSort" }, webview).catch(console.error); reporter.sendTelemetryEvent(`${telemetryPrefix}.didCreate`, {}, getMeasurementProperties(folders)); }); watcher.onDidDelete(async uri => { @@ -184,7 +259,7 @@ class GalleryWebview { delete this.gFolders[folder.id]; } } - this.messageListener({ command: "POST.gallery.requestSort" }, webview); + this.messageListener({ command: "POST.gallery.requestSort" }, webview).catch(console.error); reporter.sendTelemetryEvent(`${telemetryPrefix}.didDelete`, {}, getMeasurementProperties(folders)); }); watcher.onDidChange(async uri => { @@ -196,7 +271,7 @@ class GalleryWebview { if (this.gFolders.hasOwnProperty(folder.id) && this.gFolders[folder.id].images.hasOwnProperty(image.id)) { image.status = "refresh"; this.gFolders[folder.id].images[image.id] = image; - this.messageListener({ command: "POST.gallery.requestSort" }, webview); + this.messageListener({ command: "POST.gallery.requestSort" }, webview).catch(console.error); this.gFolders[folder.id].images[image.id].status = ""; } reporter.sendTelemetryEvent(`${telemetryPrefix}.didChange`, {}, getMeasurementProperties(folders)); diff --git a/src/gallery/script.js b/src/gallery/script.js index a8cb673..f275677 100644 --- a/src/gallery/script.js +++ b/src/gallery/script.js @@ -17,6 +17,8 @@ function init() { initMessageListeners(); DOMManager.requestContentDOMs(); EventListener.addAllToToolbar(); + // Initialize thumbnail size + EventListener.updateThumbnailSize(250); } function initMessageListeners() { @@ -29,6 +31,15 @@ function initMessageListeners() { DOMManager.updateGlobalDoms(message); DOMManager.updateGalleryContent(); break; + case "POST.gallery.metadataProgress": + ProgressManager.updateProgress(message); + break; + case "POST.gallery.metadataComplete": + ProgressManager.hideProgress(); + break; + case "POST.gallery.sortBusy": + ProgressManager.showSortBusy(message); + break; } }); } @@ -144,6 +155,71 @@ class DOMManager { content.innerHTML = "

No image found in this folder.

"; } } + +} + +class ProgressManager { + static updateProgress(message) { + const progress = message.progress; + let progressBar = document.querySelector('.progress-bar'); + + if (!progressBar) { + // Create progress bar + const toolbar = document.querySelector('.toolbar'); + const progressContainer = document.createElement('div'); + progressContainer.className = 'progress-container'; + progressContainer.innerHTML = ` +
Loading metadata...
+
+
+
+ `; + toolbar.appendChild(progressContainer); + progressBar = progressContainer.querySelector('.progress-bar'); + } + + const percentage = Math.round((progress.loaded / progress.total) * 100); + progressBar.style.width = percentage + '%'; + + const progressText = document.querySelector('.progress-text'); + if (progressText) { + progressText.textContent = `Loading metadata... ${progress.loaded}/${progress.total} (${percentage}%)`; + } + } + + static hideProgress() { + const progressContainer = document.querySelector('.progress-container'); + if (progressContainer) { + progressContainer.remove(); + } + ProgressManager.hideSortBusy(); + } + + static showSortBusy(message) { + const dropdown = document.querySelector('.toolbar .dropdown'); + const sortArrow = document.querySelector('.toolbar .sort-order-arrow'); + + // Disable sort controls + dropdown.disabled = true; + sortArrow.style.opacity = '0.5'; + sortArrow.style.pointerEvents = 'none'; + + // Show busy indicator + const progressText = document.querySelector('.progress-text'); + if (progressText) { + progressText.textContent = `Sorting by ${message.sortType}... ${message.progress.loaded}/${message.progress.total} loaded`; + } + } + + static hideSortBusy() { + const dropdown = document.querySelector('.toolbar .dropdown'); + const sortArrow = document.querySelector('.toolbar .sort-order-arrow'); + + // Re-enable sort controls + dropdown.disabled = false; + sortArrow.style.opacity = '1'; + sortArrow.style.pointerEvents = 'auto'; + } } class EventListener { @@ -163,6 +239,18 @@ class EventListener { EventListener.sortRequest(); } ); + const thumbnailSlider = document.querySelector("#thumbnail-size-slider"); + if (thumbnailSlider) { + thumbnailSlider.addEventListener( + "input", (event) => EventListener.updateThumbnailSize(event.target.value) + ); + } + const searchInput = document.querySelector("#search-input"); + if (searchInput) { + searchInput.addEventListener( + "input", (event) => EventListener.filterImages(event.target.value) + ); + } } static addToFolderBar(folderBar) { @@ -257,6 +345,20 @@ class EventListener { static expandFolderBar(folderDOM) { const elements = EventListener.getFolderAssociatedElements(folderDOM); + + // Load metadata for this folder if not loaded + if (folderDOM.dataset.loaded === "false") { + const countElement = folderDOM.querySelector(`#${folderDOM.id}-items-count`); + if (countElement) { + countElement.textContent = "⏳ Loading..."; + } + + vscode.postMessage({ + command: "POST.gallery.loadFolderMetadata", + folderId: folderDOM.id + }); + } + if (elements.arrowImg.src.includes("chevron-right.svg")) { elements.arrowImg.src = elements.arrowImg.dataset.chevronDown; } @@ -304,6 +406,48 @@ class EventListener { ascending: sortOrderDOM.src.includes("arrow-up.svg") ? true : false, }); } + + static updateThumbnailSize(size) { + document.documentElement.style.setProperty('--thumbnail-size', size + 'px'); + } + + static filterImages(searchTerm) { + const term = searchTerm.toLowerCase().trim(); + + // Get all image containers + Object.values(gFolders).forEach(folder => { + let visibleCount = 0; + + Object.values(folder.images).forEach(image => { + const container = image.container; + const filename = container.querySelector('.filename').textContent.toLowerCase(); + + if (term === '' || filename.includes(term)) { + container.style.display = ''; + visibleCount++; + } else { + container.style.display = 'none'; + } + }); + + // Update folder visibility and count + const folderGrid = folder.grid; + if (visibleCount === 0 && term !== '') { + folderGrid.style.display = 'none'; + folder.bar.style.display = 'none'; + } else { + folderGrid.style.display = 'grid'; + folder.bar.style.display = 'flex'; + } + + // Update the image count in folder bar + const countText = (object, count) => `${count} ${object}${count === 1 ? "" : "s"} found`; + const countElement = folder.bar.querySelector(`#${folder.bar.id}-items-count`); + if (countElement) { + countElement.textContent = countText("image", visibleCount); + } + }); + } } (function () { diff --git a/src/gallery/style.css b/src/gallery/style.css index 5eaebbe..802709b 100644 --- a/src/gallery/style.css +++ b/src/gallery/style.css @@ -7,11 +7,15 @@ html { overflow-y: scroll; } +:root { + --thumbnail-size: 250px; +} + .grid { display: grid; grid-auto-flow: dense; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - grid-auto-rows: 250px; + grid-template-columns: repeat(auto-fit, minmax(var(--thumbnail-size), 1fr)); + grid-auto-rows: var(--thumbnail-size); grid-gap: 10px; padding: 10px; } @@ -184,4 +188,135 @@ select option { background-color: hsl(0, 0%, 23%); color: white; border-radius: 0px; +} + +.thumbnail-size-control { + display: flex; + align-items: center; + justify-content: center; + margin-left: 30px; +} + +.thumbnail-size-control span { + font-size: 15px; + margin-right: 10px; + color: white; +} + +.thumbnail-slider { + width: 100px; + height: 4px; + background: hsl(0, 0%, 23%); + outline: none; + border-radius: 2px; + -webkit-appearance: none; +} + +.thumbnail-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: white; + cursor: pointer; + border-radius: 50%; +} + +.thumbnail-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: white; + cursor: pointer; + border-radius: 50%; + border: none; +} + +.search-control { + display: flex; + align-items: center; + justify-content: center; + margin-left: 30px; +} + +.search-control span { + font-size: 15px; + margin-right: 10px; + color: white; +} + +.search-input { + padding: 6px 10px; + background-color: hsl(0, 0%, 23%); + color: white; + border: 1px solid hsl(0, 0%, 35%); + border-radius: 3px; + width: 150px; + font-size: 14px; +} + +.search-input:focus { + outline: none; + border-color: hsl(210, 100%, 60%); + background-color: hsl(0, 0%, 27%); +} + +.search-input::placeholder { + color: hsl(0, 0%, 60%); +} + +.image-container.hidden { + display: none !important; +} + +.folder-loading { + opacity: 0.7; +} + +.folder-loading .folder-items-count { + color: hsl(210, 100%, 60%); +} + +.loading-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid hsl(0, 0%, 40%); + border-radius: 50%; + border-top-color: hsl(210, 100%, 60%); + animation: spin 1s ease-in-out infinite; + margin-left: 5px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.progress-container { + display: flex; + flex-direction: column; + align-items: center; + margin-left: 30px; + min-width: 200px; +} + +.progress-text { + font-size: 12px; + color: white; + margin-bottom: 4px; +} + +.progress-bar-container { + width: 200px; + height: 4px; + background-color: hsl(0, 0%, 30%); + border-radius: 2px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background-color: hsl(210, 100%, 60%); + border-radius: 2px; + transition: width 0.3s ease; + width: 0%; } \ No newline at end of file diff --git a/src/html_provider.ts b/src/html_provider.ts index c6a17c7..4f9a6f1 100644 --- a/src/html_provider.ts +++ b/src/html_provider.ts @@ -93,6 +93,14 @@ export default class HTMLProvider { +
+ Size + +
+
+ Search + +
`.trim(); @@ -106,6 +114,7 @@ export default class HTMLProvider { id="${folder.id}" data-path="${folder.path}" data-state="${collapsed ? 'collapsed' : 'expanded'}" + data-loaded="${folder.loaded !== false}" class="folder" >
{ assert.strictEqual(sorter.ascending, true); }); - // test("CustomSorter.sort(): sort by name", () => { - // const obj = new TestObjects(); - // const sorter = new CustomSorter(); - // const result = sorter.sort( - // {"folder1": obj.folder1, "folder2": obj.folder2}, - // "name", - // true, // ascending - // ); - - // assert.strictEqual( - // Object.values(result.folder1.images).map(image => image.id).join(","), - // "fold1_img1,fold1_img2" - // ); - // }); + test("CustomSorter.sort(): sort by name", () => { + const obj = new TestObjects(); + const sorter = new CustomSorter(); + const result = sorter.sort( + {"folder1": obj.folder1, "folder2": obj.folder2}, + "name", + true, // ascending + ); + + assert.strictEqual( + Object.values(result.folder1.images).map(image => image.id).join(","), + "fold1_img1,fold1_img2" + ); + }); + + test("CustomSorter.sort(): sort by size ascending", () => { + const obj = new TestObjects(); + const sorter = new CustomSorter(); + const result = sorter.sort( + {"folder1": obj.folder1}, + "size", + true + ); + + // Should sort by size: image1 (100) before image2 (200) + const imageIds = Object.values(result.folder1.images).map(image => image.id); + assert.strictEqual(imageIds[0], "fold1_img1"); + assert.strictEqual(imageIds[1], "fold1_img2"); + }); + + test("CustomSorter.sort(): sort by size descending", () => { + const obj = new TestObjects(); + const sorter = new CustomSorter(); + const result = sorter.sort( + {"folder1": obj.folder1}, + "size", + false + ); + + // Should sort by size descending: image2 (200) before image1 (100) + const imageIds = Object.values(result.folder1.images).map(image => image.id); + assert.strictEqual(imageIds[0], "fold1_img2"); + assert.strictEqual(imageIds[1], "fold1_img1"); + }); + + test("CustomSorter.sort(): sort by modified time", () => { + const obj = new TestObjects(); + const sorter = new CustomSorter(); + const result = sorter.sort( + {"folder1": obj.folder1}, + "mtime", + true + ); + + // Should sort by mtime: image2 (800) before image1 (1000) + const imageIds = Object.values(result.folder1.images).map(image => image.id); + assert.strictEqual(imageIds[0], "fold1_img2"); + assert.strictEqual(imageIds[1], "fold1_img1"); + }); + + test("CustomSorter.sort(): sort by extension", () => { + const obj = new TestObjects(); + const sorter = new CustomSorter(); + const result = sorter.sort( + {"folder1": obj.folder1}, + "ext", + true + ); + + // Should sort by extension: JPG before PNG + const imageIds = Object.values(result.folder1.images).map(image => image.id); + assert.strictEqual(imageIds[0], "fold1_img1"); // JPG + assert.strictEqual(imageIds[1], "fold1_img2"); // PNG + }); }); diff --git a/src/test/suite/progressive-loading.test.ts b/src/test/suite/progressive-loading.test.ts new file mode 100644 index 0000000..6eccc41 --- /dev/null +++ b/src/test/suite/progressive-loading.test.ts @@ -0,0 +1,161 @@ +import vscode from 'vscode'; +import { assert } from 'chai'; + +import * as utils from '../../utils'; +import { TFolder, TImage } from 'custom_typings'; + +suite('GeriYoco.vscode-image-gallery: Progressive Loading Test Suite', () => { + vscode.window.showInformationMessage('Progressive Loading Test Suite started.'); + + test('getFoldersStructure(): creates structure without metadata', async () => { + // Create test URIs + const testUris = [ + vscode.Uri.file('/test/folder1/image1.jpg'), + vscode.Uri.file('/test/folder1/image2.png'), + vscode.Uri.file('/test/folder2/image3.jpeg'), + ]; + + const result = await utils.getFoldersStructure(testUris); + + // Should have 2 folders + assert.strictEqual(Object.keys(result).length, 2); + + // Check folder1 + const folder1 = Object.values(result).find(f => f.path === '/test/folder1'); + assert.isDefined(folder1); + assert.strictEqual(folder1!.imageCount, 2); + assert.strictEqual(folder1!.loaded, false); + assert.strictEqual(Object.keys(folder1!.images).length, 2); + + // Check that images have placeholder metadata + const image1 = Object.values(folder1!.images)[0]; + assert.strictEqual(image1.size, 0); + assert.strictEqual(image1.mtime, 0); + assert.strictEqual(image1.ctime, 0); + assert.strictEqual(image1.loaded, false); + assert.strictEqual(image1.ext, 'JPG'); + + // Check folder2 + const folder2 = Object.values(result).find(f => f.path === '/test/folder2'); + assert.isDefined(folder2); + assert.strictEqual(folder2!.imageCount, 1); + assert.strictEqual(folder2!.loaded, false); + }); + + test('loadFolderMetadata(): loads metadata for specific folder', async () => { + // Create a test folder structure without metadata + const testUris = [vscode.Uri.file('/test/folder1/image1.jpg')]; + const folders = await utils.getFoldersStructure(testUris); + const folderId = Object.keys(folders)[0]; + const folder = folders[folderId]; + + // Verify initial state + assert.strictEqual(folder.loaded, false); + const image = Object.values(folder.images)[0]; + assert.strictEqual(image.size, 0); + assert.strictEqual(image.loaded, false); + + // Note: We can't actually load real file metadata in tests without real files + // So we'll just verify the function exists and can be called + try { + await utils.loadFolderMetadata(folder); + // If we get here without throwing, the function structure is correct + assert.isTrue(true); + } catch (error) { + // Expected to fail without real files, but structure should be correct + assert.isTrue(error instanceof Error); + } + }); + + test('Folder structure consistency', async () => { + // Test that folders created with getFoldersStructure are compatible with loadFolderMetadata + const testUris = [ + vscode.Uri.file('/test/images/photo1.jpg'), + vscode.Uri.file('/test/images/photo2.png'), + ]; + + const folders = await utils.getFoldersStructure(testUris); + const folderId = Object.keys(folders)[0]; + const folder = folders[folderId]; + + // Verify folder structure + assert.isString(folder.id); + assert.isString(folder.path); + assert.isNumber(folder.imageCount); + assert.isBoolean(folder.loaded); + assert.isObject(folder.images); + + // Verify image structure + const image = Object.values(folder.images)[0]; + assert.isString(image.id); + assert.isDefined(image.uri); + assert.isString(image.ext); + assert.isNumber(image.size); + assert.isNumber(image.mtime); + assert.isNumber(image.ctime); + assert.isString(image.status); + assert.isBoolean(image.loaded); + }); + + test('Progressive loading maintains backward compatibility', async () => { + // Test that the new progressive loading is compatible with existing getFolders function + const testUris = [vscode.Uri.file('/test/compat/image.jpg')]; + + // Both functions should handle the same URIs without throwing + try { + const structureResult = await utils.getFoldersStructure(testUris); + assert.isObject(structureResult); + + // The old getFolders function should still work (but will fail on file stats) + // We just want to ensure it doesn't throw on the structure level + const compatResult = await utils.getFolders(testUris, "create"); + assert.isObject(compatResult); + } catch (error) { + // Expected to fail on file operations in test environment, but structure should be valid + assert.isTrue(error instanceof Error); + } + }); + + test('Image count calculation', async () => { + const testUris = [ + vscode.Uri.file('/test/mixed/image1.jpg'), + vscode.Uri.file('/test/mixed/image2.png'), + vscode.Uri.file('/test/mixed/image3.gif'), + vscode.Uri.file('/test/other/image4.jpg'), + ]; + + const folders = await utils.getFoldersStructure(testUris); + + // Should have 2 folders + assert.strictEqual(Object.keys(folders).length, 2); + + // Find mixed folder + const mixedFolder = Object.values(folders).find(f => f.path === '/test/mixed'); + assert.isDefined(mixedFolder); + assert.strictEqual(mixedFolder!.imageCount, 3); + assert.strictEqual(Object.keys(mixedFolder!.images).length, 3); + + // Find other folder + const otherFolder = Object.values(folders).find(f => f.path === '/test/other'); + assert.isDefined(otherFolder); + assert.strictEqual(otherFolder!.imageCount, 1); + assert.strictEqual(Object.keys(otherFolder!.images).length, 1); + }); + + test('Extension extraction works correctly', async () => { + const testUris = [ + vscode.Uri.file('/test/types/photo.jpg'), + vscode.Uri.file('/test/types/graphic.PNG'), + vscode.Uri.file('/test/types/animation.gif'), + vscode.Uri.file('/test/types/vector.svg'), + ]; + + const folders = await utils.getFoldersStructure(testUris); + const folder = Object.values(folders)[0]; + const images = Object.values(folder.images); + + // Check extension extraction and normalization + const extensions = images.map(img => img.ext).sort(); + assert.deepEqual(extensions, ['GIF', 'JPG', 'PNG', 'SVG']); + }); +}); diff --git a/src/test/suite/search-functionality.test.ts b/src/test/suite/search-functionality.test.ts new file mode 100644 index 0000000..f381c0b --- /dev/null +++ b/src/test/suite/search-functionality.test.ts @@ -0,0 +1,230 @@ +import vscode from 'vscode'; +import { assert } from 'chai'; + +import { TFolder, TImage } from 'custom_typings'; + +suite('GeriYoco.vscode-image-gallery: Search Functionality Test Suite', () => { + vscode.window.showInformationMessage('Search Functionality Test Suite started.'); + + // Create test data for search tests + const createTestFolder = (): TFolder => ({ + id: "test-folder", + path: "/test/images", + imageCount: 6, + loaded: true, + images: { + "img1": { + id: "img1", + uri: vscode.Uri.file("/test/images/vacation-photo.jpg"), + ext: "JPG", + size: 1000, + mtime: 1000, + ctime: 1000, + status: "", + loaded: true, + }, + "img2": { + id: "img2", + uri: vscode.Uri.file("/test/images/asdfa-vacation.png"), + ext: "PNG", + size: 2000, + mtime: 2000, + ctime: 2000, + status: "", + loaded: true, + }, + "img3": { + id: "img3", + uri: vscode.Uri.file("/test/images/bak_vacation.jpg"), + ext: "JPG", + size: 1500, + mtime: 1500, + ctime: 1500, + status: "", + loaded: true, + }, + "img4": { + id: "img4", + uri: vscode.Uri.file("/test/images/work-document.pdf"), + ext: "PDF", + size: 3000, + mtime: 3000, + ctime: 3000, + status: "", + loaded: true, + }, + "img5": { + id: "img5", + uri: vscode.Uri.file("/test/images/family_photo_2023.jpg"), + ext: "JPG", + size: 2500, + mtime: 2500, + ctime: 2500, + status: "", + loaded: true, + }, + "img6": { + id: "img6", + uri: vscode.Uri.file("/test/images/screenshot.png"), + ext: "PNG", + size: 800, + mtime: 800, + ctime: 800, + status: "", + loaded: true, + }, + } + }); + + test('Search functionality: basic filename matching', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test basic search for "vacation" + const searchTerm = "vacation"; + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(searchTerm.toLowerCase()); + }); + + assert.strictEqual(matchingImages.length, 3); + assert.isTrue(matchingImages.some(img => img.uri.path.includes('vacation-photo.jpg'))); + assert.isTrue(matchingImages.some(img => img.uri.path.includes('asdfa-vacation.png'))); + assert.isTrue(matchingImages.some(img => img.uri.path.includes('bak_vacation.jpg'))); + }); + + test('Search functionality: fuzzy matching patterns', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test patterns like "asdfa-" and "bak_" + const searchTerm = "vacation"; + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(searchTerm.toLowerCase()); + }); + + // Should find: + // - "vacation-photo.jpg" (direct match) + // - "asdfa-vacation.png" (prefix pattern) + // - "bak_vacation.jpg" (underscore pattern) + assert.strictEqual(matchingImages.length, 3); + + const filenames = matchingImages.map(img => img.uri.path.split('/').pop()); + assert.isTrue(filenames.includes('vacation-photo.jpg')); + assert.isTrue(filenames.includes('asdfa-vacation.png')); + assert.isTrue(filenames.includes('bak_vacation.jpg')); + }); + + test('Search functionality: case insensitive matching', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test case insensitive search + const searchVariations = ["VACATION", "Vacation", "vacation", "VaCaTiOn"]; + + searchVariations.forEach(searchTerm => { + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(searchTerm.toLowerCase()); + }); + + assert.strictEqual(matchingImages.length, 3, `Failed for search term: ${searchTerm}`); + }); + }); + + test('Search functionality: partial matching', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test partial matching + const searchTerm = "photo"; + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(searchTerm.toLowerCase()); + }); + + // Should find: + // - "vacation-photo.jpg" + // - "family_photo_2023.jpg" + assert.strictEqual(matchingImages.length, 2); + assert.isTrue(matchingImages.some(img => img.uri.path.includes('vacation-photo.jpg'))); + assert.isTrue(matchingImages.some(img => img.uri.path.includes('family_photo_2023.jpg'))); + }); + + test('Search functionality: extension matching', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test searching by extension + const searchTerm = ".jpg"; + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(searchTerm.toLowerCase()); + }); + + // Should find all JPG files + assert.strictEqual(matchingImages.length, 3); + matchingImages.forEach(img => { + assert.isTrue(img.uri.path.toLowerCase().endsWith('.jpg')); + }); + }); + + test('Search functionality: no matches', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test search term that should not match anything + const searchTerm = "nonexistent"; + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(searchTerm.toLowerCase()); + }); + + assert.strictEqual(matchingImages.length, 0); + }); + + test('Search functionality: empty search shows all', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test empty search should show all images + const searchTerm: string = ""; + const matchingImages = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return searchTerm === '' || filename.includes(searchTerm.toLowerCase()); + }); + + assert.strictEqual(matchingImages.length, images.length); + }); + + test('Search functionality: special characters handling', () => { + const folder = createTestFolder(); + const images = Object.values(folder.images); + + // Test search with underscore and dash + const underscoreSearch = "_"; + const dashSearch = "-"; + + const underscoreMatches = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(underscoreSearch); + }); + + const dashMatches = images.filter(img => { + const filename = img.uri.path.split('/').pop()?.toLowerCase() || ''; + return filename.includes(dashSearch); + }); + + // Should find files with underscores + assert.isTrue(underscoreMatches.length > 0); + assert.isTrue(underscoreMatches.some(img => img.uri.path.includes('bak_vacation.jpg'))); + assert.isTrue(underscoreMatches.some(img => img.uri.path.includes('family_photo_2023.jpg'))); + + // Should find files with dashes + assert.isTrue(dashMatches.length > 0); + assert.isTrue(dashMatches.some(img => img.uri.path.includes('vacation-photo.jpg'))); + assert.isTrue(dashMatches.some(img => img.uri.path.includes('asdfa-vacation.png'))); + assert.isTrue(dashMatches.some(img => img.uri.path.includes('work-document.pdf'))); + }); +}); diff --git a/src/test/suite/utils.test.ts b/src/test/suite/utils.test.ts new file mode 100644 index 0000000..320bf06 --- /dev/null +++ b/src/test/suite/utils.test.ts @@ -0,0 +1,175 @@ +import vscode from 'vscode'; +import { assert } from 'chai'; + +import * as utils from '../../utils'; + +suite('GeriYoco.vscode-image-gallery: Utils Test Suite', () => { + vscode.window.showInformationMessage('Utils Test Suite started.'); + + test('utils.nonce: generates consistent nonce', () => { + const nonce1 = utils.nonce; + const nonce2 = utils.nonce; + + // Should be the same (time-independent) + assert.strictEqual(nonce1, nonce2); + + // Should start with 'N' and be at least 13 characters + assert.strictEqual(nonce1[0], 'N'); + assert.isAtLeast(nonce1.length, 13); + }); + + test('utils.getGlob: returns valid glob pattern', () => { + const glob = utils.getGlob(); + + // Should be a string with proper glob format + assert.isString(glob); + assert.isTrue(glob.startsWith('**/*.{')); + assert.isTrue(glob.endsWith('}')); + + // Should contain common image extensions + assert.isTrue(glob.includes('jpg')); + assert.isTrue(glob.includes('png')); + assert.isTrue(glob.includes('JPG')); + assert.isTrue(glob.includes('PNG')); + }); + + test('utils.getFilename: extracts filename correctly', () => { + // Test with various path formats + const testCases = [ + { input: '/path/to/image.jpg', expected: 'image.jpg' }, + { input: '/path/to/image.jpg?query=param', expected: 'image.jpg' }, + { input: 'image.png', expected: 'image.png' }, + { input: '/folder/subfolder/vacation-photo.jpeg', expected: 'vacation-photo.jpeg' }, + { input: '', expected: undefined }, + ]; + + testCases.forEach(({ input, expected }) => { + const result = utils.getFilename(input); + assert.strictEqual(result, expected, `Failed for input: ${input}`); + }); + }); + + test('utils.hash256: generates consistent hashes', () => { + const testString = 'test-string-for-hashing'; + + // Should generate same hash for same input + const hash1 = utils.hash256(testString); + const hash2 = utils.hash256(testString); + assert.strictEqual(hash1, hash2); + + // Should start with 'H' and be 17 characters by default (H + 16 hex chars) + assert.strictEqual(hash1[0], 'H'); + assert.strictEqual(hash1.length, 17); + + // Different inputs should produce different hashes + const differentHash = utils.hash256('different-string'); + assert.notStrictEqual(hash1, differentHash); + }); + + test('utils.hash256: respects truncate parameter', () => { + const testString = 'test-string-for-hashing'; + + // Test with custom truncate length + const hash8 = utils.hash256(testString, 8); + const hash12 = utils.hash256(testString, 12); + + assert.strictEqual(hash8.length, 9); // H + 8 chars + assert.strictEqual(hash12.length, 13); // H + 12 chars + + // Should be prefixes of each other + assert.isTrue(hash12.startsWith(hash8)); + }); + + test('utils.getImageSizeStat: calculates statistics correctly', () => { + // Create test folders with known image sizes + const testFolders = { + 'folder1': { + id: 'folder1', + path: '/test/folder1', + imageCount: 3, + loaded: true, + images: { + 'img1': { + id: 'img1', + uri: vscode.Uri.file('/test/folder1/img1.jpg'), + ext: 'JPG', + size: 100, + mtime: 1000, + ctime: 1000, + status: "" as const, + loaded: true, + }, + 'img2': { + id: 'img2', + uri: vscode.Uri.file('/test/folder1/img2.jpg'), + ext: 'JPG', + size: 200, + mtime: 2000, + ctime: 2000, + status: "" as const, + loaded: true, + }, + 'img3': { + id: 'img3', + uri: vscode.Uri.file('/test/folder1/img3.jpg'), + ext: 'JPG', + size: 300, + mtime: 3000, + ctime: 3000, + status: "" as const, + loaded: true, + }, + } + } + }; + + const stats = utils.getImageSizeStat(testFolders); + + // Should have correct count + assert.strictEqual(stats.count, 3); + + // Should have correct mean (100 + 200 + 300) / 3 = 200 + assert.strictEqual(stats.mean, 200); + + // Should have reasonable standard deviation (should be > 0 for varied sizes) + assert.isTrue(stats.std > 0); + }); + + test('utils.getImageSizeStat: handles empty folders', () => { + const emptyFolders = {}; + const stats = utils.getImageSizeStat(emptyFolders); + + assert.strictEqual(stats.count, 0); + assert.strictEqual(stats.mean, 0); + assert.strictEqual(stats.std, 0); + }); + + test('utils.getImageSizeStat: handles single image', () => { + const singleImageFolder = { + 'folder1': { + id: 'folder1', + path: '/test/folder1', + imageCount: 1, + loaded: true, + images: { + 'img1': { + id: 'img1', + uri: vscode.Uri.file('/test/folder1/img1.jpg'), + ext: 'JPG', + size: 500, + mtime: 1000, + ctime: 1000, + status: "" as const, + loaded: true, + }, + } + } + }; + + const stats = utils.getImageSizeStat(singleImageFolder); + + assert.strictEqual(stats.count, 1); + assert.strictEqual(stats.mean, 500); + assert.strictEqual(stats.std, 0); // Single value has no deviation + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 744df5b..ce8edef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -73,7 +73,64 @@ export async function getFileStats(imgUris: vscode.Uri[]) { return resultObj; } +export async function getFoldersStructure(imgUris: vscode.Uri[]) { + // Fast folder structure loading without file stats + let folders: Record = {}; + + for (const imgUri of imgUris) { + const folderPath = path.dirname(imgUri.path); + const folderId = hash256(folderPath); + + if (!folders[folderId]) { + folders[folderId] = { + id: folderId, + path: folderPath, + images: {}, + imageCount: 0, + loaded: false + }; + } + + // Add basic image structure without stats + const imageId = hash256(imgUri.path); + const dotIndex = imgUri.fsPath.lastIndexOf('.'); + folders[folderId].images[imageId] = { + id: imageId, + uri: imgUri, + ext: imgUri.fsPath.slice(dotIndex + 1).toUpperCase(), + size: 0, // Placeholder + mtime: 0, + ctime: 0, + status: "", + loaded: false + }; + folders[folderId].imageCount = (folders[folderId].imageCount || 0) + 1; + } + return folders; +} + +export async function loadFolderMetadata(folder: TFolder) { + // Load file stats only for this folder's images + const imageUris = Object.values(folder.images).map(img => img.uri); + const fileStats = await getFileStats(imageUris); + + // Update images with real metadata + for (const image of Object.values(folder.images)) { + const stat = fileStats[image.uri.fsPath as keyof typeof fileStats]; + if (stat) { + image.size = stat['size']; + image.mtime = new Date(stat['mtime']).getTime(); + image.ctime = new Date(stat['ctime']).getTime(); + image.loaded = true; + } + } + + folder.loaded = true; + return folder; +} + export async function getFolders(imgUris: vscode.Uri[], action: "create" | "change" | "delete" = "create") { + // Keep original function for backward compatibility (file watchers, etc.) let folders: Record = {}; let fileStats; @@ -89,6 +146,8 @@ export async function getFolders(imgUris: vscode.Uri[], action: "create" | "chan id: folderId, path: folderPath, images: {}, + imageCount: 0, + loaded: true }; } @@ -101,10 +160,12 @@ export async function getFolders(imgUris: vscode.Uri[], action: "create" | "chan uri: imgUri, ext: imgUri.fsPath.slice(dotIndex + 1).toUpperCase(), size: fileStat['size'], - mtime: fileStat['mtime'], - ctime: fileStat['ctime'], + mtime: new Date(fileStat['mtime']).getTime(), + ctime: new Date(fileStat['ctime']).getTime(), status: "", + loaded: true }; + folders[folderId].imageCount = (folders[folderId].imageCount || 0) + 1; } } return folders; diff --git a/telemetry.json b/telemetry.json deleted file mode 100644 index 7fdfd55..0000000 --- a/telemetry.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "events": { - "GeriYoco.vscode-image-gallery/gallery.activate": { }, - "GeriYoco.vscode-image-gallery/gallery.deactivate": { }, - "GeriYoco.vscode-image-gallery/gallery.createPanel": { - "duration": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "folderCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageSizeMean": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageSizeStd": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - } - }, - "GeriYoco.vscode-image-gallery/gallery.messageListener.openImageViewer": { - "preview": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight" - } - }, - "GeriYoco.vscode-image-gallery/gallery.messageListener.requestSort": { - "valueName": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight" - }, - "ascending": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight" - } - }, - "GeriYoco.vscode-image-gallery/gallery.messageListener.requestContentDOMs": { - "folderCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageSizeMean": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageSizeStd": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - } - }, - "GeriYoco.vscode-image-gallery/gallery.createFileWatcher.didCreate": { - "folderCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - } - }, - "GeriYoco.vscode-image-gallery/gallery.createFileWatcher.didDelete": { - "folderCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - } - }, - "GeriYoco.vscode-image-gallery/gallery.createFileWatcher.didChange": { - "folderCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - }, - "imageCount": { - "classification": "SystemMetaData", - "purpose": "FeatureInsight", - "isMeasurement": true - } - } - } -} \ No newline at end of file