diff --git a/.changeset/cute-clocks-crash.md b/.changeset/cute-clocks-crash.md new file mode 100644 index 000000000..fa073506a --- /dev/null +++ b/.changeset/cute-clocks-crash.md @@ -0,0 +1,5 @@ +--- +"@uppy/locales": patch +--- + +Improve zh-CN and zh-TW locale diff --git a/.changeset/every-wings-behave.md b/.changeset/every-wings-behave.md new file mode 100644 index 000000000..224287792 --- /dev/null +++ b/.changeset/every-wings-behave.md @@ -0,0 +1,5 @@ +--- +"@uppy/locales": patch +--- + +Improve Dutch locale diff --git a/.changeset/nasty-friends-win.md b/.changeset/nasty-friends-win.md new file mode 100644 index 000000000..fb9a948fa --- /dev/null +++ b/.changeset/nasty-friends-win.md @@ -0,0 +1,36 @@ +--- +"@uppy/google-photos-picker": minor +"@uppy/google-drive-picker": minor +"@uppy/thumbnail-generator": minor +"@uppy/golden-retriever": minor +"@uppy/provider-views": minor +"@uppy/remote-sources": minor +"@uppy/screen-capture": minor +"@uppy/google-drive": minor +"@uppy/image-editor": minor +"@uppy/drop-target": minor +"@uppy/transloadit": minor +"@uppy/compressor": minor +"@uppy/status-bar": minor +"@uppy/xhr-upload": minor +"@uppy/dashboard": minor +"@uppy/drag-drop": minor +"@uppy/instagram": minor +"@uppy/facebook": minor +"@uppy/onedrive": minor +"@uppy/unsplash": minor +"@uppy/dropbox": minor +"@uppy/aws-s3": minor +"@uppy/webcam": minor +"@uppy/webdav": minor +"@uppy/audio": minor +"@uppy/core": minor +"@uppy/form": minor +"@uppy/zoom": minor +"@uppy/box": minor +"@uppy/tus": minor +"@uppy/url": minor +--- + +- Add PluginTypeRegistry and typed getPlugin overload in @uppy/core +- Register plugin ids across packages so uppy.getPlugin('Dashboard' | 'Webcam') returns the concrete plugin type and removes the need to pass generics in getPlugin() \ No newline at end of file diff --git a/.changeset/polite-cougars-itch.md b/.changeset/polite-cougars-itch.md new file mode 100644 index 000000000..983456042 --- /dev/null +++ b/.changeset/polite-cougars-itch.md @@ -0,0 +1,9 @@ +--- +"@uppy/aws-s3": patch +"@uppy/core": patch +"@uppy/tus": patch +"@uppy/utils": patch +"@uppy/xhr-upload": patch +--- + +Fix: Move completed uploads exclusion logic into uploaders. This fixes the problem where postprocessors would not run for already uploaded files. diff --git a/.github/workflows/bundlers.yml b/.github/workflows/bundlers.yml index e77ab5634..f67cfd8de 100644 --- a/.github/workflows/bundlers.yml +++ b/.github/workflows/bundlers.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Get yarn cache directory path id: yarn-cache-dir-path run: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 916bc4e45..1c3b22c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: node-version: [lts/*] steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Get yarn cache directory path id: yarn-cache-dir-path run: @@ -65,7 +65,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Get yarn cache directory path id: yarn-cache-dir-path run: @@ -94,7 +94,7 @@ jobs: SKIP_YARN_COREPACK_CHECK: true steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/companion-deploy.yml b/.github/workflows/companion-deploy.yml index 04e5e86e0..99647c7e3 100644 --- a/.github/workflows/companion-deploy.yml +++ b/.github/workflows/companion-deploy.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set SHA commit in version run: (cd packages/@uppy/companion && node -e 'const @@ -46,16 +46,16 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: 0 steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Docker meta id: docker_meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: images: transloadit/companion tags: | type=edge type=raw,value=latest,enable=false - - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - uses: docker/setup-buildx-action@v3 - name: Log in to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Alter dockerfile run: | sed -i 's/^EXPOSE 3020$/EXPOSE $PORT/g' Dockerfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 24c829c93..4989905d6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,7 +36,7 @@ jobs: diff: ${{ steps.diff.outputs.OUTPUT_DIFF }} steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 2 ref: ${{ github.event.pull_request && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.sha }} diff --git a/.github/workflows/lockfile_check.yml b/.github/workflows/lockfile_check.yml index 832401a96..83cdcc7c7 100644 --- a/.github/workflows/lockfile_check.yml +++ b/.github/workflows/lockfile_check.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Get yarn cache directory path id: yarn-cache-dir-path run: diff --git a/.github/workflows/manual-cdn.yml b/.github/workflows/manual-cdn.yml index e55436edb..e3cced4a5 100644 --- a/.github/workflows/manual-cdn.yml +++ b/.github/workflows/manual-cdn.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Get yarn cache directory path id: yarn-cache-dir-path run: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8df6dbeea..c116dfe11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: published: ${{ steps.changesets.outputs.published }} steps: - name: Checkout Repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 2 @@ -90,10 +90,10 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: 0 steps: - name: Checkout sources - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Docker meta id: docker_meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 with: images: transloadit/companion tags: | @@ -101,7 +101,7 @@ jobs: type=semver,pattern={{version}},value=${{ needs.release.outputs.companionWasReleased }} # set latest tag for default branch type=raw,value=latest,enable=true - - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - uses: docker/setup-buildx-action@v3 - name: Log in to DockerHub uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 diff --git a/packages/@uppy/audio/src/Audio.tsx b/packages/@uppy/audio/src/Audio.tsx index 1ef4374f2..f998c9ef0 100644 --- a/packages/@uppy/audio/src/Audio.tsx +++ b/packages/@uppy/audio/src/Audio.tsx @@ -15,6 +15,12 @@ import PermissionsScreen from './PermissionsScreen.js' import RecordingScreen from './RecordingScreen.js' import supportsMediaRecorder from './supportsMediaRecorder.js' +declare module '@uppy/core' { + export interface PluginTypeRegistry { + Audio: Audio + } +} + export interface AudioOptions extends UIPluginOptions { showAudioSourceDropdown?: boolean locale?: LocaleStrings diff --git a/packages/@uppy/aws-s3/src/index.test.ts b/packages/@uppy/aws-s3/src/index.test.ts index aa3dc6aac..f3061f79a 100644 --- a/packages/@uppy/aws-s3/src/index.test.ts +++ b/packages/@uppy/aws-s3/src/index.test.ts @@ -9,7 +9,7 @@ import { } from 'vitest' import 'whatwg-fetch' -import Core, { type UppyFile } from '@uppy/core' +import Core, { type Meta, type UppyFile } from '@uppy/core' import nock from 'nock' import AwsS3Multipart, { type AwsBody, @@ -36,8 +36,8 @@ describe('AwsS3Multipart', () => { let opts: Partial> beforeEach(() => { - const core = new Core().use(AwsS3Multipart) - const awsS3Multipart = core.getPlugin('AwsS3Multipart') as any + const core = new Core().use(AwsS3Multipart) + const awsS3Multipart = core.getPlugin('AwsS3Multipart')! opts = awsS3Multipart.opts }) @@ -125,8 +125,8 @@ describe('AwsS3Multipart', () => { const awsS3Multipart = core.getPlugin('AwsS3Multipart')! const err = 'Expected a `endpoint` option' - const file = {} - const opts = {} + const file = {} as unknown as UppyFile> + const opts = {} as any expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err) expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err) @@ -202,11 +202,11 @@ describe('AwsS3Multipart', () => { }) describe('without companionUrl (custom main functions)', () => { - let core: Core - let awsS3Multipart: AwsS3Multipart + let core: Core + let awsS3Multipart: AwsS3Multipart beforeEach(() => { - core = new Core() + core = new Core() core.use(AwsS3Multipart, { shouldUseMultipart: true, limit: 0, @@ -226,7 +226,7 @@ describe('AwsS3Multipart', () => { }), listParts: undefined as any, }) - awsS3Multipart = core.getPlugin('AwsS3Multipart') as any + awsS3Multipart = core.getPlugin('AwsS3Multipart')! }) it('Keeps chunks marked as busy through retries until they complete', async () => { @@ -366,7 +366,6 @@ describe('AwsS3Multipart', () => { ), listParts: undefined as any, }) - const awsS3Multipart = core.getPlugin('AwsS3Multipart')! const fileSize = 5 * MB + 1 * MB core.addFile({ @@ -380,7 +379,7 @@ describe('AwsS3Multipart', () => { await core.upload() - expect(awsS3Multipart.opts.uploadPartBytes.mock.calls.length).toEqual(3) + expect(uploadPartBytes.mock.calls.length).toEqual(3) }) it('calls `upload-error` when uploadPartBytes fails after all retries', async () => { @@ -406,7 +405,6 @@ describe('AwsS3Multipart', () => { listParts: undefined as any, }) const fileSize = 5 * MB + 1 * MB - const awsS3Multipart = core.getPlugin('AwsS3Multipart')! const uploadErrorMock = vi.fn() const uploadSuccessMock = vi.fn() core.on('upload-error', uploadErrorMock) @@ -438,7 +436,7 @@ describe('AwsS3Multipart', () => { // Catch Promise.all reject } - expect(awsS3Multipart.opts.uploadPartBytes.mock.calls.length).toEqual(5) + expect(uploadPartBytes.mock.calls.length).toEqual(5) expect(uploadErrorMock.mock.calls.length).toEqual(1) expect(uploadSuccessMock.mock.calls.length).toEqual(1) // This fails for me becuase upload returned early. }) @@ -526,8 +524,8 @@ describe('AwsS3Multipart', () => { }) describe('dynamic companionHeader using setOption', () => { - let core: Core - let awsS3Multipart: AwsS3Multipart + let core: Core + let awsS3Multipart: AwsS3Multipart const newToken = 'new token' it('companionHeader is updated before uploading file', async () => { @@ -536,7 +534,7 @@ describe('AwsS3Multipart', () => { /* Set up preprocessor */ core.addPreProcessor(() => { awsS3Multipart = - core.getPlugin>('AwsS3Multipart')! + core.getPlugin>('AwsS3Multipart')! awsS3Multipart.setOptions({ endpoint: 'http://localhost', headers: { diff --git a/packages/@uppy/aws-s3/src/index.ts b/packages/@uppy/aws-s3/src/index.ts index 48b977bf9..d248f4c5b 100644 --- a/packages/@uppy/aws-s3/src/index.ts +++ b/packages/@uppy/aws-s3/src/index.ts @@ -16,7 +16,7 @@ import type { import { createAbortError, filterFilesToEmitUploadStarted, - filterNonFailedFiles, + filterFilesToUpload, getAllowedMetaFields, RateLimitedQueue, } from '@uppy/utils' @@ -45,6 +45,9 @@ declare module '@uppy/core' { export interface UppyEventMap { 's3-multipart:part-uploaded': PartUploadedCallback } + export interface PluginTypeRegistry { + AwsS3Multipart: AwsS3Multipart + } } function assertServerError(res: T): T { @@ -935,7 +938,7 @@ export default class AwsS3Multipart< if (fileIDs.length === 0) return undefined const files = this.uppy.getFilesByIds(fileIDs) - const filesFiltered = filterNonFailedFiles(files) + const filesFiltered = filterFilesToUpload(files) const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered) this.uppy.emit('upload-start', filesToEmit) diff --git a/packages/@uppy/box/src/Box.tsx b/packages/@uppy/box/src/Box.tsx index 9776530b2..590ebc3e1 100644 --- a/packages/@uppy/box/src/Box.tsx +++ b/packages/@uppy/box/src/Box.tsx @@ -109,3 +109,9 @@ export default class Box return this.view.render(state) } } + +declare module '@uppy/core' { + export interface PluginTypeRegistry { + Box: Box + } +} diff --git a/packages/@uppy/compressor/src/index.ts b/packages/@uppy/compressor/src/index.ts index 0d02edcf3..c1951e7b1 100644 --- a/packages/@uppy/compressor/src/index.ts +++ b/packages/@uppy/compressor/src/index.ts @@ -11,6 +11,9 @@ declare module '@uppy/core' { export interface UppyEventMap { 'compressor:complete': (file: UppyFile[]) => void } + export interface PluginTypeRegistry { + Compressor: Compressor + } } export interface CompressorOpts extends PluginOpts, CompressorJS.Options { diff --git a/packages/@uppy/core/src/Uppy.test.ts b/packages/@uppy/core/src/Uppy.test.ts index 758864d12..cf2a5530d 100644 --- a/packages/@uppy/core/src/Uppy.test.ts +++ b/packages/@uppy/core/src/Uppy.test.ts @@ -2609,7 +2609,7 @@ describe('src/Core', () => { expect(infoVisibleEvent.mock.calls.length).toEqual(1) }) - it('should set an info message to be displayed for a period of time before hiding', (done) => { + it('should set an info message to be displayed for a period of time before hiding', async () => { const infoVisibleEvent = vi.fn() const infoHiddenEvent = vi.fn() const core = new Core() @@ -2618,12 +2618,10 @@ describe('src/Core', () => { core.info('This is the message', 'info', 100) expect(infoHiddenEvent.mock.calls.length).toEqual(0) - setTimeout(() => { - expect(infoHiddenEvent.mock.calls.length).toEqual(1) - expect(core.getState().info).toEqual([]) - // @ts-ignore - done() - }, 110) + + await new Promise((resolve) => setTimeout(resolve, 110)) + expect(infoHiddenEvent.mock.calls.length).toEqual(1) + expect(core.getState().info).toEqual([]) }) it('should hide an info message', () => { diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index e8b245703..6f13065b2 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -142,6 +142,10 @@ export type UnknownProviderPluginState = { searchResults?: string[] | undefined } +// biome-ignore lint/suspicious/noEmptyInterface: PluginTypeRegistry is extended via module augmentation +// biome-ignore lint/correctness/noUnusedVariables: Type parameters are used in module augmentation +export interface PluginTypeRegistry {} + export interface AsyncStore { getItem: (key: string) => Promise setItem: (key: string, value: string) => Promise @@ -1941,12 +1945,21 @@ export class Uppy< /** * Find one Plugin by name. */ + + getPlugin>( + id: K, + ): PluginTypeRegistry[K] | undefined + getPlugin = UnknownPlugin>( id: string, - ): T | undefined { + ): T | undefined + + getPlugin(id: string): UnknownPlugin | undefined { for (const plugins of Object.values(this.#plugins)) { const foundPlugin = plugins.find((plugin) => plugin.id === id) - if (foundPlugin != null) return foundPlugin as T + if (foundPlugin != null) { + return foundPlugin as UnknownPlugin + } } return undefined } @@ -2220,9 +2233,6 @@ export class Uppy< ] try { for (let step = currentUpload.step || 0; step < steps.length; step++) { - if (!currentUpload) { - break - } const fn = steps[step] this.setState({ @@ -2235,13 +2245,7 @@ export class Uppy< }, }) - // when restoring (e.g. using golden retriever), we don't need to re-upload already successfully uploaded files - // so let's exclude them here: - // https://github.com/transloadit/uppy/issues/5930 - const fileIDs = currentUpload.fileIDs.filter((fileID) => { - const file = this.getFile(fileID) - return !file.progress.uploadComplete - }) + const { fileIDs } = currentUpload // TODO give this the `updatedUpload` object as its only parameter maybe? // Otherwise when more metadata may be added to the upload this would keep getting more parameters @@ -2249,6 +2253,9 @@ export class Uppy< // Update currentUpload value in case it was modified asynchronously. currentUpload = getCurrentUpload() + if (!currentUpload) { + break + } } } catch (err) { this.#removeUpload(uploadID) diff --git a/packages/@uppy/core/src/index.ts b/packages/@uppy/core/src/index.ts index 70d844346..961bdd1a5 100644 --- a/packages/@uppy/core/src/index.ts +++ b/packages/@uppy/core/src/index.ts @@ -22,6 +22,7 @@ export type { PartialTreeFolderNode, PartialTreeFolderRoot, PartialTreeId, + PluginTypeRegistry, State, UnknownPlugin, UnknownProviderPlugin, diff --git a/packages/@uppy/core/src/types.test.ts b/packages/@uppy/core/src/types.test.ts index 2c621cbf9..7e8450b81 100644 --- a/packages/@uppy/core/src/types.test.ts +++ b/packages/@uppy/core/src/types.test.ts @@ -1,5 +1,6 @@ import type { Body, InternalMetadata, LocaleStrings, Meta } from '@uppy/utils' import { expectTypeOf, test } from 'vitest' +import BasePlugin from './BasePlugin.js' import UIPlugin, { type UIPluginOptions } from './UIPlugin.js' import Uppy, { type UnknownPlugin } from './Uppy.js' @@ -68,3 +69,72 @@ test('Meta and Body generic move through the Uppy class', async () => { await core.upload() }) + +class TestRegistryPlugin extends BasePlugin< + Record, + M, + B +> { + constructor(uppy: Uppy) { + super(uppy, {}) + this.id = 'TestRegistryPlugin' + this.type = 'acquirer' + } +} + +// Augment the Type Registry with our test plugin +declare module './Uppy.js' { + interface PluginTypeRegistry { + TestRegistryPlugin: TestRegistryPlugin + } +} + +test('Type Registry: getPlugin with registered plugin name returns correct type', () => { + const uppy = new Uppy() + uppy.use(TestRegistryPlugin) + + // When using a registered plugin name, TypeScript should infer the correct type from PluginTypeRegistry + const plugin = uppy.getPlugin('TestRegistryPlugin') + + expectTypeOf(plugin).toEqualTypeOf< + TestRegistryPlugin> | undefined + >() +}) + +test('Type Registry: getPlugin with unregistered name returns UnknownPlugin', () => { + const uppy = new Uppy() + + // When using a non-registered string, should return UnknownPlugin + const plugin = uppy.getPlugin('SomeRandomPlugin') + + expectTypeOf(plugin).toEqualTypeOf< + UnknownPlugin> | undefined + >() +}) + +test('Type Registry: getPlugin with dynamic string returns UnknownPlugin', () => { + const uppy = new Uppy() + const pluginName: string = 'DynamicName' + + // Dynamic string should use the fallback overload unlike literal string + const plugin = uppy.getPlugin(pluginName) + + expectTypeOf(plugin).toEqualTypeOf< + UnknownPlugin> | undefined + >() +}) + +test('Type Registry: works with custom Meta and Body types', () => { + type CustomMeta = { userId: string; timestamp: number } + type CustomBody = { encrypted: boolean } + + // With custom Meta and Body types + const uppy = new Uppy() + uppy.use(TestRegistryPlugin) + + const plugin = uppy.getPlugin('TestRegistryPlugin') + + expectTypeOf(plugin).toEqualTypeOf< + TestRegistryPlugin | undefined + >() +}) diff --git a/packages/@uppy/dashboard/package.json b/packages/@uppy/dashboard/package.json index 711ad24ab..6e69e73dc 100644 --- a/packages/@uppy/dashboard/package.json +++ b/packages/@uppy/dashboard/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@uppy/core": "workspace:^", + "@uppy/dropbox": "workspace:^", "@uppy/google-drive": "workspace:^", "@uppy/url": "workspace:^", "@uppy/webcam": "workspace:^", diff --git a/packages/@uppy/dashboard/src/Dashboard.tsx b/packages/@uppy/dashboard/src/Dashboard.tsx index 6aeb23780..f3aa11973 100644 --- a/packages/@uppy/dashboard/src/Dashboard.tsx +++ b/packages/@uppy/dashboard/src/Dashboard.tsx @@ -40,6 +40,10 @@ declare module '@uppy/core' { 'dashboard:file-edit-complete': DashboardFileEditCompleteCallback 'dashboard:close-panel': (id: string | undefined) => void } + + export interface PluginTypeRegistry { + Dashboard: Dashboard + } } interface PromiseWithResolvers { diff --git a/packages/@uppy/dashboard/src/GlobalSearch.browser.test.ts b/packages/@uppy/dashboard/src/GlobalSearch.browser.test.ts new file mode 100644 index 000000000..16eaa00df --- /dev/null +++ b/packages/@uppy/dashboard/src/GlobalSearch.browser.test.ts @@ -0,0 +1,568 @@ +import Uppy from '@uppy/core' +import Dashboard from '@uppy/dashboard' +import Dropbox from '@uppy/dropbox' +import GoogleDrive from '@uppy/google-drive' +import { ProviderViews } from '@uppy/provider-views' +import { page, userEvent } from '@vitest/browser/context' +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' +import { worker } from './setup.js' + +import '@uppy/core/css/style.css' +import '@uppy/dashboard/css/style.css' + +let uppy: Uppy | undefined + +/** + * In Normal mode (ListItem.tsx), folders are rendered as buttons, whereas files are rendered as checkboxes with a corresponding