Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
3c76aad
#224 add tooltips to many locations
F3l1x1vo Aug 10, 2026
4d3b8ce
convert toast to portal
F3l1x1vo Aug 10, 2026
cdafe55
Merge branch 'feat/s3-file-browser-v1' into feat/s3-file-browser-v1-t…
F3l1x1vo Aug 10, 2026
9645d4a
fix merge conflict
F3l1x1vo Aug 10, 2026
d58c4a6
Merge branch 'feat/s3-file-browser-v1' into feat/s3-file-browser-v2
F3l1x1vo Aug 13, 2026
fab1ea7
feat: Add basic search Modal (#288)
dklOrdix Sep 1, 2026
36ffd37
Feat/s3 file browser v2 visual improvements (#297)
dklOrdix Sep 1, 2026
65b2baa
Feat/s3 file browser v2 recent search tabs (#292)
dklOrdix Sep 1, 2026
7b717d9
fix merge regressions that were lost from commit 92773d6
F3l1x1vo Sep 1, 2026
e04a225
Multipart Download and Archived Download (#287)
F3l1x1vo Sep 1, 2026
49c7d1d
fix: Allow running as arbitrary UID for (OpenShift) (#296)
labrenbe Aug 27, 2026
1628632
use tabs by default, fix tooltips, rename storage routes, operations …
F3l1x1vo Aug 31, 2026
2fdfcc2
fix tab and bucket switching behavior
F3l1x1vo Aug 31, 2026
3604c4e
remove close button
F3l1x1vo Aug 31, 2026
dd5a02e
fix lint
F3l1x1vo Aug 31, 2026
3ed1e0e
fix history.pushState
F3l1x1vo Aug 31, 2026
d09e86e
add nvm version
F3l1x1vo Aug 31, 2026
5e76450
pull from v1
F3l1x1vo Sep 1, 2026
4a24e5b
Merge remote-tracking branch 'origin/feat/s3-file-browser-v1' into fe…
F3l1x1vo Sep 2, 2026
e00cb8a
fix: hide parallel search button when not on search tab
dklOrdix Sep 2, 2026
8213130
fix: hide browser downloads from operations
dklOrdix Sep 2, 2026
148ed68
fix: improve download history entries
dklOrdix Sep 2, 2026
81f5802
fix: report partial storage search failures
dklOrdix Sep 2, 2026
9e02aec
#180 add pagination to search
F3l1x1vo Sep 3, 2026
03f57a7
#180 add search term history cycling
F3l1x1vo Sep 3, 2026
a210f38
fix lint and unit test
F3l1x1vo Sep 3, 2026
0e50eb5
#180 stream search results
F3l1x1vo Sep 3, 2026
05d7c8d
Merge feat/s3-file-browser-v1 into feat/s3-file-browser-v2
F3l1x1vo Oct 7, 2026
f995494
#289 fix e2e test
F3l1x1vo Oct 8, 2026
92352cb
#289 fix warnings
F3l1x1vo Oct 8, 2026
ec27e50
#289 implement code review
F3l1x1vo Oct 9, 2026
41ddf86
#289 remove typebox override, add override comments
F3l1x1vo Oct 9, 2026
d06b0e0
#289 fix e2e test
F3l1x1vo Oct 9, 2026
55d59bd
fix(storage): keep trailing slash on folder destination keys
Bohreromir Oct 12, 2026
7fd009c
fix(storage): close regex search guard bypasses
Bohreromir Oct 12, 2026
5b03260
fix(storage): treat not-yet-started download jobs as pending
Bohreromir Oct 12, 2026
6195580
fix(storage): evaluate search date filters in the user's time zone
Bohreromir Oct 12, 2026
51afee1
fix(storage): keep connection name when blank, report duplicates
Bohreromir Oct 12, 2026
e272630
fix(storage): report the search result limit as its own error
Bohreromir Oct 12, 2026
b0976d2
fix(storage): count one ZIP64 extra block in the archive size
Bohreromir Oct 12, 2026
4db9993
fix(ui): let Escape close an open popover inside a modal first
Bohreromir Oct 12, 2026
c5c2352
fix(storage): key recent searches by a hash of their parameters
Bohreromir Oct 12, 2026
3f1c414
fix(storage): require the secret key when changing the access key
Bohreromir Oct 12, 2026
368749a
fix(storage): return 404 for manifest ids that are not UUIDs
Bohreromir Oct 12, 2026
a85b49e
fix(storage): allow retrying a failed download with the same job id
Bohreromir Oct 12, 2026
25dc39e
fix(storage): clear download history only for the active connection
Bohreromir Oct 12, 2026
197f2cd
fix(storage): treat a search stream without completion as failed
Bohreromir Oct 12, 2026
a0009f0
fix(storage): match the whole minute for "=" date filters with a time
Bohreromir Oct 12, 2026
ae9f8a0
fix(storage): validate max folder depth before searching
Bohreromir Oct 12, 2026
a8e1b18
fix(storage): share keep-alive agents across S3 clients
Bohreromir Oct 12, 2026
c86d07d
test(e2e): skip the folder rename test without Garage
Bohreromir Oct 13, 2026
7d3321a
fix(storage): sanitise archive entry names
Bohreromir Oct 13, 2026
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
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ build/
.output/
e2e/test-results/
e2e/.auth/
.playwright/
reports/
src/lib/paraglide/
project.inlang/

Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ RUN NODEJS_VERSION=$(cat /tmp/.node-version) && \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
microdnf update -y && \
microdnf install -y curl tar gzip && \
microdnf install -y curl tar gzip zip && \
microdnf clean all && \
curl -fsSL "https://nodejs.org/dist/v${NODEJS_VERSION}/node-v${NODEJS_VERSION}-linux-${NODEJS_ARCH}.tar.gz" \
| tar -xzf - --strip-components=1 -C /usr/local
Expand Down
36 changes: 36 additions & 0 deletions e2e/storage/connections-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ test.describe('Storage — Connections management', () => {
await expect(page.getByRole('heading', { name: 'Manage connections' })).toBeVisible();
});

test('edit page keeps the current name when the name field is cleared', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
await page.goto('/settings/connections');
await waitForHydration(page);

await openFirstConnectionEditPage(page);
const nameInput = page.getByLabel('Connection name');
const originalName = await nameInput.inputValue();
expect(originalName).not.toBe('');

await nameInput.fill(' ');
await page.getByRole('button', { name: 'Save changes' }).click();
await waitForHydration(page);
await expect(page).toHaveURL('/settings/connections');

await openFirstConnectionEditPage(page);
await expect(page.getByLabel('Connection name')).toHaveValue(originalName);
});

test('edit page requires the secret key when the access key changes', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
await page.goto('/settings/connections');
await waitForHydration(page);

await openFirstConnectionEditPage(page);
await page.getByLabel('Access key').fill('GKnewaccesskey');
await page.getByRole('button', { name: 'Save changes' }).click();

await expect(
page.getByText('Enter the secret key that belongs to the new access key.')
).toBeVisible();
await expect(page).toHaveURL(/\/edit$/);
});

test('edit page shows error for invalid credentials', async ({ page }) => {
const credentials = requireGarageCredentials();
await connectToStorage(page, credentials);
Expand Down
121 changes: 121 additions & 0 deletions e2e/storage/download.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import AdmZip from 'adm-zip';
import {
createS3Client,
hasGarageCredentials,
Expand Down Expand Up @@ -47,6 +49,8 @@ test.describe('Storage S3 — Download', () => {
]);

expect(download.suggestedFilename()).toBe('download-me.txt');
expect(await download.failure()).toBeNull();
expect(await readFile((await download.path())!, 'utf8')).toBe('download content');
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
Expand Down Expand Up @@ -80,4 +84,121 @@ test.describe('Storage S3 — Download', () => {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});

test('downloads up to three selected files individually', async ({ page }, testInfo) => {
const credentials = requireGarageCredentials();
const client = createS3Client(credentials);
const prefix = uniquePrefix(testInfo, 'dl-multiple');
const cleanupKeys = ['one.txt', 'two.txt', 'three.txt'].map((name) => `${prefix}${name}`);

try {
await Promise.all(
cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key))
);
await connectAndOpenPrefix(page, credentials, prefix);
await page.getByRole('button', { name: 'Toggle selection mode' }).click();
for (const name of ['one.txt', 'two.txt', 'three.txt']) {
await page.getByLabel(`Select ${name}`).check();
}

const downloads: string[] = [];
page.on('download', (download) => downloads.push(download.suggestedFilename()));
await page.getByRole('button', { name: 'Download', exact: true }).click();
await expect.poll(() => downloads.sort()).toEqual(['one.txt', 'three.txt', 'two.txt']);
expect(downloads.sort()).toEqual(['one.txt', 'three.txt', 'two.txt']);
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});

test('archives a selection larger than three files', async ({ page }, testInfo) => {
const credentials = requireGarageCredentials();
const client = createS3Client(credentials);
const prefix = uniquePrefix(testInfo, 'dl-archive');
const cleanupKeys = ['one.txt', 'two.txt', 'three.txt', 'four.txt'].map(
(name) => `${prefix}${name}`
);

try {
await Promise.all(
cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key))
);
await connectAndOpenPrefix(page, credentials, prefix);
await page.getByRole('button', { name: 'Toggle selection mode' }).click();
for (const name of ['one.txt', 'two.txt', 'three.txt', 'four.txt']) {
await page.getByLabel(`Select ${name}`).check();
}

const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Download', exact: true }).click()
]);
expect(download.suggestedFilename()).toMatch(/\.zip$/);
expect(await download.failure()).toBeNull();
const zip = new AdmZip(await readFile((await download.path())!));
expect(
zip
.getEntries()
.map((entry) => entry.entryName)
.sort()
).toEqual([...cleanupKeys].sort());
for (const key of cleanupKeys) expect(zip.readAsText(key)).toBe(key);
await expect
.poll(() =>
page.evaluate(() => {
const operations = JSON.parse(
localStorage.getItem('storage_operations_history') ?? '[]'
) as Array<{ type: string; status: string; sourceNames: string[] }>;
return operations
.filter(
(operation) =>
operation.type === 'download' && operation.sourceNames.includes('four.txt')
)
.at(-1)?.status;
})
)
.toBe('done');
await page.getByRole('button', { name: 'Operations', exact: true }).click();
const history = page.getByRole('region', { name: 'Download history' });
await history.getByRole('button').filter({ hasText: credentials.bucket }).first().click();
const [redownload] = await Promise.all([
page.waitForEvent('download'),
history.getByRole('button', { name: 'Download all', exact: true }).first().click()
]);
expect(await redownload.failure()).toBeNull();
const recreatedZip = new AdmZip(await readFile((await redownload.path())!));
for (const key of cleanupKeys) expect(recreatedZip.readAsText(key)).toBe(key);
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});

test('downloads a folder via its context menu when not in selection mode', async ({
page
}, testInfo) => {
const credentials = requireGarageCredentials();
const client = createS3Client(credentials);
const prefix = uniquePrefix(testInfo, 'dl-folder');
const cleanupKeys = [`${prefix}folder1/one.txt`, `${prefix}folder1/two.txt`];

try {
await Promise.all(
cleanupKeys.map((key) => putTextObject(client, credentials.bucket, key, key))
);
await connectAndOpenPrefix(page, credentials, prefix);

await rowByName(page, 'folder1').click({ button: 'right' });

const downloadBtn = page.getByRole('menuitem', { name: 'Download' });
await expect(downloadBtn).not.toHaveAttribute('disabled');

const [download] = await Promise.all([page.waitForEvent('download'), downloadBtn.click()]);
expect(download.suggestedFilename()).toMatch(/\.zip$/);
expect(await download.failure()).toBeNull();
const zip = new AdmZip(await readFile((await download.path())!));
for (const key of cleanupKeys) expect(zip.readAsText(key)).toBe(key);
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});
});
45 changes: 43 additions & 2 deletions e2e/storage/file-operations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ test.describe('Storage S3 — File Operations', () => {
const prefix = uniquePrefix(testInfo, 'kb-cut-paste');
const srcKey = `${prefix}cutsrc/`;
const srcFile = `${srcKey}cut-kb.txt`;
const cleanupKeys = [srcFile];
const destFile = `${prefix}cut-kb.txt`;
const cleanupKeys = [srcFile, destFile];

try {
await putDirectoryMarker(client, credentials.bucket, srcKey);
Expand All @@ -388,7 +389,7 @@ test.describe('Storage S3 — File Operations', () => {
await page.waitForTimeout(1000);

// File should exist at destination
expect(await objectExists(client, credentials.bucket, `${prefix}cut-kb.txt`)).toBe(true);
expect(await objectExists(client, credentials.bucket, destFile)).toBe(true);
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
Expand Down Expand Up @@ -491,4 +492,44 @@ test.describe('Storage S3 — File Operations', () => {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});

test('renames a folder subtree and removes its source marker', async ({ page }, testInfo) => {
const credentials = requireGarageCredentials();
const client = createS3Client(credentials);
const prefix = uniquePrefix(testInfo, 'folder-rename');
const oldRoot = `${prefix}docs/`;
const newRoot = `${prefix}renamed/`;
const cleanupKeys = [
oldRoot,
`${oldRoot}one.txt`,
`${oldRoot}nested/two.txt`,
newRoot,
`${newRoot}one.txt`,
`${newRoot}nested/two.txt`
];
try {
await putDirectoryMarker(client, credentials.bucket, oldRoot);
await putTextObject(client, credentials.bucket, `${oldRoot}one.txt`, 'first');
await putTextObject(client, credentials.bucket, `${oldRoot}nested/two.txt`, 'second');
await connectAndOpenPrefix(page, credentials, prefix);
await rowByName(page, 'docs').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Rename' }).click();
await page.locator('.modal-box input').fill('renamed');
await page.getByRole('button', { name: 'Rename', exact: true }).click();
await expect
.poll(() => objectExists(client, credentials.bucket, `${newRoot}nested/two.txt`))
.toBe(true);
expect(await getObjectText(client, credentials.bucket, `${newRoot}one.txt`)).toBe('first');
expect(await getObjectText(client, credentials.bucket, `${newRoot}nested/two.txt`)).toBe(
'second'
);
expect(await objectExists(client, credentials.bucket, oldRoot)).toBe(false);
expect(await objectExists(client, credentials.bucket, `${oldRoot}one.txt`)).toBe(false);
expect(await objectExists(client, credentials.bucket, `${oldRoot}nested/two.txt`)).toBe(
false
);
} finally {
await deleteKnownKeys(client, credentials.bucket, cleanupKeys);
}
});
});
3 changes: 3 additions & 0 deletions e2e/storage/preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ test.describe('Storage S3 — Preview', () => {

await rowByName(page, 'escape.txt').dblclick();
await expect(page.getByRole('heading', { name: 'escape.txt' })).toBeVisible();
const editor = page.getByRole('application', { name: 'Text editor' });
await expect(editor).toHaveAttribute('data-ready', 'true');
await expect(editor.locator(':focus')).toHaveCount(1);

await page.keyboard.press('Escape');

Expand Down
Loading
Loading