From c59d134be2bfdde6b66b308859bedfacbd7ef41a Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 2 Oct 2024 17:22:45 +0200 Subject: [PATCH] feat: display files added/removed/modified Signed-off-by: Philippe Martin --- .../lib/image/ImageDetailsFilesLayers.spec.ts | 24 +++--- .../lib/image/ImageDetailsFilesLayers.svelte | 22 +++++- .../src/lib/image/filesystem-tree.spec.ts | 75 ++++++++++++++----- .../renderer/src/lib/image/filesystem-tree.ts | 48 +++++++++--- .../src/lib/image/imageDetailsFiles.spec.ts | 42 +++++++++-- .../src/lib/image/imageDetailsFiles.ts | 34 +++++++-- 6 files changed, 186 insertions(+), 59 deletions(-) diff --git a/packages/renderer/src/lib/image/ImageDetailsFilesLayers.spec.ts b/packages/renderer/src/lib/image/ImageDetailsFilesLayers.spec.ts index bd6ade6cdce04..87433276bcfe4 100644 --- a/packages/renderer/src/lib/image/ImageDetailsFilesLayers.spec.ts +++ b/packages/renderer/src/lib/image/ImageDetailsFilesLayers.spec.ts @@ -18,11 +18,9 @@ import '@testing-library/jest-dom/vitest'; -import type { ImageFile } from '@podman-desktop/api'; import { render, screen, within } from '@testing-library/svelte'; import { expect, test } from 'vitest'; -import type { FilesystemTree } from './filesystem-tree'; import type { ImageFilesystemLayerUI } from './imageDetailsFiles'; import { signedHumanSize } from './ImageDetailsFilesLayers'; import ImageDetailsFilesLayers from './ImageDetailsFilesLayers.svelte'; @@ -38,28 +36,26 @@ test('render', async () => { id: 'layer1', createdBy: 'creator', sizeInArchive: 1000, - sizeInContainer: 900, - stackTree: { - size: 2000, - } as unknown as FilesystemTree, + addedCount: 5, + addedSize: 1000, } as unknown as ImageFilesystemLayerUI, { id: 'layer2', createdBy: 'creator', + addedCount: 1, + addedSize: 10, sizeInArchive: 0, - sizeInContainer: -300, - stackTree: { - size: 1700, - } as unknown as FilesystemTree, + modifiedCount: 1, + modifiedSize: -5, + removedCount: 2, + removedSize: -7, } as unknown as ImageFilesystemLayerUI, ]; render(ImageDetailsFilesLayers, { layers }); const rows = screen.getAllByRole('row'); expect(rows.length).toBe(2); within(rows[0]).getByText('1 kB • layer1'); - within(rows[0]).getByText('contribute to FS: +900 B'); - within(rows[0]).getByText('total FS: 2 kB'); + within(rows[0]).getByText('files: 5 added (+1 kB)'); within(rows[1]).getByText('0 B • layer2'); - within(rows[1]).getByText('contribute to FS: -300 B'); - within(rows[1]).getByText('total FS: 1.7 kB'); + within(rows[1]).getByText('files: 1 added (+10 B) • 1 modified (-5 B) • 2 removed (-7 B)'); }); diff --git a/packages/renderer/src/lib/image/ImageDetailsFilesLayers.svelte b/packages/renderer/src/lib/image/ImageDetailsFilesLayers.svelte index f6f29f8fa27ff..6856f6c253a7b 100644 --- a/packages/renderer/src/lib/image/ImageDetailsFilesLayers.svelte +++ b/packages/renderer/src/lib/image/ImageDetailsFilesLayers.svelte @@ -15,9 +15,27 @@ function onLayerSelected(layer: ImageFilesystemLayerUI) { currentLayerId = layer.id; dispatch('selected', layer); } + +function getSizesText(layer: ImageFilesystemLayerUI): string { + let parts: string[] = []; + if (layer.addedCount) { + parts.push(`${layer.addedCount} added (${signedHumanSize(layer.addedSize)})`); + } + if (layer.modifiedCount) { + parts.push(`${layer.modifiedCount} modified (${signedHumanSize(layer.modifiedSize)})`); + } + if (layer.removedCount) { + parts.push(`${layer.removedCount} removed (${signedHumanSize(layer.removedSize)})`); + } + if (!parts.length) { + return ''; + } + return `files: ${parts.join(' • ')}`; +} {#each layers as layer} + {@const sizesText = getSizesText(layer)} diff --git a/packages/renderer/src/lib/image/filesystem-tree.spec.ts b/packages/renderer/src/lib/image/filesystem-tree.spec.ts index aefedc097a048..8276ffc164e30 100644 --- a/packages/renderer/src/lib/image/filesystem-tree.spec.ts +++ b/packages/renderer/src/lib/image/filesystem-tree.spec.ts @@ -26,10 +26,10 @@ interface Typ { test('add paths to filetree', () => { const tree = new FilesystemTree('tree1') - .addPath('A', { path: 'A-path' }, 5) - .addPath('a/', { path: 'a/-path' }, 0) - .addPath('a/b/c/d.txt', { path: 'a/b/c/d.txt-path' }, 3) - .addPath('a/b/c/e.txt', { path: 'a/b/c/e.txt-path' }, 4); + .addPath('A', { path: 'A-path' }, 5, false) + .addPath('a/', { path: 'a/-path' }, 0, false) + .addPath('a/b/c/d.txt', { path: 'a/b/c/d.txt-path' }, 3, false) + .addPath('a/b/c/e.txt', { path: 'a/b/c/e.txt-path' }, 4, false); const copy = tree.copy(); @@ -71,21 +71,21 @@ test('add paths to filetree', () => { }); test('currentSize with existing file', () => { - const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5); + const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5, false); const current = tree.currentSize('A/B/C.txt'); expect(current).toBe(5); }); test('currentSize with non existing file', () => { - const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5); + const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5, false); const current = tree.currentSize('A/B/C.log'); expect(current).toBe(undefined); }); test('add an existing file', () => { const tree = new FilesystemTree('tree1') - .addPath('A/B/C.txt', { path: 'A/B/C.txt ' }, 5) - .addPath('A/B/C.txt', { path: 'A/B/C.txt ' }, 4); + .addPath('A/B/C.txt', { path: 'A/B/C.txt ' }, 5, false) + .addPath('A/B/C.txt', { path: 'A/B/C.txt ' }, 4, false); expect(tree.size).toBe(4); expect(tree.root.children).toHaveLength(1); expect(tree.root.children.get('A')!.children).toHaveLength(1); @@ -95,31 +95,53 @@ test('add an existing file', () => { test('add an existing directory containing files', () => { const tree = new FilesystemTree('tree1') - .addPath('A/B/C.txt', { path: 'A/B/C.txt ' }, 5) - .addPath('A/B/D.txt', { path: 'A/B/D.txt ' }, 4) - .addPath('A/B', { path: 'A/B ' }, 0) - .addPath('A/B/E.txt', { path: 'A/B/E.txt ' }, 1); + .addPath('A/B/C.txt', { path: 'A/B/C.txt ' }, 5, false) + .addPath('A/B/D.txt', { path: 'A/B/D.txt ' }, 4, false) + .addPath('A/B', { path: 'A/B ' }, 0, false) + .addPath('A/B/E.txt', { path: 'A/B/E.txt ' }, 1, false); expect(tree.size).toBe(10); + expect(tree.addedCount).toBe(3); + expect(tree.modifiedCount).toBe(1); // A/B + expect(tree.removedCount).toBe(0); + expect(tree.addedSize).toBe(10); + expect(tree.modifiedSize).toBe(0); + expect(tree.removedSize).toBe(0); }); test('remove a non existing file', () => { - const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5).hidePath('A/B/D.txt'); + const tree = new FilesystemTree('tree1') + .addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5, false) + .hidePath('A/B/D.txt'); expect(tree.size).toBe(5); expect(tree.root.children).toHaveLength(1); expect(tree.root.children.get('A')!.children).toHaveLength(1); expect(tree.root.children.get('A')!.children.get('B')!.children).toHaveLength(1); expect(tree.root.children.get('A')!.children.get('B')!.children.get('C.txt')!.children).toHaveLength(0); expect(tree.root.children.get('A')!.children.get('B')!.children.get('C.txt')!.hidden).toBeFalsy(); + expect(tree.addedCount).toBe(1); + expect(tree.modifiedCount).toBe(0); + expect(tree.removedCount).toBe(0); + expect(tree.addedSize).toBe(5); + expect(tree.modifiedSize).toBe(0); + expect(tree.removedSize).toBe(0); }); test('remove an existing file', () => { - const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5).hidePath('A/B/C.txt'); + const tree = new FilesystemTree('tree1') + .addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5, false) + .hidePath('A/B/C.txt'); expect(tree.size).toBe(0); expect(tree.root.children).toHaveLength(1); expect(tree.root.children.get('A')!.children).toHaveLength(1); expect(tree.root.children.get('A')!.children.get('B')!.children).toHaveLength(1); expect(tree.root.children.get('A')!.children.get('B')!.children.get('C.txt')!.children).toHaveLength(0); expect(tree.root.children.get('A')!.children.get('B')!.children.get('C.txt')!.hidden).toBeTruthy(); + expect(tree.addedCount).toBe(1); + expect(tree.modifiedCount).toBe(0); + expect(tree.removedCount).toBe(1); + expect(tree.addedSize).toBe(5); + expect(tree.modifiedSize).toBe(0); + expect(tree.removedSize).toBe(-5); }); test('add a whiteout', () => { @@ -130,12 +152,18 @@ test('add a whiteout', () => { expect(tree.root.children.get('A')!.children.get('B')!.children).toHaveLength(1); expect(tree.root.children.get('A')!.children.get('B')!.children.get('C.txt')!.children).toHaveLength(0); expect(tree.root.children.get('A')!.children.get('B')!.children.get('C.txt')!.hidden).toBeTruthy(); + expect(tree.addedCount).toBe(0); + expect(tree.modifiedCount).toBe(0); + expect(tree.removedCount).toBe(0); + expect(tree.addedSize).toBe(0); + expect(tree.modifiedSize).toBe(0); + expect(tree.removedSize).toBe(0); }); test('hide content of non-existing directory', () => { const tree = new FilesystemTree('tree1') - .addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 1) - .addPath('A/B/D.txt', { path: 'A/B/D.txt' }, 2) + .addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 1, false) + .addPath('A/B/D.txt', { path: 'A/B/D.txt' }, 2, false) .hideDirectoryContent('A/E'); expect(tree.size).toBe(3); expect(tree.root.children).toHaveLength(1); @@ -150,8 +178,8 @@ test('hide content of non-existing directory', () => { test('hide directory content', () => { const tree = new FilesystemTree('tree1') - .addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 1) - .addPath('A/B/D.txt', { path: 'A/B/D.txt' }, 2) + .addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 1, false) + .addPath('A/B/D.txt', { path: 'A/B/D.txt' }, 2, false) .hideDirectoryContent('A'); expect(tree.size).toBe(0); expect(tree.root.children).toHaveLength(1); @@ -164,10 +192,17 @@ test('hide directory content', () => { expect(tree.root.children.get('A')!.children.get('B')!.children.get('D.txt')!.hidden).toBeTruthy(); }); -test('isDirectory', () => { - const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5); +test('isDirectory with children', () => { + const tree = new FilesystemTree('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5, false); expect(tree.isDirectory('/A/B')).toBeTruthy(); expect(tree.isDirectory('/A/C')).toBeFalsy(); expect(tree.isDirectory('/A/B/C.txt')).toBeFalsy(); expect(tree.isDirectory('/A/B/D.txt')).toBeFalsy(); }); + +test('isDirectory without children', () => { + const tree1 = new FilesystemTree('tree1').addPath('A/B', { path: 'A/B' }, 0, true); + expect(tree1.isDirectory('/A/B')).toBeTruthy(); + const tree2 = new FilesystemTree('tree1').addPath('A/B', { path: 'A/B' }, 0, false); + expect(tree2.isDirectory('/A/B')).toBeFalsy(); +}); diff --git a/packages/renderer/src/lib/image/filesystem-tree.ts b/packages/renderer/src/lib/image/filesystem-tree.ts index d74bb83e8ad1b..a7c94b27ce5dc 100644 --- a/packages/renderer/src/lib/image/filesystem-tree.ts +++ b/packages/renderer/src/lib/image/filesystem-tree.ts @@ -19,25 +19,27 @@ export class FilesystemNode { name: string; data?: T; + isDirectory: boolean; children: Map>; size: number; hidden: boolean; - constructor(name: string) { + constructor(name: string, isDirectory: boolean) { this.name = name; this.children = new Map>(); this.size = 0; this.hidden = false; + this.isDirectory = isDirectory; } - addChild(name: string): FilesystemNode { - const child = new FilesystemNode(name); + addChild(name: string, isDirectory: boolean): FilesystemNode { + const child = new FilesystemNode(name, isDirectory); this.children.set(name, child); return child; } copy(): FilesystemNode { - const result = new FilesystemNode(this.name); + const result = new FilesystemNode(this.name, this.isDirectory); result.data = this.data; result.size = this.size; result.hidden = this.hidden; @@ -53,16 +55,42 @@ export class FilesystemTree { root: FilesystemNode; size: number; + // The number of added/modified/removed files + addedCount: number; + modifiedCount: number; + removedCount: number; + + addedSize: number; + modifiedSize: number; + removedSize: number; + constructor(name: string) { this.name = name; - this.root = new FilesystemNode('/'); + this.root = new FilesystemNode('/', true); this.size = 0; + + this.addedCount = 0; + this.modifiedCount = 0; + this.removedCount = 0; + + this.addedSize = 0; + this.modifiedSize = 0; + this.removedSize = 0; } - addPath(path: string, entry: T, size: number): FilesystemTree { + addPath(path: string, entry: T, size: number, isDirectory: boolean): FilesystemTree { const currentSize = this.currentSize(path); // If we add an already existing directory, we replace its size with its current one // so we do not overwrite it (being the size of all its descendants) + if (currentSize && !this.isDirectory(path)) { + this.modifiedCount++; + this.modifiedSize += size - currentSize; + } else if (!this.isDirectory(path)) { + this.addedCount++; + this.addedSize += size; + } else if (this.isDirectory(path)) { + this.modifiedCount++; + } if (currentSize && this.isDirectory(path)) { size = currentSize; } @@ -78,7 +106,7 @@ export class FilesystemTree { if (next) { node = next; } else { - node = node.addChild(part); + node = node.addChild(part, isDirectory); } node.size += size - (currentSize ?? 0); } @@ -127,6 +155,8 @@ export class FilesystemTree { // the path is not found, return now return this; } + this.removedCount++; + this.removedSize -= currentSize; this.size -= currentSize; const parts = path.split('/'); let node = this.root; @@ -161,7 +191,7 @@ export class FilesystemTree { if (next) { node = next; } else { - node = node.addChild(part); + node = node.addChild(part, false); } node.size -= currentSize ?? 0; } @@ -203,7 +233,7 @@ export class FilesystemTree { return false; } } - return node.children.size > 0; + return node.children.size > 0 || node.isDirectory; } copy(): FilesystemTree { diff --git a/packages/renderer/src/lib/image/imageDetailsFiles.spec.ts b/packages/renderer/src/lib/image/imageDetailsFiles.spec.ts index f6e2fa67e909a..9fda188b39f7e 100644 --- a/packages/renderer/src/lib/image/imageDetailsFiles.spec.ts +++ b/packages/renderer/src/lib/image/imageDetailsFiles.spec.ts @@ -75,10 +75,20 @@ describe('toImageFilesystemLayerUIs', () => { ]; const result = toImageFilesystemLayerUIs(input); expect(result[0].sizeInArchive).toBe(150); - expect(result[0].sizeInContainer).toBe(150); + expect(result[0].addedCount).toBe(2); + expect(result[0].addedSize).toBe(150); + expect(result[0].modifiedCount).toBe(0); + expect(result[0].modifiedSize).toBe(0); + expect(result[0].removedCount).toBe(0); + expect(result[0].removedSize).toBe(0); expect(result[0].stackTree.size).toBe(150); expect(result[1].sizeInArchive).toBe(20); - expect(result[1].sizeInContainer).toBe(20); + expect(result[1].addedCount).toBe(1); + expect(result[1].addedSize).toBe(20); + expect(result[1].modifiedCount).toBe(0); + expect(result[1].modifiedSize).toBe(0); + expect(result[1].removedCount).toBe(0); + expect(result[1].removedSize).toBe(0); expect(result[1].stackTree.size).toBe(170); }); @@ -130,10 +140,20 @@ describe('toImageFilesystemLayerUIs', () => { ]; const result = toImageFilesystemLayerUIs(input); expect(result[0].sizeInArchive).toBe(150); - expect(result[0].sizeInContainer).toBe(150); + expect(result[0].addedCount).toBe(2); + expect(result[0].addedSize).toBe(150); + expect(result[0].modifiedCount).toBe(0); + expect(result[0].modifiedSize).toBe(0); + expect(result[0].removedCount).toBe(0); + expect(result[0].removedSize).toBe(0); expect(result[0].stackTree.size).toBe(150); expect(result[1].sizeInArchive).toBe(42); - expect(result[1].sizeInContainer).toBe(-8); + expect(result[1].addedCount).toBe(0); + expect(result[1].addedSize).toBe(0); + expect(result[1].modifiedCount).toBe(1); + expect(result[1].modifiedSize).toBe(-8); + expect(result[1].removedCount).toBe(0); + expect(result[1].removedSize).toBe(0); expect(result[1].stackTree.size).toBe(142); }); @@ -173,10 +193,20 @@ describe('toImageFilesystemLayerUIs', () => { ]; const result = toImageFilesystemLayerUIs(input); expect(result[0].sizeInArchive).toBe(150); - expect(result[0].sizeInContainer).toBe(150); + expect(result[0].addedCount).toBe(2); + expect(result[0].addedSize).toBe(150); + expect(result[0].modifiedCount).toBe(0); + expect(result[0].modifiedSize).toBe(0); + expect(result[0].removedCount).toBe(0); + expect(result[0].removedSize).toBe(0); expect(result[0].stackTree.size).toBe(150); expect(result[1].sizeInArchive).toBe(0); - expect(result[1].sizeInContainer).toBe(-50); + expect(result[1].addedCount).toBe(0); + expect(result[1].addedSize).toBe(0); + expect(result[1].modifiedCount).toBe(0); + expect(result[1].modifiedSize).toBe(0); + expect(result[1].removedCount).toBe(1); + expect(result[1].removedSize).toBe(-50); expect(result[1].stackTree.size).toBe(100); }); }); diff --git a/packages/renderer/src/lib/image/imageDetailsFiles.ts b/packages/renderer/src/lib/image/imageDetailsFiles.ts index a4db1a0532665..adcaf3e74dfba 100644 --- a/packages/renderer/src/lib/image/imageDetailsFiles.ts +++ b/packages/renderer/src/lib/image/imageDetailsFiles.ts @@ -27,13 +27,23 @@ export interface ImageFilesystemLayerUI extends ImageFilesystemLayer { layerTree: FilesystemTree; // The sum of the sizes of all the files in the layer sizeInArchive: number; - // The size of the files in the final filesystem - sizeInContainer: number; + // The number of added/modified/removed files and the sizes of related changes + addedCount: number; + modifiedCount: number; + removedCount: number; + addedSize: number; + modifiedSize: number; + removedSize: number; } export function toImageFilesystemLayerUIs(layers: ImageFilesystemLayer[]): ImageFilesystemLayerUI[] { const result: ImageFilesystemLayerUI[] = []; - let containerSizePreviousLayer = 0; + let addedCountPreviousLayer = 0; + let modifiedCountPreviousLayer = 0; + let removedCountPreviousLayer = 0; + let addedSizePreviousLayer = 0; + let modifiedSizePreviousLayer = 0; + let removedSizePreviousLayer = 0; const stackTree = new FilesystemTree(''); for (const layer of layers) { const layerTree = new FilesystemTree(''); @@ -47,18 +57,28 @@ export function toImageFilesystemLayerUIs(layers: ImageFilesystemLayer[]): Image layerTree.addWhiteout(`${opaqueWhiteout}/*`); } for (const file of layer.files ?? []) { - stackTree.addPath(file.path, file, file.size); - layerTree.addPath(file.path, file, file.size); + stackTree.addPath(file.path, file, file.size, file.type === 'directory'); + layerTree.addPath(file.path, file, file.size, file.type === 'directory'); sizeInArchive += file.size; } result.push({ stackTree: stackTree.copy(), layerTree, ...layer, - sizeInContainer: stackTree.size - containerSizePreviousLayer, sizeInArchive, + addedCount: stackTree.addedCount - addedCountPreviousLayer, + modifiedCount: stackTree.modifiedCount - modifiedCountPreviousLayer, + removedCount: stackTree.removedCount - removedCountPreviousLayer, + addedSize: stackTree.addedSize - addedSizePreviousLayer, + modifiedSize: stackTree.modifiedSize - modifiedSizePreviousLayer, + removedSize: stackTree.removedSize - removedSizePreviousLayer, }); - containerSizePreviousLayer = stackTree.size; + addedCountPreviousLayer = stackTree.addedCount; + modifiedCountPreviousLayer = stackTree.modifiedCount; + removedCountPreviousLayer = stackTree.removedCount; + addedSizePreviousLayer = stackTree.addedSize; + modifiedSizePreviousLayer = stackTree.modifiedSize; + removedSizePreviousLayer = stackTree.removedSize; } return result; }