diff --git a/docs/superpowers/specs/2026-10-06-bucket-versioning-design.md b/docs/superpowers/specs/2026-10-06-bucket-versioning-design.md new file mode 100644 index 0000000..bf384fe --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-bucket-versioning-design.md @@ -0,0 +1,249 @@ +# Bucket versioning management & per-object versions — design + +## Summary + +Add S3 object-versioning support to the app in two cohesive parts on the +`feat/bucket-versioning` branch: + +1. **Bucket versioning management** — a new "Versioning" navigation section + (alongside CORS and Object Lock) that shows a bucket's current versioning + status and lets the user enable or suspend versioning. +2. **Per-object versions** — a "Versions" action in the file details panel that + opens a dialog listing every version (and delete marker) of the selected + object, with per-row actions: download a version, restore an older version as + the current one, permanently delete a version, and remove a delete marker + ("undelete"). + +Both parts mirror the existing bucket-config pattern (`cors`, `objectLock`): +a focused main-process module, typed IPC channels, a renderer query/mutation +hook, and a component — plus i18n across all six locales. + +## Decisions made + +- **Placement — bucket management:** its own `versioning` nav section, driven by + the sidebar account/bucket selectors, consistent with `cors` and `objectLock`. +- **Placement — per-object versions:** a toolbar button in `MetadataPanel` opens + a dismissible `VersionsDialog` (shared `Modal`: Esc + backdrop), consistent + with the existing Permissions/Metadata dialogs. +- **Versioning status model:** three states — `Unversioned`, `Enabled`, + `Suspended`. Once enabled, S3 versioning can only be **suspended**, never fully + disabled; existing versions are retained while suspended and no new versions + are created. The toggle is Enable/Suspend and the UI states this explicitly. +- **Per-object actions (all four):** download/presign a specific version, restore + an older version (non-destructive copy-to-latest), permanently delete a version + (destructive, confirmed), and remove a delete marker (undelete). +- **Version download:** the existing `presignGet` / `downloadObject` IPC gain an + **optional** `versionId`, reusing the existing save-dialog and presign flow + rather than duplicating it. Backward compatible — omitting it is today's + behaviour. +- **Both parts land on `feat/bucket-versioning`.** + +## Part A — bucket versioning management + +### Backend — `src/main/s3/versioning.ts` + +```ts +export type VersioningStatus = 'Unversioned' | 'Enabled' | 'Suspended'; + +export function getBucketVersioning( + client: S3Client, + bucket: string, +): Promise>; + +export function putBucketVersioning( + client: S3Client, + bucket: string, + enabled: boolean, +): Promise>; +``` + +- `getBucketVersioning` → `GetBucketVersioningCommand`. Map the SDK `Status` + field: `'Enabled'` → `Enabled`, `'Suspended'` → `Suspended`, missing/undefined + → `Unversioned`. Errors go through `toErr` (from `./objects`). +- `putBucketVersioning` → `PutBucketVersioningCommand` with + `VersioningConfiguration: { Status: enabled ? 'Enabled' : 'Suspended' }`. +- MFA-delete is intentionally never set (out of scope). + +### IPC + +- `CH.getBucketVersioning` = `'s3:getBucketVersioning'`, + args `[{ accountId; bucket }]`, res `Result<{ status: VersioningStatus }>`. +- `CH.putBucketVersioning` = `'s3:putBucketVersioning'`, + args `[{ accountId; bucket; enabled: boolean }]`, res `Result`. +- Handlers in `register.ts` resolve a client from `accountId` via the existing + account-client helper (same shape as `getBucketCors` / `getObjectLockConfig`). + +### Renderer + +- `src/renderer/hooks/useBucketVersioning.ts` — a `useQuery` for the status keyed + on `['bucketVersioning', accountId, bucket]` (enabled only when both are set) + plus a `useMutation` for enable/suspend that invalidates the status query. +- `src/renderer/components/versioning/VersioningEditor.tsx` — the section body: + - Shows the current status as a badge (`Unversioned` / `Enabled` / `Suspended`). + - A primary button: **Enable** when not enabled; **Suspend** when enabled. + - An explanatory note that suspend keeps existing versions and only stops new + ones, and that versioning cannot be fully removed once enabled. + - Loading / error / "pick an account and bucket" empty states matching the + other selector sections. + +### Navigation wiring + +- `SectionNav`: add `'versioning'` to the `Section` union and a nav entry with an + icon (e.g. `FiLayers` / `FiClock` from `react-icons/fi`), placed next to CORS + and Object Lock. +- `App.tsx`: add `'versioning'` to `SELECTOR_SECTIONS` and render + `` in the section switch. +- i18n label under `nav.versioning`. + +## Part B — per-object versions + +### Backend — `src/main/s3/objectVersions.ts` + +```ts +export interface ObjectVersion { + versionId: string; + isLatest: boolean; + isDeleteMarker: boolean; + lastModified: string | null; + size: number | null; // null for delete markers + etag: string | null; // null for delete markers +} + +export function listObjectVersions( + client: S3Client, + args: { bucket: string; key: string }, +): Promise>; + +export function restoreObjectVersion( + client: S3Client, + args: { bucket: string; key: string; versionId: string }, +): Promise>; + +export function deleteObjectVersion( + client: S3Client, + args: { bucket: string; key: string; versionId: string }, +): Promise>; + +export function removeDeleteMarker( + client: S3Client, + args: { bucket: string; key: string; versionId: string }, +): Promise>; +``` + +- `listObjectVersions` → `ListObjectVersionsCommand` with `Prefix: key`. Because + a prefix can match sibling keys, keep only entries whose `Key === key`. Merge + `Versions` (→ `isDeleteMarker: false`) and `DeleteMarkers` + (→ `isDeleteMarker: true`, `size: null`, `etag: null`). Paginate while + `IsTruncated`, carrying `KeyMarker` / `VersionIdMarker`. Sort by + `lastModified` descending (latest first); within equal timestamps the + `IsLatest` entry sorts first. +- `restoreObjectVersion` → `CopyObjectCommand` with + `CopySource` = URL-encoded `${bucket}/${key}?versionId=${versionId}` and + `Key: key` (same bucket). Default `MetadataDirective` (COPY) preserves + metadata. This writes a new current version — non-destructive. +- `deleteObjectVersion` / `removeDeleteMarker` → `DeleteObjectCommand` with + `Bucket`, `Key`, `VersionId`. (Same SDK call; two names for intent/clarity and + independent i18n/confirmation. `removeDeleteMarker` targets a delete marker's + version id, which makes the prior version current again.) +- All functions return through `toErr` on failure. Providers that do not support + versioning surface their native error (e.g. `NotImplemented`) via + `humanErrorMessage` in the renderer. + +### Version-aware download / presign + +- Extend the existing `objects.ts` `presignGetUrl` and `downloadObject` to accept + an **optional** `versionId`, threaded into `GetObjectCommand`. Omitting it is + unchanged behaviour. +- `ApiMap[CH.presignGet].args` and `ApiMap[CH.downloadObject].args` gain an + optional `versionId?: string`. `preload` forwards the whole arg object — no + change there. + +### IPC + +- `CH.listObjectVersions` = `'s3:listObjectVersions'`, + args `[{ accountId; bucket; key }]`, res `Result`. +- `CH.restoreObjectVersion` = `'s3:restoreObjectVersion'`, + args `[{ accountId; bucket; key; versionId }]`, res `Result`. +- `CH.deleteObjectVersion` = `'s3:deleteObjectVersion'`, + args `[{ accountId; bucket; key; versionId }]`, res `Result`. +- `CH.removeDeleteMarker` = `'s3:removeDeleteMarker'`, + args `[{ accountId; bucket; key; versionId }]`, res `Result`. + +### Renderer + +- `src/renderer/hooks/useObjectVersions.ts` — a `useQuery` keyed on + `['objectVersions', accountId, bucket, key]` plus mutations `restore`, + `remove` (permanent delete), and `removeMarker`, each invalidating the versions + query (and `remove`/`removeMarker`/`restore` also invalidating the object + listing so the file browser reflects a changed current version). +- `src/renderer/components/files/VersionsDialog.tsx` — a `Modal`-wrapped dialog: + - A header with the object key and a close button. + - A list of versions, newest first. Each row shows: a short version id, last + modified timestamp, size (`formatBytes`, blank for markers), a **"latest"** + badge on the current version, and a **"deleted"** badge on delete markers. + - Per-row actions: + - Normal version: **Download** (calls the version-aware download), **Restore** + (if not the latest), **Delete** (permanent, via `ConfirmDialog`). + - Delete marker: **Remove** (undelete, via `ConfirmDialog`). + - Loading / error / empty ("no versions / versioning not enabled") states. +- `MetadataPanel`: add a **Versions** button to the action toolbar (e.g. + `FiClock`) that opens `VersionsDialog` for the selected `objectKey`. Shown + always; the dialog itself explains when there is only one version. + +### i18n + +A `versioning.*` namespace across all six locales (en/de/fr/pl/nl/ro): +status labels (`unversioned` / `enabled` / `suspended`), the enable/suspend +buttons and their confirmations, the suspend-keeps-versions note, the nav label, +the dialog title, column headers, the latest/deleted badges, the per-row action +labels, and the permanent-delete / remove-marker confirmation messages. + +## Error handling & edge cases + +- **Unsupported provider:** versioning / list-versions calls may fail with + `NotImplemented`, `MethodNotAllowed`, or similar — surfaced as a readable error + in the section / dialog, not a crash. +- **Suspended bucket:** still lists existing versions; the status badge shows + `Suspended`. +- **Restore under object lock / retention:** the copy may be rejected by the + provider; the error is surfaced. +- **Permanent delete under retention / legal hold:** the delete may be rejected; + the error is surfaced (no optimistic removal before the call resolves). +- **Prefix collision:** `listObjectVersions` filters to exact `Key === key`, so + sibling keys sharing the prefix are excluded. +- **Delete marker as latest:** the object appears "deleted" in a normal listing; + the dialog still shows its versions and offers "remove marker" to undelete. + +## Testing (TDD) + +- `versioning.test.ts`: `getBucketVersioning` maps `Enabled` / `Suspended` / + missing→`Unversioned`; `putBucketVersioning(true|false)` sends the right + `Status`; error mapping via mocked client. +- `objectVersions.test.ts`: `listObjectVersions` merges versions + delete markers, + filters by exact key, sorts latest-first, maps fields (null size/etag for + markers), paginates; `restoreObjectVersion` sends a `CopyObjectCommand` with a + correctly encoded `CopySource` + version id; `deleteObjectVersion` / + `removeDeleteMarker` send `DeleteObjectCommand` with the version id; error + mapping. +- `objects.test.ts`: `presignGetUrl` / `downloadObject` include `VersionId` when + given and omit it otherwise. +- `register.test.ts`: the six new handlers resolve a client and delegate; the + extended presign/download handlers forward `versionId`. +- Hook tests: `useBucketVersioning` (query + enable/suspend invalidation), + `useObjectVersions` (query + restore/remove/removeMarker invalidation). +- Component tests: `VersioningEditor` renders status and toggles with + confirmation; `VersionsDialog` renders rows with badges, wires per-row actions + to mutations, and gates permanent delete / remove-marker behind `ConfirmDialog`; + `MetadataPanel` shows the Versions button and opens the dialog. + +## Out of scope + +- MFA-delete (requires account root + an MFA serial). +- Version diff / preview or side-by-side comparison. +- A bucket-wide version listing (versions are viewed per object). +- Lifecycle rules for expiring noncurrent versions. +- Bulk version operations across many objects at once. + +## Open questions + +None. diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index aaaeadf..2fb1fbd 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -4,7 +4,9 @@ import type { ListObjectsResult, ObjectMetadata } from '../s3/objects'; import type { Visibility } from '../s3/visibility'; import type { Account } from '../storage/accountsRepo'; import type { CorsRule } from '../s3/cors'; +import type { VersioningStatus } from '../s3/versioning'; import type { ObjectLockStatus, DefaultRetention } from '../s3/objectLock'; +import type { ObjectVersion } from '../s3/objectVersions'; import type { ObjectRetention, LegalHoldStatus } from '../s3/objectRetention'; import type { Endpoint, SyncPlan, SyncResult } from '../s3/sync'; import type { LocalSyncArgs } from '../s3/localSync'; @@ -39,6 +41,12 @@ export const CH = { getBucketCors: 's3:getBucketCors', putBucketCors: 's3:putBucketCors', deleteBucketCors: 's3:deleteBucketCors', + getBucketVersioning: 's3:getBucketVersioning', + putBucketVersioning: 's3:putBucketVersioning', + listObjectVersions: 's3:listObjectVersions', + restoreObjectVersion: 's3:restoreObjectVersion', + deleteObjectVersion: 's3:deleteObjectVersion', + removeDeleteMarker: 's3:removeDeleteMarker', getObjectLockConfig: 's3:getObjectLockConfig', putObjectLockConfig: 's3:putObjectLockConfig', getObjectRetention: 's3:getObjectRetention', @@ -121,15 +129,21 @@ export interface ApiMap { [CH.headObject]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; [CH.objectVisibility]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; [CH.setObjectVisibility]: { args: [{ accountId: string; bucket: string; key: string; visibility: 'public' | 'private' }]; res: Result }; - [CH.presignGet]: { args: [{ accountId: string; bucket: string; key: string; expiresIn: number }]; res: Result }; + [CH.presignGet]: { args: [{ accountId: string; bucket: string; key: string; expiresIn: number; versionId?: string }]; res: Result }; [CH.presignPut]: { args: [{ accountId: string; bucket: string; key: string; expiresIn: number }]; res: Result }; [CH.deleteObject]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; [CH.deleteFolder]: { args: [{ accountId: string; bucket: string; prefix: string }]; res: Result }; [CH.uploadObject]: { args: [{ accountId: string; bucket: string; key: string; filePath: string; contentType?: string; uploadId: string }]; res: Result<{ key: string }> }; - [CH.downloadObject]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result<{ path: string | null }> }; + [CH.downloadObject]: { args: [{ accountId: string; bucket: string; key: string; versionId?: string }]; res: Result<{ path: string | null }> }; [CH.getBucketCors]: { args: [{ accountId: string; bucket: string }]; res: Result }; [CH.putBucketCors]: { args: [{ accountId: string; bucket: string; rules: CorsRule[] }]; res: Result }; [CH.deleteBucketCors]: { args: [{ accountId: string; bucket: string }]; res: Result }; + [CH.getBucketVersioning]: { args: [{ accountId: string; bucket: string }]; res: Result<{ status: VersioningStatus }> }; + [CH.putBucketVersioning]: { args: [{ accountId: string; bucket: string; enabled: boolean }]; res: Result }; + [CH.listObjectVersions]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; + [CH.restoreObjectVersion]: { args: [{ accountId: string; bucket: string; key: string; versionId: string }]; res: Result }; + [CH.deleteObjectVersion]: { args: [{ accountId: string; bucket: string; key: string; versionId: string }]; res: Result }; + [CH.removeDeleteMarker]: { args: [{ accountId: string; bucket: string; key: string; versionId: string }]; res: Result }; [CH.getObjectLockConfig]: { args: [{ accountId: string; bucket: string }]; res: Result }; [CH.putObjectLockConfig]: { args: [{ accountId: string; bucket: string; retention: DefaultRetention | null }]; res: Result }; [CH.getObjectRetention]: { args: [{ accountId: string; bucket: string; key: string }]; res: Result }; diff --git a/src/main/ipc/register.test.ts b/src/main/ipc/register.test.ts index 68edcfe..d80ff35 100644 --- a/src/main/ipc/register.test.ts +++ b/src/main/ipc/register.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { mockClient } from 'aws-sdk-client-mock'; -import { S3Client, ListBucketsCommand, GetObjectCommand, GetBucketCorsCommand, GetObjectLockConfigurationCommand, ListObjectsV2Command, PutObjectAclCommand, GetObjectRetentionCommand, PutObjectLegalHoldCommand, GetObjectAclCommand, HeadObjectCommand, CopyObjectCommand, CreateBucketCommand } from '@aws-sdk/client-s3'; +import { S3Client, ListBucketsCommand, GetObjectCommand, GetBucketCorsCommand, GetObjectLockConfigurationCommand, ListObjectsV2Command, PutObjectAclCommand, GetObjectRetentionCommand, PutObjectLegalHoldCommand, GetObjectAclCommand, HeadObjectCommand, CopyObjectCommand, CreateBucketCommand, GetBucketVersioningCommand, PutBucketVersioningCommand, ListObjectVersionsCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs'; import { Readable } from 'node:stream'; import { join } from 'node:path'; @@ -431,6 +431,23 @@ describe('downloadObject handler', () => { expect(readFileSync(dest, 'utf8')).toBe('payload'); expect(deps.saveDialog).toHaveBeenCalledWith('out.txt'); }); + + it('threads versionId into the GetObject request', async () => { + const { handlers, deps } = buildHarness(); + const dir = mkdtempSync(join(tmpdir(), 's3m-dl-')); + const dest = join(dir, 'v.txt'); + (deps.saveDialog as ReturnType).mockResolvedValue(dest); + s3Mock.on(GetObjectCommand).resolves({ Body: Readable.from([Buffer.from('old')]) as never }); + const created = (await handlers.get(CH.accountsCreate)!({ + label: 'AWS', provider: 'amazon-s3', region: 'us-east-1', accessKeyId: 'AK', secretAccessKey: 'SK', + })) as { data: { id: string } }; + + const res = (await handlers.get(CH.downloadObject)!({ + accountId: created.data.id, bucket: 'b', key: 'v.txt', versionId: 'v7', + })) as { ok: boolean }; + expect(res.ok).toBe(true); + expect(s3Mock.commandCalls(GetObjectCommand)[0].args[0].input).toMatchObject({ Key: 'v.txt', VersionId: 'v7' }); + }); }); describe('CORS handlers', () => { @@ -827,3 +844,49 @@ describe('custom provider', () => { expect(deps.accounts.list()).toHaveLength(0); }); }); + +describe('bucket versioning channels', () => { + it('getBucketVersioning delegates to the client', async () => { + s3Mock.on(GetBucketVersioningCommand).resolves({ Status: 'Enabled' }); + const { handlers, deps } = buildHarness(); + const acc = deps.accounts.create({ label: 'a', provider: 'amazon-s3', endpoint: undefined, region: 'eu-central-1', accessKeyId: 'AK', forcePathStyle: false }); + deps.secrets.set(acc.id, 'sk'); + const res = (await handlers.get(CH.getBucketVersioning)!({ accountId: acc.id, bucket: 'b' })) as { ok: boolean; data: { status: string } }; + expect(res).toEqual({ ok: true, data: { status: 'Enabled' } }); + }); + + it('putBucketVersioning sends the chosen status', async () => { + s3Mock.on(PutBucketVersioningCommand).resolves({}); + const { handlers, deps } = buildHarness(); + const acc = deps.accounts.create({ label: 'a', provider: 'amazon-s3', endpoint: undefined, region: 'eu-central-1', accessKeyId: 'AK', forcePathStyle: false }); + deps.secrets.set(acc.id, 'sk'); + const res = (await handlers.get(CH.putBucketVersioning)!({ accountId: acc.id, bucket: 'b', enabled: false })) as { ok: boolean }; + expect(res).toEqual({ ok: true, data: true }); + expect(s3Mock.commandCalls(PutBucketVersioningCommand)[0].args[0].input.VersioningConfiguration).toEqual({ Status: 'Suspended' }); + }); +}); + +describe('object versions channels', () => { + function withAccount() { + const { handlers, deps } = buildHarness(); + const acc = deps.accounts.create({ label: 'a', provider: 'amazon-s3', endpoint: undefined, region: 'eu-central-1', accessKeyId: 'AK', forcePathStyle: false }); + deps.secrets.set(acc.id, 'sk'); + return { handlers, accountId: acc.id }; + } + + it('listObjectVersions returns mapped versions', async () => { + s3Mock.on(ListObjectVersionsCommand).resolves({ Versions: [{ Key: 'a.txt', VersionId: 'v1', IsLatest: true, LastModified: new Date('2024-01-01T00:00:00Z'), Size: 3, ETag: '"e"' }], IsTruncated: false }); + const { handlers, accountId } = withAccount(); + const res = (await handlers.get(CH.listObjectVersions)!({ accountId, bucket: 'b', key: 'a.txt' })) as { ok: boolean; data: unknown[] }; + expect(res.ok).toBe(true); + expect(res.data).toHaveLength(1); + }); + + it('deleteObjectVersion sends a versioned delete', async () => { + s3Mock.on(DeleteObjectCommand).resolves({}); + const { handlers, accountId } = withAccount(); + const res = (await handlers.get(CH.deleteObjectVersion)!({ accountId, bucket: 'b', key: 'a.txt', versionId: 'v1' })) as { ok: boolean }; + expect(res).toEqual({ ok: true, data: true }); + expect(s3Mock.commandCalls(DeleteObjectCommand)[0].args[0].input).toMatchObject({ VersionId: 'v1' }); + }); +}); diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 7b47327..9a822f0 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -19,6 +19,8 @@ import { import { getObjectVisibility, setObjectVisibility } from '../s3/visibility'; import { getBucketCors, putBucketCors, deleteBucketCors } from '../s3/cors'; import type { CorsRule } from '../s3/cors'; +import { getBucketVersioning, putBucketVersioning } from '../s3/versioning'; +import { listObjectVersions, restoreObjectVersion, deleteObjectVersion, removeDeleteMarker } from '../s3/objectVersions'; import { getObjectLockConfig, putObjectLockConfig } from '../s3/objectLock'; import { getObjectRetention, putObjectRetention, getObjectLegalHold, putObjectLegalHold } from '../s3/objectRetention'; import type { LegalHoldStatus } from '../s3/objectRetention'; @@ -342,8 +344,8 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void { updateObjectMetadata(clientFor(a.accountId), { bucket: a.bucket, key: a.key, contentType: a.contentType, cacheControl: a.cacheControl, contentDisposition: a.contentDisposition, metadata: a.metadata }), ); - h(CH.presignGet, (a: { accountId: string; bucket: string; key: string; expiresIn: number }) => - presignGetUrl(clientFor(a.accountId), { bucket: a.bucket, key: a.key, expiresIn: a.expiresIn }), + h(CH.presignGet, (a: { accountId: string; bucket: string; key: string; expiresIn: number; versionId?: string }) => + presignGetUrl(clientFor(a.accountId), { bucket: a.bucket, key: a.key, expiresIn: a.expiresIn, versionId: a.versionId }), ); h(CH.presignPut, (a: { accountId: string; bucket: string; key: string; expiresIn: number }) => @@ -377,10 +379,10 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void { } }); - h(CH.downloadObject, async (a: { accountId: string; bucket: string; key: string }) => { + h(CH.downloadObject, async (a: { accountId: string; bucket: string; key: string; versionId?: string }) => { const dest = await deps.saveDialog(basename(a.key)); if (!dest) return ok({ path: null }); - const r = await downloadObject(clientFor(a.accountId), { bucket: a.bucket, key: a.key, destPath: dest }); + const r = await downloadObject(clientFor(a.accountId), { bucket: a.bucket, key: a.key, destPath: dest, versionId: a.versionId }); return r.ok ? ok({ path: dest as string | null }) : r; }); @@ -396,6 +398,30 @@ export function registerIpc(ipcMain: IpcMainLike, deps: RegisterDeps): void { deleteBucketCors(clientFor(a.accountId), a.bucket), ); + h(CH.getBucketVersioning, (a: { accountId: string; bucket: string }) => + getBucketVersioning(clientFor(a.accountId), a.bucket), + ); + + h(CH.putBucketVersioning, (a: { accountId: string; bucket: string; enabled: boolean }) => + putBucketVersioning(clientFor(a.accountId), a.bucket, a.enabled), + ); + + h(CH.listObjectVersions, (a: { accountId: string; bucket: string; key: string }) => + listObjectVersions(clientFor(a.accountId), { bucket: a.bucket, key: a.key }), + ); + + h(CH.restoreObjectVersion, (a: { accountId: string; bucket: string; key: string; versionId: string }) => + restoreObjectVersion(clientFor(a.accountId), { bucket: a.bucket, key: a.key, versionId: a.versionId }), + ); + + h(CH.deleteObjectVersion, (a: { accountId: string; bucket: string; key: string; versionId: string }) => + deleteObjectVersion(clientFor(a.accountId), { bucket: a.bucket, key: a.key, versionId: a.versionId }), + ); + + h(CH.removeDeleteMarker, (a: { accountId: string; bucket: string; key: string; versionId: string }) => + removeDeleteMarker(clientFor(a.accountId), { bucket: a.bucket, key: a.key, versionId: a.versionId }), + ); + h(CH.getObjectLockConfig, (a: { accountId: string; bucket: string }) => getObjectLockConfig(clientFor(a.accountId), a.bucket), ); diff --git a/src/main/s3/download.test.ts b/src/main/s3/download.test.ts index 33c6dc1..3d0a687 100644 --- a/src/main/s3/download.test.ts +++ b/src/main/s3/download.test.ts @@ -20,4 +20,27 @@ describe('downloadObject', () => { expect(r).toEqual({ ok: true, data: { path: dest } }); expect(readFileSync(dest, 'utf8')).toBe('file bytes'); }); + + it('includes VersionId in the GetObject request when provided', async () => { + s3Mock.on(GetObjectCommand).resolves({ Body: Readable.from([Buffer.from('hi')]) as never }); + const dir = mkdtempSync(join(tmpdir(), 's3m-')); + const dest = join(dir, 'out.bin'); + + const r = await downloadObject(new S3Client({}), { bucket: 'b', key: 'a.txt', destPath: dest, versionId: 'v1' }); + expect(r).toEqual({ ok: true, data: { path: dest } }); + expect(s3Mock.commandCalls(GetObjectCommand)[0].args[0].input).toMatchObject({ + Bucket: 'b', + Key: 'a.txt', + VersionId: 'v1', + }); + }); + + it('omits VersionId from the GetObject request when not provided', async () => { + s3Mock.on(GetObjectCommand).resolves({ Body: Readable.from([Buffer.from('hi')]) as never }); + const dir = mkdtempSync(join(tmpdir(), 's3m-')); + const dest = join(dir, 'out2.bin'); + + await downloadObject(new S3Client({}), { bucket: 'b', key: 'a.txt', destPath: dest }); + expect(s3Mock.commandCalls(GetObjectCommand)[0].args[0].input.VersionId).toBeUndefined(); + }); }); diff --git a/src/main/s3/objectVersions.test.ts b/src/main/s3/objectVersions.test.ts new file mode 100644 index 0000000..c89b638 --- /dev/null +++ b/src/main/s3/objectVersions.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { S3Client, ListObjectVersionsCommand, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { listObjectVersions, restoreObjectVersion, deleteObjectVersion, removeDeleteMarker } from './objectVersions'; + +const s3Mock = mockClient(S3Client); +beforeEach(() => s3Mock.reset()); + +describe('listObjectVersions', () => { + it('merges versions and delete markers, filters by exact key, sorts latest first', async () => { + s3Mock.on(ListObjectVersionsCommand).resolves({ + Versions: [ + { Key: 'a.txt', VersionId: 'v1', IsLatest: false, LastModified: new Date('2024-01-01T00:00:00Z'), Size: 10, ETag: '"e1"' }, + { Key: 'a.txt', VersionId: 'v2', IsLatest: true, LastModified: new Date('2024-03-01T00:00:00Z'), Size: 20, ETag: '"e2"' }, + { Key: 'a.txt.bak', VersionId: 'vX', IsLatest: true, LastModified: new Date('2024-04-01T00:00:00Z'), Size: 5, ETag: '"ex"' }, + ], + DeleteMarkers: [ + { Key: 'a.txt', VersionId: 'dm1', IsLatest: false, LastModified: new Date('2024-02-01T00:00:00Z') }, + ], + IsTruncated: false, + }); + const r = await listObjectVersions(new S3Client({}), { bucket: 'b', key: 'a.txt' }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.data.map((v) => v.versionId)).toEqual(['v2', 'dm1', 'v1']); + expect(r.data[0]).toEqual({ versionId: 'v2', isLatest: true, isDeleteMarker: false, lastModified: '2024-03-01T00:00:00.000Z', size: 20, etag: '"e2"' }); + expect(r.data[1]).toEqual({ versionId: 'dm1', isLatest: false, isDeleteMarker: true, lastModified: '2024-02-01T00:00:00.000Z', size: null, etag: null }); + }); + + it('paginates while IsTruncated', async () => { + s3Mock + .on(ListObjectVersionsCommand) + .resolvesOnce({ Versions: [{ Key: 'a.txt', VersionId: 'v1', IsLatest: false, LastModified: new Date('2024-01-01T00:00:00Z'), Size: 1, ETag: '"1"' }], IsTruncated: true, NextKeyMarker: 'a.txt', NextVersionIdMarker: 'v1' }) + .resolves({ Versions: [{ Key: 'a.txt', VersionId: 'v2', IsLatest: true, LastModified: new Date('2024-02-01T00:00:00Z'), Size: 2, ETag: '"2"' }], IsTruncated: false }); + const r = await listObjectVersions(new S3Client({}), { bucket: 'b', key: 'a.txt' }); + expect(r.ok && r.data.map((v) => v.versionId)).toEqual(['v2', 'v1']); + }); + + it('maps errors to err', async () => { + s3Mock.on(ListObjectVersionsCommand).rejects(Object.assign(new Error('no'), { name: 'NotImplemented' })); + const r = await listObjectVersions(new S3Client({}), { bucket: 'b', key: 'a.txt' }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe('NotImplemented'); + }); +}); + +describe('restoreObjectVersion', () => { + it('copies the chosen version onto the key', async () => { + s3Mock.on(CopyObjectCommand).resolves({}); + const r = await restoreObjectVersion(new S3Client({}), { bucket: 'b', key: 'dir/a b.txt', versionId: 'v1' }); + expect(r).toEqual({ ok: true, data: true }); + const input = s3Mock.commandCalls(CopyObjectCommand)[0].args[0].input; + expect(input.Bucket).toBe('b'); + expect(input.Key).toBe('dir/a b.txt'); + expect(input.CopySource).toBe('b/dir/a%20b.txt?versionId=v1'); + }); +}); + +describe('deleteObjectVersion / removeDeleteMarker', () => { + it('deleteObjectVersion sends DeleteObject with the version id', async () => { + s3Mock.on(DeleteObjectCommand).resolves({}); + const r = await deleteObjectVersion(new S3Client({}), { bucket: 'b', key: 'a.txt', versionId: 'v1' }); + expect(r).toEqual({ ok: true, data: true }); + const input = s3Mock.commandCalls(DeleteObjectCommand)[0].args[0].input; + expect(input).toMatchObject({ Bucket: 'b', Key: 'a.txt', VersionId: 'v1' }); + }); + + it('removeDeleteMarker sends DeleteObject with the marker version id', async () => { + s3Mock.on(DeleteObjectCommand).resolves({}); + const r = await removeDeleteMarker(new S3Client({}), { bucket: 'b', key: 'a.txt', versionId: 'dm1' }); + expect(r).toEqual({ ok: true, data: true }); + expect(s3Mock.commandCalls(DeleteObjectCommand)[0].args[0].input).toMatchObject({ Bucket: 'b', Key: 'a.txt', VersionId: 'dm1' }); + }); +}); diff --git a/src/main/s3/objectVersions.ts b/src/main/s3/objectVersions.ts new file mode 100644 index 0000000..cd883d9 --- /dev/null +++ b/src/main/s3/objectVersions.ts @@ -0,0 +1,110 @@ +import { S3Client, ListObjectVersionsCommand, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { ok, type Result } from '../shared/result'; +import { toErr } from './objects'; +import { encodeCopyKey } from './transfer'; + +export interface ObjectVersion { + versionId: string; + isLatest: boolean; + isDeleteMarker: boolean; + lastModified: string | null; + size: number | null; + etag: string | null; +} + +export async function listObjectVersions( + client: S3Client, + args: { bucket: string; key: string }, +): Promise> { + try { + const out: ObjectVersion[] = []; + let keyMarker: string | undefined; + let versionIdMarker: string | undefined; + do { + const res = await client.send( + new ListObjectVersionsCommand({ + Bucket: args.bucket, + Prefix: args.key, + KeyMarker: keyMarker, + VersionIdMarker: versionIdMarker, + }), + ); + for (const v of res.Versions ?? []) { + if (v.Key !== args.key || !v.VersionId) continue; + out.push({ + versionId: v.VersionId, + isLatest: v.IsLatest ?? false, + isDeleteMarker: false, + lastModified: v.LastModified ? v.LastModified.toISOString() : null, + size: v.Size ?? null, + etag: v.ETag ?? null, + }); + } + for (const d of res.DeleteMarkers ?? []) { + if (d.Key !== args.key || !d.VersionId) continue; + out.push({ + versionId: d.VersionId, + isLatest: d.IsLatest ?? false, + isDeleteMarker: true, + lastModified: d.LastModified ? d.LastModified.toISOString() : null, + size: null, + etag: null, + }); + } + keyMarker = res.IsTruncated ? res.NextKeyMarker : undefined; + versionIdMarker = res.IsTruncated ? res.NextVersionIdMarker : undefined; + } while (keyMarker || versionIdMarker); + + out.sort((a, b) => { + if (a.isLatest !== b.isLatest) return a.isLatest ? -1 : 1; + const ta = a.lastModified ? Date.parse(a.lastModified) : 0; + const tb = b.lastModified ? Date.parse(b.lastModified) : 0; + return tb - ta; + }); + return ok(out); + } catch (e) { + return toErr(e); + } +} + +export async function restoreObjectVersion( + client: S3Client, + args: { bucket: string; key: string; versionId: string }, +): Promise> { + try { + await client.send( + new CopyObjectCommand({ + Bucket: args.bucket, + CopySource: `${args.bucket}/${encodeCopyKey(args.key)}?versionId=${args.versionId}`, + Key: args.key, + }), + ); + return ok(true); + } catch (e) { + return toErr(e); + } +} + +export async function deleteObjectVersion( + client: S3Client, + args: { bucket: string; key: string; versionId: string }, +): Promise> { + try { + await client.send(new DeleteObjectCommand({ Bucket: args.bucket, Key: args.key, VersionId: args.versionId })); + return ok(true); + } catch (e) { + return toErr(e); + } +} + +export async function removeDeleteMarker( + client: S3Client, + args: { bucket: string; key: string; versionId: string }, +): Promise> { + try { + await client.send(new DeleteObjectCommand({ Bucket: args.bucket, Key: args.key, VersionId: args.versionId })); + return ok(true); + } catch (e) { + return toErr(e); + } +} diff --git a/src/main/s3/objects.ts b/src/main/s3/objects.ts index 140b7c7..34e665b 100644 --- a/src/main/s3/objects.ts +++ b/src/main/s3/objects.ts @@ -93,12 +93,12 @@ export async function headObject( export async function presignGetUrl( client: S3Client, - args: { bucket: string; key: string; expiresIn: number }, + args: { bucket: string; key: string; expiresIn: number; versionId?: string }, ): Promise> { try { const url = await getSignedUrl( client, - new GetObjectCommand({ Bucket: args.bucket, Key: args.key }), + new GetObjectCommand({ Bucket: args.bucket, Key: args.key, VersionId: args.versionId }), { expiresIn: args.expiresIn }, ); return ok(url); @@ -206,11 +206,11 @@ export async function uploadObject( export async function downloadObject( client: S3Client, - args: { bucket: string; key: string; destPath: string }, + args: { bucket: string; key: string; destPath: string; versionId?: string }, ): Promise> { try { const out = await client.send( - new GetObjectCommand({ Bucket: args.bucket, Key: args.key }), + new GetObjectCommand({ Bucket: args.bucket, Key: args.key, VersionId: args.versionId }), ); if (!out.Body) return err('EmptyBody', 'Object has no body'); await pipeline(out.Body as Readable, createWriteStream(args.destPath)); diff --git a/src/main/s3/presign.test.ts b/src/main/s3/presign.test.ts index c22a482..f8793b9 100644 --- a/src/main/s3/presign.test.ts +++ b/src/main/s3/presign.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { S3Client } from '@aws-sdk/client-s3'; +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { presignGetUrl } from './objects'; @@ -15,4 +15,16 @@ describe('presignGetUrl', () => { expect(r).toEqual({ ok: true, data: 'https://signed.example/x' }); expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 3600 }); }); + + it('includes VersionId in the signed GetObject command when provided', async () => { + await presignGetUrl(new S3Client({}), { bucket: 'b', key: 'k', expiresIn: 3600, versionId: 'v1' }); + const cmd = vi.mocked(getSignedUrl).mock.calls[0][1] as GetObjectCommand; + expect(cmd.input).toMatchObject({ Bucket: 'b', Key: 'k', VersionId: 'v1' }); + }); + + it('omits VersionId from the signed GetObject command when not provided', async () => { + await presignGetUrl(new S3Client({}), { bucket: 'b', key: 'k', expiresIn: 3600 }); + const cmd = vi.mocked(getSignedUrl).mock.calls[0][1] as GetObjectCommand; + expect(cmd.input.VersionId).toBeUndefined(); + }); }); diff --git a/src/main/s3/versioning.test.ts b/src/main/s3/versioning.test.ts new file mode 100644 index 0000000..b9b5e7e --- /dev/null +++ b/src/main/s3/versioning.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mockClient } from 'aws-sdk-client-mock'; +import { S3Client, GetBucketVersioningCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3'; +import { getBucketVersioning, putBucketVersioning } from './versioning'; + +const s3Mock = mockClient(S3Client); +beforeEach(() => s3Mock.reset()); + +describe('getBucketVersioning', () => { + it('maps Status Enabled', async () => { + s3Mock.on(GetBucketVersioningCommand).resolves({ Status: 'Enabled' }); + expect(await getBucketVersioning(new S3Client({}), 'b')).toEqual({ ok: true, data: { status: 'Enabled' } }); + }); + + it('maps Status Suspended', async () => { + s3Mock.on(GetBucketVersioningCommand).resolves({ Status: 'Suspended' }); + expect(await getBucketVersioning(new S3Client({}), 'b')).toEqual({ ok: true, data: { status: 'Suspended' } }); + }); + + it('maps a missing Status to Unversioned', async () => { + s3Mock.on(GetBucketVersioningCommand).resolves({}); + expect(await getBucketVersioning(new S3Client({}), 'b')).toEqual({ ok: true, data: { status: 'Unversioned' } }); + }); + + it('maps errors to err', async () => { + s3Mock.on(GetBucketVersioningCommand).rejects(Object.assign(new Error('no'), { name: 'AccessDenied' })); + const r = await getBucketVersioning(new S3Client({}), 'b'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.code).toBe('AccessDenied'); + }); +}); + +describe('putBucketVersioning', () => { + it('sends Status Enabled when enabled', async () => { + s3Mock.on(PutBucketVersioningCommand).resolves({}); + expect(await putBucketVersioning(new S3Client({}), 'b', true)).toEqual({ ok: true, data: true }); + expect(s3Mock.commandCalls(PutBucketVersioningCommand)[0].args[0].input.VersioningConfiguration).toEqual({ Status: 'Enabled' }); + }); + + it('sends Status Suspended when not enabled', async () => { + s3Mock.on(PutBucketVersioningCommand).resolves({}); + await putBucketVersioning(new S3Client({}), 'b', false); + expect(s3Mock.commandCalls(PutBucketVersioningCommand)[0].args[0].input.VersioningConfiguration).toEqual({ Status: 'Suspended' }); + }); +}); diff --git a/src/main/s3/versioning.ts b/src/main/s3/versioning.ts new file mode 100644 index 0000000..b50f2ee --- /dev/null +++ b/src/main/s3/versioning.ts @@ -0,0 +1,37 @@ +import { S3Client, GetBucketVersioningCommand, PutBucketVersioningCommand } from '@aws-sdk/client-s3'; +import { ok, type Result } from '../shared/result'; +import { toErr } from './objects'; + +export type VersioningStatus = 'Unversioned' | 'Enabled' | 'Suspended'; + +export async function getBucketVersioning( + client: S3Client, + bucket: string, +): Promise> { + try { + const out = await client.send(new GetBucketVersioningCommand({ Bucket: bucket })); + const status: VersioningStatus = + out.Status === 'Enabled' ? 'Enabled' : out.Status === 'Suspended' ? 'Suspended' : 'Unversioned'; + return ok({ status }); + } catch (e) { + return toErr(e); + } +} + +export async function putBucketVersioning( + client: S3Client, + bucket: string, + enabled: boolean, +): Promise> { + try { + await client.send( + new PutBucketVersioningCommand({ + Bucket: bucket, + VersioningConfiguration: { Status: enabled ? 'Enabled' : 'Suspended' }, + }), + ); + return ok(true); + } catch (e) { + return toErr(e); + } +} diff --git a/src/preload.ts b/src/preload.ts index 005903d..4a2bbc4 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -35,6 +35,12 @@ const api = { getBucketCors: (a: ApiMap[typeof CH.getBucketCors]['args'][0]) => invoke(CH.getBucketCors, a), putBucketCors: (a: ApiMap[typeof CH.putBucketCors]['args'][0]) => invoke(CH.putBucketCors, a), deleteBucketCors: (a: ApiMap[typeof CH.deleteBucketCors]['args'][0]) => invoke(CH.deleteBucketCors, a), + getBucketVersioning: (a: ApiMap[typeof CH.getBucketVersioning]['args'][0]) => invoke(CH.getBucketVersioning, a), + putBucketVersioning: (a: ApiMap[typeof CH.putBucketVersioning]['args'][0]) => invoke(CH.putBucketVersioning, a), + listObjectVersions: (a: ApiMap[typeof CH.listObjectVersions]['args'][0]) => invoke(CH.listObjectVersions, a), + restoreObjectVersion: (a: ApiMap[typeof CH.restoreObjectVersion]['args'][0]) => invoke(CH.restoreObjectVersion, a), + deleteObjectVersion: (a: ApiMap[typeof CH.deleteObjectVersion]['args'][0]) => invoke(CH.deleteObjectVersion, a), + removeDeleteMarker: (a: ApiMap[typeof CH.removeDeleteMarker]['args'][0]) => invoke(CH.removeDeleteMarker, a), getObjectLockConfig: (a: ApiMap[typeof CH.getObjectLockConfig]['args'][0]) => invoke(CH.getObjectLockConfig, a), putObjectLockConfig: (a: ApiMap[typeof CH.putObjectLockConfig]['args'][0]) => invoke(CH.putObjectLockConfig, a), getObjectRetention: (a: ApiMap[typeof CH.getObjectRetention]['args'][0]) => invoke(CH.getObjectRetention, a), diff --git a/src/renderer/App.test.tsx b/src/renderer/App.test.tsx index fce5deb..af628fd 100644 --- a/src/renderer/App.test.tsx +++ b/src/renderer/App.test.tsx @@ -22,6 +22,8 @@ beforeEach(() => { deleteBucketCors: vi.fn().mockResolvedValue({ ok: true, data: true }), getObjectLockConfig: vi.fn().mockResolvedValue({ ok: true, data: { enabled: false, defaultRetention: null } }), putObjectLockConfig: vi.fn().mockResolvedValue({ ok: true, data: true }), + getBucketVersioning: vi.fn().mockResolvedValue({ ok: true, data: { status: 'Unversioned' } }), + putBucketVersioning: vi.fn().mockResolvedValue({ ok: true, data: true }), onSyncProgress: vi.fn(() => () => {}), getSettings: vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system' } }), setSettings: vi.fn().mockResolvedValue({ ok: true, data: { presignExpirySeconds: 3600, theme: 'system' } }), @@ -103,6 +105,25 @@ describe('App — Object Lock', () => { }); }); +describe('App — Versioning', () => { + it('renders the Versioning editor for the Versioning section', async () => { + renderApp(); + await userEvent.click(screen.getByRole('button', { name: 'Versioning' })); + expect(await screen.findByRole('heading', { name: 'Versioning' })).toBeInTheDocument(); + // Versioning is a selector section: the sidebar account selector is active. + expect(screen.getByLabelText('Account')).toBeEnabled(); + }); + + it('loads the versioning status of the account/bucket chosen in the sidebar', async () => { + renderApp(); + await userEvent.click(screen.getByRole('button', { name: 'Versioning' })); + await pick('Account', 'AWS prod (Amazon S3)'); + await pick('Bucket', 'assets'); + expect(await screen.findByText('Not enabled')).toBeInTheDocument(); + expect(window.s3.getBucketVersioning).toHaveBeenCalledWith({ accountId: 'a', bucket: 'assets' }); + }); +}); + describe('App — Connections removal', () => { it('clears the selected account when it is removed in Connections', async () => { (window.s3 as unknown as { accounts: Record }).accounts.remove = vi diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2f376fb..a033622 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,6 +14,7 @@ import { ToastProvider } from './components/ui/ToastProvider'; import { Dashboard } from './components/dashboard/Dashboard'; import { CorsEditor } from './components/cors/CorsEditor'; import { ObjectLockEditor } from './components/objectlock/ObjectLockEditor'; +import { VersioningEditor } from './components/versioning/VersioningEditor'; import { SyncSection } from './components/sync/SyncSection'; import { SyncRunProvider } from './components/sync/SyncRunProvider'; import { SyncStatus } from './components/sync/SyncStatus'; @@ -21,7 +22,7 @@ import { SettingsScreen } from './components/settings/SettingsScreen'; import { StartupUpdateCheck } from './components/StartupUpdateCheck'; // Sections whose work targets the single account/bucket chosen in the sidebar. -const SELECTOR_SECTIONS: Section[] = ['files', 'cors', 'objectLock']; +const SELECTOR_SECTIONS: Section[] = ['files', 'cors', 'objectLock', 'versioning']; export function App() { const { t } = useTranslation(); @@ -140,6 +141,8 @@ export function App() { ) : section === 'objectLock' ? ( + ) : section === 'versioning' ? ( + ) : section === 'sync' ? null : section === 'settings' ? ( ) : ( diff --git a/src/renderer/components/SectionNav.test.tsx b/src/renderer/components/SectionNav.test.tsx index 47afbf1..fe52125 100644 --- a/src/renderer/components/SectionNav.test.tsx +++ b/src/renderer/components/SectionNav.test.tsx @@ -6,7 +6,7 @@ import { SectionNav, type Section } from './SectionNav'; describe('SectionNav', () => { it('renders all sections including Accounts and marks the active one', () => { render( {}} />); - for (const label of ['Dashboard', 'Files', 'Object Lock', 'CORS', 'Sync', 'Settings', 'Accounts']) { + for (const label of ['Dashboard', 'Files', 'Object Lock', 'CORS', 'Versioning', 'Sync', 'Settings', 'Accounts']) { expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); } expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page'); @@ -15,7 +15,7 @@ describe('SectionNav', () => { it('orders items Dashboard first, then S3 tools, then Settings and Accounts', () => { render( {}} />); const labels = screen.getAllByRole('button').map((b) => b.textContent); - expect(labels).toEqual(['Dashboard', 'Files', 'Object Lock', 'CORS', 'Sync', 'Settings', 'Accounts']); + expect(labels).toEqual(['Dashboard', 'Files', 'Object Lock', 'CORS', 'Versioning', 'Sync', 'Settings', 'Accounts']); }); it('renders a divider between the primary and secondary groups', () => { @@ -36,4 +36,11 @@ describe('SectionNav', () => { await userEvent.click(screen.getByRole('button', { name: 'Settings' })); expect(onSelect).toHaveBeenCalledWith('settings' satisfies Section); }); + + it('renders a Versioning nav item and selects it', async () => { + const onSelect = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Versioning' })); + expect(onSelect).toHaveBeenCalledWith('versioning' satisfies Section); + }); }); diff --git a/src/renderer/components/SectionNav.tsx b/src/renderer/components/SectionNav.tsx index 8a93b63..7e71d07 100644 --- a/src/renderer/components/SectionNav.tsx +++ b/src/renderer/components/SectionNav.tsx @@ -1,12 +1,13 @@ import { useTranslation } from 'react-i18next'; import type { IconType } from 'react-icons'; -import { FiGrid, FiFolder, FiLock, FiGlobe, FiRefreshCw, FiSettings, FiUsers } from 'react-icons/fi'; +import { FiGrid, FiFolder, FiLock, FiGlobe, FiClock, FiRefreshCw, FiSettings, FiUsers } from 'react-icons/fi'; export type Section = | 'files' | 'dashboard' | 'objectLock' | 'cors' + | 'versioning' | 'sync' | 'settings' | 'connections'; @@ -18,6 +19,7 @@ const PRIMARY: NavItem[] = [ { id: 'files', key: 'nav.files', icon: FiFolder }, { id: 'objectLock', key: 'nav.objectLock', icon: FiLock }, { id: 'cors', key: 'nav.cors', icon: FiGlobe }, + { id: 'versioning', key: 'nav.versioning', icon: FiClock }, { id: 'sync', key: 'nav.sync', icon: FiRefreshCw }, ]; diff --git a/src/renderer/components/files/MetadataPanel.test.tsx b/src/renderer/components/files/MetadataPanel.test.tsx index 4060f69..b54c4a5 100644 --- a/src/renderer/components/files/MetadataPanel.test.tsx +++ b/src/renderer/components/files/MetadataPanel.test.tsx @@ -192,3 +192,20 @@ describe('MetadataPanel edit metadata', () => { expect(await screen.findByLabelText('Content-Type')).toBeInTheDocument(); }); }); + +describe('MetadataPanel versions', () => { + it('opens the Versions dialog from the actions row', async () => { + (window as unknown as { s3: unknown }).s3 = { + headObject: vi.fn().mockResolvedValue({ ok: true, data: { size: 1, contentType: null, lastModified: null, storageClass: null, etag: null, metadata: {} } }), + objectVisibility: vi.fn().mockResolvedValue({ ok: true, data: 'private' }), + getObjectLockConfig: vi.fn().mockResolvedValue({ ok: true, data: { enabled: false, defaultRetention: null } }), + listObjectVersions: vi.fn().mockResolvedValue({ ok: true, data: [] }), + }; + wrap( {}} />); + await userEvent.click(screen.getByRole('button', { name: 'Versions' })); + expect(await screen.findByText('Versions of logo.png')).toBeInTheDocument(); + await waitFor(() => + expect(window.s3.listObjectVersions).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'assets', key: 'images/logo.png' }), + ); + }); +}); diff --git a/src/renderer/components/files/MetadataPanel.tsx b/src/renderer/components/files/MetadataPanel.tsx index 881918a..7ef54f7 100644 --- a/src/renderer/components/files/MetadataPanel.tsx +++ b/src/renderer/components/files/MetadataPanel.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FiX, FiDownload, FiLink, FiEdit3, FiMove, FiLock, FiTag, FiTrash2 } from 'react-icons/fi'; +import { FiX, FiDownload, FiLink, FiEdit3, FiMove, FiLock, FiTag, FiClock, FiTrash2 } from 'react-icons/fi'; import { useObjectDetails } from '../../hooks/useObjectDetails'; import { formatBytes, formatTimestamp } from '../../lib/format'; import { useObjectActions } from '../../hooks/useObjectActions'; @@ -14,6 +14,7 @@ import { useObjectLock } from '../../hooks/useObjectLock'; import { RetentionSection } from './RetentionSection'; import { PermissionsDialog } from './PermissionsDialog'; import { MetadataDialog } from './MetadataDialog'; +import { VersionsDialog } from './VersionsDialog'; function Row({ label, value }: { label: string; value: string }) { return ( @@ -47,6 +48,7 @@ export function MetadataPanel({ const [moving, setMoving] = useState(false); const [permissionsOpen, setPermissionsOpen] = useState(false); const [metadataOpen, setMetadataOpen] = useState(false); + const [versionsOpen, setVersionsOpen] = useState(false); return (
@@ -78,6 +80,9 @@ export function MetadataPanel({ + {!confirming && ( +
+ + {query.isLoading &&

{t('versioning.loadingVersions')}

} + {query.isError &&

{(query.error as Error).message}

} + {query.isSuccess && query.data.length === 0 && ( +

{t('versioning.noVersions')}

+ )} + + {query.isSuccess && query.data.length > 0 && ( +
    + {query.data.map((v) => ( +
  • +
    +
    + {v.versionId} + {v.isLatest && {t('versioning.latest')}} + {v.isDeleteMarker && {t('versioning.deleteMarker')}} +
    +
    + {formatTimestamp(v.lastModified)}{v.size !== null ? ` · ${formatBytes(v.size)}` : ''} +
    +
    + {v.isDeleteMarker ? ( + + ) : ( + <> + + {!v.isLatest && ( + + )} + + + )} +
  • + ))} +
+ )} + + {deletingId !== null && ( + setDeletingId(null)} + onConfirm={async () => { + const id = deletingId; + setDeletingId(null); + await run(remove.mutateAsync(id), t('versioning.deletedVersion')); + }} + /> + )} + + {removingMarkerId !== null && ( + setRemovingMarkerId(null)} + onConfirm={async () => { + const id = removingMarkerId; + setRemovingMarkerId(null); + await run(removeMarker.mutateAsync(id), t('versioning.markerRemoved')); + }} + /> + )} + + ); +} diff --git a/src/renderer/components/ui/Modal.test.tsx b/src/renderer/components/ui/Modal.test.tsx index aebdc81..8ac45b6 100644 --- a/src/renderer/components/ui/Modal.test.tsx +++ b/src/renderer/components/ui/Modal.test.tsx @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState, type ReactNode } from 'react'; +import { flushSync } from 'react-dom'; import { Modal } from './Modal'; describe('Modal', () => { @@ -30,4 +32,67 @@ describe('Modal', () => { await userEvent.click(screen.getByText('inside')); expect(onDismiss).not.toHaveBeenCalled(); }); + + describe('nested modals', () => { + function Nested({ showInner, outerDismiss, innerDismiss }: { showInner: boolean; outerDismiss: () => void; innerDismiss: () => void }): ReactNode { + return ( + + outer + {showInner && inner} + + ); + } + + it('dismisses only the top-most modal on Escape, then the next one once it is gone', async () => { + const outer = vi.fn(); + const inner = vi.fn(); + const { rerender } = render(); + + await userEvent.keyboard('{Escape}'); + expect(inner).toHaveBeenCalledTimes(1); + expect(outer).not.toHaveBeenCalled(); + + rerender(); + await userEvent.keyboard('{Escape}'); + expect(outer).toHaveBeenCalledTimes(1); + expect(inner).toHaveBeenCalledTimes(1); + }); + + it('treats a modal opened later inside an already open one as the top-most', async () => { + const outer = vi.fn(); + const inner = vi.fn(); + const { rerender } = render(); + rerender(); + + await userEvent.keyboard('{Escape}'); + expect(inner).toHaveBeenCalledTimes(1); + expect(outer).not.toHaveBeenCalled(); + }); + + it('does not dismiss the outer modal too when the inner one unmounts itself on Escape', async () => { + // The inner modal's own Escape listener unmounts it synchronously, which pops it off the stack + // before the outer modal's listener runs. The outer one must still see that Escape as handled. + const outer = vi.fn(); + function Stateful({ outerDismiss }: { outerDismiss: () => void }) { + const [showInner, setShowInner] = useState(true); + return ( + + outer + {showInner && flushSync(() => setShowInner(false))}>inner} + + ); + } + // A fresh onDismiss re-subscribes the outer listener after the inner one, so it runs second. + const { rerender } = render( outer()} />); + rerender( outer()} />); + expect(screen.getByText('inner')).toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); + expect(screen.queryByText('inner')).not.toBeInTheDocument(); + expect(outer).not.toHaveBeenCalled(); + + await userEvent.keyboard('{Escape}'); + expect(outer).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/renderer/components/ui/Modal.tsx b/src/renderer/components/ui/Modal.tsx index 9fdbfaf..983776f 100644 --- a/src/renderer/components/ui/Modal.tsx +++ b/src/renderer/components/ui/Modal.tsx @@ -1,4 +1,24 @@ -import { useEffect, type ReactNode } from 'react'; +import { useEffect, useRef, type ReactNode } from 'react'; + +type ModalRef = { current: HTMLElement | null }; + +// Open modals, bottom to top. Escape is handled by the top-most one only, so dismissing a +// nested dialog (e.g. a ConfirmDialog inside another Modal) leaves the dialog beneath it open. +const modalStack: ModalRef[] = []; + +function pushModal(ref: ModalRef) { + // Effects of a nested modal commit before its ancestor's. When both mount in the same commit, + // keep the ancestor below the modal rendered inside it. + const el = ref.current; + const i = el ? modalStack.findIndex((m) => m.current !== null && m.current !== el && el.contains(m.current)) : -1; + if (i === -1) modalStack.push(ref); + else modalStack.splice(i, 0, ref); +} + +function removeModal(ref: ModalRef) { + const i = modalStack.indexOf(ref); + if (i !== -1) modalStack.splice(i, 1); +} export function Modal({ onDismiss, @@ -9,9 +29,26 @@ export function Modal({ className?: string; children: ReactNode; }) { + const rootRef = useRef(null); + + // Registered once per mount: re-registering on every onDismiss change would move this modal + // to the top of the stack. + useEffect(() => { + const ref = rootRef; + pushModal(ref); + return () => removeModal(ref); + }, []); + useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onDismiss(); + if (e.key !== 'Escape') return; + // Every modal has its own listener and their order is not guaranteed. A modal that dismisses + // itself unmounts synchronously and leaves the stack, which would make the modal below it the + // top-most one for the next listener. Claiming the event keeps that one from reacting too. + if (e.defaultPrevented) return; + if (modalStack[modalStack.length - 1] !== rootRef) return; + e.preventDefault(); + onDismiss(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); @@ -19,6 +56,7 @@ export function Modal({ return (
+ {node} + , + ); +} + +function setS3(status: string) { + (window as unknown as { s3: unknown }).s3 = { + getBucketVersioning: vi.fn().mockResolvedValue({ ok: true, data: { status } }), + putBucketVersioning: vi.fn().mockResolvedValue({ ok: true, data: true }), + }; +} + +describe('VersioningEditor', () => { + beforeEach(() => setS3('Unversioned')); + + it('prompts to select a bucket when none chosen', () => { + wrap(); + expect(screen.getByText(/Select a bucket to view its versioning status/i)).toBeInTheDocument(); + }); + + it('enables versioning directly when not enabled', async () => { + wrap(); + expect(await screen.findByText('Not enabled')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Enable versioning' })); + expect(window.s3.putBucketVersioning).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'assets', enabled: true }); + }); + + it('suspends with confirmation when enabled', async () => { + setS3('Enabled'); + wrap(); + expect(await screen.findByText('Enabled')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Suspend versioning' })); + // The confirm dialog's button shares the same English label; it is rendered last. + await userEvent.click(screen.getAllByRole('button', { name: 'Suspend versioning' }).at(-1)!); + expect(window.s3.putBucketVersioning).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'assets', enabled: false }); + }); + + it('shows an inline error when the query fails', async () => { + (window as unknown as { s3: unknown }).s3 = { + getBucketVersioning: vi.fn().mockResolvedValue({ ok: false, error: { code: 'AccessDenied', message: 'no perms' } }), + putBucketVersioning: vi.fn(), + }; + wrap(); + expect(await screen.findByText('AccessDenied: no perms')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/versioning/VersioningEditor.tsx b/src/renderer/components/versioning/VersioningEditor.tsx new file mode 100644 index 0000000..29710c8 --- /dev/null +++ b/src/renderer/components/versioning/VersioningEditor.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useBucketVersioning } from '../../hooks/useBucketVersioning'; +import { useToast } from '../ui/ToastProvider'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; + +export function VersioningEditor({ + accountId, + bucket, +}: { + accountId: string | null; + bucket: string | null; +}) { + const { t } = useTranslation(); + const { query, setVersioning } = useBucketVersioning(accountId, bucket); + const { show } = useToast(); + const [confirmSuspend, setConfirmSuspend] = useState(false); + + const status = query.data?.status; + const isEnabled = status === 'Enabled'; + + const statusText = + status === 'Enabled' ? t('versioning.statusEnabled') + : status === 'Suspended' ? t('versioning.statusSuspended') + : t('versioning.statusUnversioned'); + + const apply = async (enabled: boolean) => { + try { + await setVersioning.mutateAsync(enabled); + show(enabled ? t('versioning.enabled') : t('versioning.suspended')); + } catch (e) { + show((e as Error).message, 'error'); + } + }; + + return ( +
+

{t('versioning.title')}

+ + {bucket === null &&

{t('versioning.selectBucket')}

} + + {bucket !== null && query.isLoading &&

{t('versioning.loading')}

} + {bucket !== null && query.isError &&

{(query.error as Error).message}

} + + {bucket !== null && query.isSuccess && ( +
+
+ {t('versioning.statusLabel')} + {statusText} +
+ +

{t('versioning.note')}

+ + {isEnabled ? ( + + ) : ( + + )} +
+ )} + + {confirmSuspend && ( + setConfirmSuspend(false)} + onConfirm={async () => { + setConfirmSuspend(false); + await apply(false); + }} + /> + )} +
+ ); +} diff --git a/src/renderer/hooks/useBucketVersioning.test.tsx b/src/renderer/hooks/useBucketVersioning.test.tsx new file mode 100644 index 0000000..1c87d1e --- /dev/null +++ b/src/renderer/hooks/useBucketVersioning.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useBucketVersioning } from './useBucketVersioning'; + +function wrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +beforeEach(() => { + (window as unknown as { s3: unknown }).s3 = { + getBucketVersioning: vi.fn().mockResolvedValue({ ok: true, data: { status: 'Enabled' } }), + putBucketVersioning: vi.fn().mockResolvedValue({ ok: true, data: true }), + }; +}); + +describe('useBucketVersioning', () => { + it('loads the status', async () => { + const { result } = renderHook(() => useBucketVersioning('acc-1', 'assets'), { wrapper: wrapper() }); + await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); + expect(result.current.query.data).toEqual({ status: 'Enabled' }); + }); + + it('is idle when bucket is null', () => { + const get = vi.fn(); + (window as unknown as { s3: unknown }).s3 = { getBucketVersioning: get }; + const { result } = renderHook(() => useBucketVersioning('acc-1', null), { wrapper: wrapper() }); + expect(result.current.query.fetchStatus).toBe('idle'); + expect(get).not.toHaveBeenCalled(); + }); + + it('setVersioning sends enabled', async () => { + const { result } = renderHook(() => useBucketVersioning('acc-1', 'assets'), { wrapper: wrapper() }); + await result.current.setVersioning.mutateAsync(false); + expect(window.s3.putBucketVersioning).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'assets', enabled: false }); + }); +}); diff --git a/src/renderer/hooks/useBucketVersioning.ts b/src/renderer/hooks/useBucketVersioning.ts new file mode 100644 index 0000000..89d5ba3 --- /dev/null +++ b/src/renderer/hooks/useBucketVersioning.ts @@ -0,0 +1,26 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { unwrap } from '../lib/result'; + +export function bucketVersioningKey(accountId: string | null, bucket: string | null) { + return ['bucketVersioning', accountId, bucket] as const; +} + +export function useBucketVersioning(accountId: string | null, bucket: string | null) { + const qc = useQueryClient(); + const enabled = accountId !== null && bucket !== null; + const invalidate = () => qc.invalidateQueries({ queryKey: bucketVersioningKey(accountId, bucket) }); + + const query = useQuery({ + queryKey: bucketVersioningKey(accountId, bucket), + enabled, + queryFn: async () => unwrap(await window.s3.getBucketVersioning({ accountId: accountId!, bucket: bucket! })), + }); + + const setVersioning = useMutation({ + mutationFn: async (nextEnabled: boolean) => + unwrap(await window.s3.putBucketVersioning({ accountId: accountId!, bucket: bucket!, enabled: nextEnabled })), + onSuccess: invalidate, + }); + + return { query, setVersioning }; +} diff --git a/src/renderer/hooks/useObjectVersions.test.tsx b/src/renderer/hooks/useObjectVersions.test.tsx new file mode 100644 index 0000000..f9fadd1 --- /dev/null +++ b/src/renderer/hooks/useObjectVersions.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useObjectVersions } from './useObjectVersions'; + +let client: QueryClient; +function wrapper() { + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +const versions = [{ versionId: 'v2', isLatest: true, isDeleteMarker: false, lastModified: '2024-03-01T00:00:00.000Z', size: 20, etag: '"e2"' }]; + +beforeEach(() => { + (window as unknown as { s3: unknown }).s3 = { + listObjectVersions: vi.fn().mockResolvedValue({ ok: true, data: versions }), + restoreObjectVersion: vi.fn().mockResolvedValue({ ok: true, data: true }), + deleteObjectVersion: vi.fn().mockResolvedValue({ ok: true, data: true }), + removeDeleteMarker: vi.fn().mockResolvedValue({ ok: true, data: true }), + }; +}); + +describe('useObjectVersions', () => { + it('loads versions', async () => { + const { result } = renderHook(() => useObjectVersions('acc-1', 'b', 'a.txt'), { wrapper: wrapper() }); + await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); + expect(result.current.query.data).toEqual(versions); + }); + + it('is idle when objectKey is null', () => { + const list = vi.fn(); + (window as unknown as { s3: unknown }).s3 = { listObjectVersions: list }; + const { result } = renderHook(() => useObjectVersions('acc-1', 'b', null), { wrapper: wrapper() }); + expect(result.current.query.fetchStatus).toBe('idle'); + expect(list).not.toHaveBeenCalled(); + }); + + it('restore/remove/removeMarker send the version id', async () => { + const { result } = renderHook(() => useObjectVersions('acc-1', 'b', 'a.txt'), { wrapper: wrapper() }); + await result.current.restore.mutateAsync('v-restore'); + await result.current.remove.mutateAsync('v-remove'); + await result.current.removeMarker.mutateAsync('v-marker'); + expect(window.s3.restoreObjectVersion).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'b', key: 'a.txt', versionId: 'v-restore' }); + expect(window.s3.restoreObjectVersion).toHaveBeenCalledTimes(1); + expect(window.s3.deleteObjectVersion).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'b', key: 'a.txt', versionId: 'v-remove' }); + expect(window.s3.deleteObjectVersion).toHaveBeenCalledTimes(1); + expect(window.s3.removeDeleteMarker).toHaveBeenCalledWith({ accountId: 'acc-1', bucket: 'b', key: 'a.txt', versionId: 'v-marker' }); + expect(window.s3.removeDeleteMarker).toHaveBeenCalledTimes(1); + }); + + it('remove mutation invalidates both versions and objects query keys', async () => { + const { result } = renderHook(() => useObjectVersions('acc-1', 'b', 'a.txt'), { wrapper: wrapper() }); + const spy = vi.spyOn(client, 'invalidateQueries'); + await result.current.remove.mutateAsync('v1'); + expect(spy).toHaveBeenCalledWith({ queryKey: ['objectVersions', 'acc-1', 'b', 'a.txt'] }); + expect(spy).toHaveBeenCalledWith({ queryKey: ['objects', 'acc-1', 'b'] }); + }); +}); diff --git a/src/renderer/hooks/useObjectVersions.ts b/src/renderer/hooks/useObjectVersions.ts new file mode 100644 index 0000000..cb95712 --- /dev/null +++ b/src/renderer/hooks/useObjectVersions.ts @@ -0,0 +1,39 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { unwrap } from '../lib/result'; + +export function objectVersionsKey(accountId: string | null, bucket: string | null, objectKey: string | null) { + return ['objectVersions', accountId, bucket, objectKey] as const; +} + +export function useObjectVersions(accountId: string | null, bucket: string | null, objectKey: string | null) { + const qc = useQueryClient(); + const enabled = accountId !== null && bucket !== null && objectKey !== null; + const invalidate = () => { + void qc.invalidateQueries({ queryKey: objectVersionsKey(accountId, bucket, objectKey) }); + void qc.invalidateQueries({ queryKey: ['objects', accountId, bucket] }); + }; + + const query = useQuery({ + queryKey: objectVersionsKey(accountId, bucket, objectKey), + enabled, + queryFn: async () => unwrap(await window.s3.listObjectVersions({ accountId: accountId!, bucket: bucket!, key: objectKey! })), + }); + + const restore = useMutation({ + mutationFn: async (versionId: string) => + unwrap(await window.s3.restoreObjectVersion({ accountId: accountId!, bucket: bucket!, key: objectKey!, versionId })), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: async (versionId: string) => + unwrap(await window.s3.deleteObjectVersion({ accountId: accountId!, bucket: bucket!, key: objectKey!, versionId })), + onSuccess: invalidate, + }); + const removeMarker = useMutation({ + mutationFn: async (versionId: string) => + unwrap(await window.s3.removeDeleteMarker({ accountId: accountId!, bucket: bucket!, key: objectKey!, versionId })), + onSuccess: invalidate, + }); + + return { query, restore, remove, removeMarker }; +} diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 856974f..6cdd2eb 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -15,6 +15,7 @@ "files": "Dateien", "objectLock": "Object Lock", "cors": "CORS", + "versioning": "Versionierung", "sync": "Sync", "dashboard": "Dashboard", "settings": "Einstellungen", @@ -134,6 +135,7 @@ "move": "Verschieben", "permissions": "Berechtigungen…", "editMetadata": "Metadaten bearbeiten…", + "versions": "Versionen", "delete": "Löschen", "deleteConfirm": "{{key}} löschen?", "makePublicConfirm": "Dieses Objekt für alle öffentlich lesbar machen?", @@ -375,6 +377,39 @@ "loadingBuckets": "Buckets werden geladen…", "loadError": "Buckets konnten nicht geladen werden" }, + "versioning": { + "title": "Versionierung", + "selectBucket": "Bucket auswählen, um den Versionierungs-Status zu sehen.", + "loading": "Versionierungs-Status wird geladen…", + "statusLabel": "Status", + "statusUnversioned": "Nicht aktiviert", + "statusEnabled": "Aktiviert", + "statusSuspended": "Ausgesetzt", + "enable": "Versionierung aktivieren", + "suspend": "Versionierung aussetzen", + "enabled": "Versionierung aktiviert", + "suspended": "Versionierung ausgesetzt", + "note": "Einmal aktiviert, kann Versionierung nur ausgesetzt, nicht vollständig entfernt werden. Aussetzen behält bestehende Versionen und stoppt das Anlegen neuer.", + "suspendConfirm": "Versionierung für diesen Bucket aussetzen? Bestehende Versionen bleiben erhalten; neue Schreibvorgänge erzeugen keine Versionen mehr.", + "suspendConfirmLabel": "Versionierung aussetzen", + "versionsTitle": "Versionen von {{name}}", + "loadingVersions": "Versionen werden geladen…", + "noVersions": "Keine Versionen gefunden. Möglicherweise ist Versionierung für diesen Bucket nicht aktiviert.", + "latest": "aktuell", + "deleteMarker": "gelöscht", + "download": "Herunterladen", + "downloaded": "Heruntergeladen", + "restore": "Wiederherstellen", + "deleteVersion": "Löschen", + "removeMarker": "Marker entfernen", + "restored": "Version wiederhergestellt", + "deletedVersion": "Version gelöscht", + "markerRemoved": "Delete-Marker entfernt", + "deleteConfirm": "Diese Version endgültig löschen? Das kann nicht rückgängig gemacht werden.", + "deleteConfirmLabel": "Version löschen", + "removeMarkerConfirm": "Delete-Marker entfernen und die vorherige Version als aktuell wiederherstellen?", + "removeMarkerConfirmLabel": "Marker entfernen" + }, "updates": { "available": "Update verfügbar: {{version}}" } diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index a118d0f..096d878 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -15,6 +15,7 @@ "files": "Files", "objectLock": "Object Lock", "cors": "CORS", + "versioning": "Versioning", "sync": "Sync", "dashboard": "Dashboard", "settings": "Settings", @@ -134,6 +135,7 @@ "move": "Move", "permissions": "Permissions…", "editMetadata": "Edit metadata…", + "versions": "Versions", "delete": "Delete", "deleteConfirm": "Delete {{key}}?", "makePublicConfirm": "Make this object publicly readable by anyone?", @@ -375,6 +377,39 @@ "loadingBuckets": "Loading buckets…", "loadError": "Couldn't load buckets" }, + "versioning": { + "title": "Versioning", + "selectBucket": "Select a bucket to view its versioning status.", + "loading": "Loading versioning status…", + "statusLabel": "Status", + "statusUnversioned": "Not enabled", + "statusEnabled": "Enabled", + "statusSuspended": "Suspended", + "enable": "Enable versioning", + "suspend": "Suspend versioning", + "enabled": "Versioning enabled", + "suspended": "Versioning suspended", + "note": "Once enabled, versioning can only be suspended, not fully removed. Suspending keeps existing versions and stops creating new ones.", + "suspendConfirm": "Suspend versioning on this bucket? Existing versions are kept; new writes stop creating versions.", + "suspendConfirmLabel": "Suspend versioning", + "versionsTitle": "Versions of {{name}}", + "loadingVersions": "Loading versions…", + "noVersions": "No versions found. Versioning may not be enabled for this bucket.", + "latest": "latest", + "deleteMarker": "deleted", + "download": "Download", + "downloaded": "Downloaded", + "restore": "Restore", + "deleteVersion": "Delete", + "removeMarker": "Remove marker", + "restored": "Version restored", + "deletedVersion": "Version deleted", + "markerRemoved": "Delete marker removed", + "deleteConfirm": "Permanently delete this version? This cannot be undone.", + "deleteConfirmLabel": "Delete version", + "removeMarkerConfirm": "Remove the delete marker and restore the previous version as current?", + "removeMarkerConfirmLabel": "Remove marker" + }, "updates": { "available": "Update available: {{version}}" } diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index ce96d56..1e4f493 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -15,6 +15,7 @@ "files": "Fichiers", "objectLock": "Object Lock", "cors": "CORS", + "versioning": "Versions", "sync": "Sync", "dashboard": "Tableau de bord", "settings": "Paramètres", @@ -134,6 +135,7 @@ "move": "Déplacer", "permissions": "Autorisations…", "editMetadata": "Modifier les métadonnées…", + "versions": "Versions", "delete": "Supprimer", "deleteConfirm": "Supprimer {{key}} ?", "makePublicConfirm": "Rendre cet objet lisible publiquement par tout le monde ?", @@ -375,6 +377,39 @@ "loadingBuckets": "Chargement des buckets…", "loadError": "Impossible de charger les buckets" }, + "versioning": { + "title": "Gestion des versions", + "selectBucket": "Sélectionnez un bucket pour voir son état de versioning.", + "loading": "Chargement de l'état de versioning…", + "statusLabel": "État", + "statusUnversioned": "Non activé", + "statusEnabled": "Activé", + "statusSuspended": "Suspendu", + "enable": "Activer le versioning", + "suspend": "Suspendre le versioning", + "enabled": "Versioning activé", + "suspended": "Versioning suspendu", + "note": "Une fois activé, le versioning peut seulement être suspendu, pas entièrement supprimé. La suspension conserve les versions existantes et arrête la création de nouvelles.", + "suspendConfirm": "Suspendre le versioning sur ce bucket ? Les versions existantes sont conservées ; les nouvelles écritures ne créent plus de versions.", + "suspendConfirmLabel": "Suspendre le versioning", + "versionsTitle": "Versions de {{name}}", + "loadingVersions": "Chargement des versions…", + "noVersions": "Aucune version trouvée. Le versioning n'est peut-être pas activé pour ce bucket.", + "latest": "actuelle", + "deleteMarker": "supprimé", + "download": "Télécharger", + "downloaded": "Téléchargé", + "restore": "Restaurer", + "deleteVersion": "Supprimer", + "removeMarker": "Retirer le marqueur", + "restored": "Version restaurée", + "deletedVersion": "Version supprimée", + "markerRemoved": "Marqueur de suppression retiré", + "deleteConfirm": "Supprimer définitivement cette version ? Cette action est irréversible.", + "deleteConfirmLabel": "Supprimer la version", + "removeMarkerConfirm": "Retirer le marqueur de suppression et restaurer la version précédente comme actuelle ?", + "removeMarkerConfirmLabel": "Retirer le marqueur" + }, "updates": { "available": "Mise à jour disponible : {{version}}" } diff --git a/src/renderer/i18n/locales/nl.json b/src/renderer/i18n/locales/nl.json index 56cf995..8fac87e 100644 --- a/src/renderer/i18n/locales/nl.json +++ b/src/renderer/i18n/locales/nl.json @@ -15,6 +15,7 @@ "files": "Bestanden", "objectLock": "Object Lock", "cors": "CORS", + "versioning": "Versies", "sync": "Sync", "dashboard": "Dashboard", "settings": "Instellingen", @@ -134,6 +135,7 @@ "move": "Verplaatsen", "permissions": "Rechten…", "editMetadata": "Metadata bewerken…", + "versions": "Versies", "delete": "Verwijderen", "deleteConfirm": "{{key}} verwijderen?", "makePublicConfirm": "Dit object voor iedereen openbaar leesbaar maken?", @@ -375,6 +377,39 @@ "loadingBuckets": "Buckets laden…", "loadError": "Kon buckets niet laden" }, + "versioning": { + "title": "Versiebeheer", + "selectBucket": "Selecteer een bucket om de versioning-status te zien.", + "loading": "Versioning-status laden…", + "statusLabel": "Status", + "statusUnversioned": "Niet ingeschakeld", + "statusEnabled": "Ingeschakeld", + "statusSuspended": "Onderbroken", + "enable": "Versioning inschakelen", + "suspend": "Versioning onderbreken", + "enabled": "Versioning ingeschakeld", + "suspended": "Versioning onderbroken", + "note": "Eenmaal ingeschakeld kan versioning alleen worden onderbroken, niet volledig verwijderd. Onderbreken behoudt bestaande versies en stopt het aanmaken van nieuwe.", + "suspendConfirm": "Versioning voor deze bucket onderbreken? Bestaande versies blijven behouden; nieuwe schrijfacties maken geen versies meer aan.", + "suspendConfirmLabel": "Versioning onderbreken", + "versionsTitle": "Versies van {{name}}", + "loadingVersions": "Versies laden…", + "noVersions": "Geen versies gevonden. Mogelijk is versioning niet ingeschakeld voor deze bucket.", + "latest": "huidig", + "deleteMarker": "verwijderd", + "download": "Downloaden", + "downloaded": "Gedownload", + "restore": "Herstellen", + "deleteVersion": "Verwijderen", + "removeMarker": "Marker verwijderen", + "restored": "Versie hersteld", + "deletedVersion": "Versie verwijderd", + "markerRemoved": "Verwijdermarkering verwijderd", + "deleteConfirm": "Deze versie definitief verwijderen? Dit kan niet ongedaan worden gemaakt.", + "deleteConfirmLabel": "Versie verwijderen", + "removeMarkerConfirm": "De verwijdermarkering verwijderen en de vorige versie als huidige herstellen?", + "removeMarkerConfirmLabel": "Marker verwijderen" + }, "updates": { "available": "Update beschikbaar: {{version}}" } diff --git a/src/renderer/i18n/locales/pl.json b/src/renderer/i18n/locales/pl.json index 3b230be..3d973d0 100644 --- a/src/renderer/i18n/locales/pl.json +++ b/src/renderer/i18n/locales/pl.json @@ -15,6 +15,7 @@ "files": "Pliki", "objectLock": "Object Lock", "cors": "CORS", + "versioning": "Wersje", "sync": "Synchronizacja", "dashboard": "Pulpit", "settings": "Ustawienia", @@ -138,6 +139,7 @@ "move": "Przenieś", "permissions": "Uprawnienia…", "editMetadata": "Edytuj metadane…", + "versions": "Wersje", "delete": "Usuń", "deleteConfirm": "Usunąć {{key}}?", "makePublicConfirm": "Udostępnić ten obiekt do publicznego odczytu dla każdego?", @@ -381,6 +383,39 @@ "loadingBuckets": "Ładowanie bucketów…", "loadError": "Nie udało się załadować bucketów" }, + "versioning": { + "title": "Wersjonowanie", + "selectBucket": "Wybierz bucket, aby zobaczyć stan wersjonowania.", + "loading": "Ładowanie stanu wersjonowania…", + "statusLabel": "Stan", + "statusUnversioned": "Nie włączono", + "statusEnabled": "Włączone", + "statusSuspended": "Wstrzymane", + "enable": "Włącz wersjonowanie", + "suspend": "Wstrzymaj wersjonowanie", + "enabled": "Wersjonowanie włączone", + "suspended": "Wersjonowanie wstrzymane", + "note": "Po włączeniu wersjonowanie można tylko wstrzymać, nie można go całkowicie usunąć. Wstrzymanie zachowuje istniejące wersje i zatrzymuje tworzenie nowych.", + "suspendConfirm": "Wstrzymać wersjonowanie dla tego bucketa? Istniejące wersje zostaną zachowane; nowe zapisy nie będą tworzyć wersji.", + "suspendConfirmLabel": "Wstrzymaj wersjonowanie", + "versionsTitle": "Wersje pliku {{name}}", + "loadingVersions": "Ładowanie wersji…", + "noVersions": "Nie znaleziono wersji. Wersjonowanie może nie być włączone dla tego bucketa.", + "latest": "aktualna", + "deleteMarker": "usunięto", + "download": "Pobierz", + "downloaded": "Pobrano", + "restore": "Przywróć", + "deleteVersion": "Usuń", + "removeMarker": "Usuń znacznik", + "restored": "Wersja przywrócona", + "deletedVersion": "Wersja usunięta", + "markerRemoved": "Znacznik usunięcia usunięty", + "deleteConfirm": "Trwale usunąć tę wersję? Tej operacji nie można cofnąć.", + "deleteConfirmLabel": "Usuń wersję", + "removeMarkerConfirm": "Usunąć znacznik usunięcia i przywrócić poprzednią wersję jako aktualną?", + "removeMarkerConfirmLabel": "Usuń znacznik" + }, "updates": { "available": "Dostępna aktualizacja: {{version}}" } diff --git a/src/renderer/i18n/locales/ro.json b/src/renderer/i18n/locales/ro.json index 8c77917..c70f835 100644 --- a/src/renderer/i18n/locales/ro.json +++ b/src/renderer/i18n/locales/ro.json @@ -15,6 +15,7 @@ "files": "Fișiere", "objectLock": "Object Lock", "cors": "CORS", + "versioning": "Versiuni", "sync": "Sync", "dashboard": "Tablou de bord", "settings": "Setări", @@ -136,6 +137,7 @@ "move": "Mută", "permissions": "Permisiuni…", "editMetadata": "Editează metadatele…", + "versions": "Versiuni", "delete": "Șterge", "deleteConfirm": "Ștergi {{key}}?", "makePublicConfirm": "Faci acest obiect accesibil public oricui pentru citire?", @@ -378,6 +380,39 @@ "loadingBuckets": "Se încarcă bucket-urile…", "loadError": "Nu s-au putut încărca bucket-urile" }, + "versioning": { + "title": "Versionare", + "selectBucket": "Selectați un bucket pentru a vedea starea versionării.", + "loading": "Se încarcă starea versionării…", + "statusLabel": "Stare", + "statusUnversioned": "Nu este activată", + "statusEnabled": "Activată", + "statusSuspended": "Suspendată", + "enable": "Activează versionarea", + "suspend": "Suspendă versionarea", + "enabled": "Versionare activată", + "suspended": "Versionare suspendată", + "note": "Odată activată, versionarea poate fi doar suspendată, nu eliminată complet. Suspendarea păstrează versiunile existente și oprește crearea altora noi.", + "suspendConfirm": "Suspendați versionarea pentru acest bucket? Versiunile existente sunt păstrate; scrierile noi nu mai creează versiuni.", + "suspendConfirmLabel": "Suspendă versionarea", + "versionsTitle": "Versiuni ale {{name}}", + "loadingVersions": "Se încarcă versiunile…", + "noVersions": "Nu s-au găsit versiuni. Este posibil ca versionarea să nu fie activată pentru acest bucket.", + "latest": "curentă", + "deleteMarker": "șters", + "download": "Descarcă", + "downloaded": "Descărcat", + "restore": "Restaurează", + "deleteVersion": "Șterge", + "removeMarker": "Elimină marcajul", + "restored": "Versiune restaurată", + "deletedVersion": "Versiune ștearsă", + "markerRemoved": "Marcaj de ștergere eliminat", + "deleteConfirm": "Ștergeți definitiv această versiune? Această acțiune nu poate fi anulată.", + "deleteConfirmLabel": "Șterge versiunea", + "removeMarkerConfirm": "Eliminați marcajul de ștergere și restaurați versiunea anterioară ca fiind curentă?", + "removeMarkerConfirmLabel": "Elimină marcajul" + }, "updates": { "available": "Actualizare disponibilă: {{version}}" }