Skip to content
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

feat: display files added/removed/modified by image layers #9175

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 10 additions & 14 deletions packages/renderer/src/lib/image/ImageDetailsFilesLayers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,28 +36,26 @@ test('render', async () => {
id: 'layer1',
createdBy: 'creator',
sizeInArchive: 1000,
sizeInContainer: 900,
stackTree: {
size: 2000,
} as unknown as FilesystemTree<ImageFile>,
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<ImageFile>,
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)');
});
22 changes: 19 additions & 3 deletions packages/renderer/src/lib/image/ImageDetailsFilesLayers.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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(' • ')}`;
}
</script>

{#each layers as layer}
{@const sizesText = getSizesText(layer)}
<button
on:click={() => onLayerSelected(layer)}
role="row"
Expand All @@ -29,9 +47,7 @@ function onLayerSelected(layer: ImageFilesystemLayerUI) {
<div class="text-sm opacity-70">{new ImageUtils().getHumanSize(layer.sizeInArchive)} &bull; {layer.id}</div>
<ImageDetailsFilesExpandableCommand command={layer.createdBy} />
<div class="text-sm opacity-70">
<span>contribute to FS: {signedHumanSize(layer.sizeInContainer)}</span>
<span> | </span>
<span>total FS: {new ImageUtils().getHumanSize(layer.stackTree.size)}</span>
{sizesText}
</div>
</div>
</button>
Expand Down
75 changes: 55 additions & 20 deletions packages/renderer/src/lib/image/filesystem-tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ interface Typ {

test('add paths to filetree', () => {
const tree = new FilesystemTree<Typ>('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();

Expand Down Expand Up @@ -71,21 +71,21 @@ test('add paths to filetree', () => {
});

test('currentSize with existing file', () => {
const tree = new FilesystemTree<Typ>('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5);
const tree = new FilesystemTree<Typ>('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<Typ>('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5);
const tree = new FilesystemTree<Typ>('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<Typ>('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);
Expand All @@ -95,31 +95,53 @@ test('add an existing file', () => {

test('add an existing directory containing files', () => {
const tree = new FilesystemTree<Typ>('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<Typ>('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5).hidePath('A/B/D.txt');
const tree = new FilesystemTree<Typ>('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<Typ>('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5).hidePath('A/B/C.txt');
const tree = new FilesystemTree<Typ>('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', () => {
Expand All @@ -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<Typ>('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);
Expand All @@ -150,8 +178,8 @@ test('hide content of non-existing directory', () => {

test('hide directory content', () => {
const tree = new FilesystemTree<Typ>('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);
Expand All @@ -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<Typ>('tree1').addPath('A/B/C.txt', { path: 'A/B/C.txt' }, 5);
test('isDirectory with children', () => {
const tree = new FilesystemTree<Typ>('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<Typ>('tree1').addPath('A/B', { path: 'A/B' }, 0, true);
expect(tree1.isDirectory('/A/B')).toBeTruthy();
const tree2 = new FilesystemTree<Typ>('tree1').addPath('A/B', { path: 'A/B' }, 0, false);
expect(tree2.isDirectory('/A/B')).toBeFalsy();
});
48 changes: 39 additions & 9 deletions packages/renderer/src/lib/image/filesystem-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,27 @@
export class FilesystemNode<T> {
name: string;
data?: T;
isDirectory: boolean;
children: Map<string, FilesystemNode<T>>;
size: number;
hidden: boolean;

constructor(name: string) {
constructor(name: string, isDirectory: boolean) {
this.name = name;
this.children = new Map<string, FilesystemNode<T>>();
this.size = 0;
this.hidden = false;
this.isDirectory = isDirectory;
}

addChild(name: string): FilesystemNode<T> {
const child = new FilesystemNode<T>(name);
addChild(name: string, isDirectory: boolean): FilesystemNode<T> {
const child = new FilesystemNode<T>(name, isDirectory);
this.children.set(name, child);
return child;
}

copy(): FilesystemNode<T> {
const result = new FilesystemNode<T>(this.name);
const result = new FilesystemNode<T>(this.name, this.isDirectory);
result.data = this.data;
result.size = this.size;
result.hidden = this.hidden;
Expand All @@ -53,16 +55,42 @@ export class FilesystemTree<T> {
root: FilesystemNode<T>;
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<T>('/');
this.root = new FilesystemNode<T>('/', 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<T> {
addPath(path: string, entry: T, size: number, isDirectory: boolean): FilesystemTree<T> {
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;
}
Expand All @@ -78,7 +106,7 @@ export class FilesystemTree<T> {
if (next) {
node = next;
} else {
node = node.addChild(part);
node = node.addChild(part, isDirectory);
}
node.size += size - (currentSize ?? 0);
}
Expand Down Expand Up @@ -127,6 +155,8 @@ export class FilesystemTree<T> {
// 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;
Expand Down Expand Up @@ -161,7 +191,7 @@ export class FilesystemTree<T> {
if (next) {
node = next;
} else {
node = node.addChild(part);
node = node.addChild(part, false);
}
node.size -= currentSize ?? 0;
}
Expand Down Expand Up @@ -203,7 +233,7 @@ export class FilesystemTree<T> {
return false;
}
}
return node.children.size > 0;
return node.children.size > 0 || node.isDirectory;
}

copy(): FilesystemTree<T> {
Expand Down
Loading