From baff99eb35c34282b1ebf0a1591d23ed9338b33f Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 3 Apr 2026 15:07:40 +0100 Subject: [PATCH 1/5] drag and drop files or folder to create send --- apps/browser/src/_locales/en/messages.json | 7 ++ apps/browser/src/popup/app.component.html | 9 +- apps/browser/src/popup/app.component.ts | 32 +++++ .../add-edit/send-add-edit.component.ts | 25 +++- apps/desktop/src/app/app.component.ts | 62 ++++++++- .../src/app/tools/send/pending-send.guard.ts | 15 ++- .../src/app/tools/send/send.component.ts | 43 +++++++ apps/desktop/src/locales/en/messages.json | 7 ++ apps/web/src/app/app.component.html | 18 ++- apps/web/src/app/app.component.ts | 18 ++- apps/web/src/app/app.module.ts | 3 + apps/web/src/app/tools/send/send.component.ts | 23 ++++ apps/web/src/locales/en/messages.json | 10 ++ .../abstractions/send-form-config.service.ts | 6 + .../send-details/send-details.component.ts | 5 +- .../send-file-details.component.ts | 44 +++++++ .../send-folder-details.component.ts | 49 ++++++++ .../components/send-form.component.html | 17 ++- .../components/send-form.component.ts | 51 +++++++- .../directives/drag-drop-zone.directive.ts | 118 ++++++++++++++++++ .../tools/send/send-ui/src/send-form/index.ts | 4 + .../pending-drag-drop-files.service.ts | 31 +++++ .../src/send-form/utils/drag-drop-entries.ts | 117 +++++++++++++++++ .../send-form/utils/setup-app-drag-drop.ts | 70 +++++++++++ 24 files changed, 771 insertions(+), 13 deletions(-) create mode 100644 libs/tools/send/send-ui/src/send-form/directives/drag-drop-zone.directive.ts create mode 100644 libs/tools/send/send-ui/src/send-form/services/pending-drag-drop-files.service.ts create mode 100644 libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts create mode 100644 libs/tools/send/send-ui/src/send-form/utils/setup-app-drag-drop.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index cc8b9aed2843..6aafcc1e56bc 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2994,6 +2994,13 @@ "maxFolderSize": { "message": "Maximum folder size is 500 MB." }, + "dropFilesToCreateSend": { + "message": "Drop files here to create a Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "dropFilesHere": { + "message": "Drop files here" + }, "allSends": { "message": "All Sends", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/apps/browser/src/popup/app.component.html b/apps/browser/src/popup/app.component.html index 3a5c8021e179..7846cf5ce083 100644 --- a/apps/browser/src/popup/app.component.html +++ b/apps/browser/src/popup/app.component.html @@ -15,8 +15,15 @@ } @else {
-
+
+ @if (isDragOverApp()) { +
+ {{ "dropFilesToCreateSend" | i18n }} +
+ }
diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index e4cb8a654c46..3a1453963627 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -8,6 +8,7 @@ import { NgZone, OnDestroy, OnInit, + signal, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; @@ -38,12 +39,15 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { @@ -53,6 +57,7 @@ import { ToastService, } from "@bitwarden/components"; import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management"; +import { PendingDragDropFilesService, setupAppDragDrop } from "@bitwarden/send-ui"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service"; @@ -74,6 +79,10 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn export class AppComponent implements OnInit, OnDestroy { private compactModeService = inject(PopupCompactModeService); private sdkService = inject(SdkService); + private pendingDragDropService = inject(PendingDragDropFilesService); + private configService = inject(ConfigService); + + readonly isDragOverApp = signal(false); private lastActivity: Date; private activeUserId: UserId; @@ -124,6 +133,7 @@ export class AppComponent implements OnInit, OnDestroy { async ngOnInit() { initPopupClosedListener(); + await this.setupDragDropListeners(); this.compactModeService.init(); await this.popupSizeService.setHeight(); @@ -307,6 +317,28 @@ export class AppComponent implements OnInit, OnDestroy { return outlet.activatedRouteData.elevation; } + private async setupDragDropListeners(): Promise { + const enabled = await this.configService.getFeatureFlag(FeatureFlag.SendFolder); + if (!enabled) { + return; + } + + setupAppDragDrop( + (active) => this.isDragOverApp.set(active), + (result) => { + this.pendingDragDropService.setFiles(result.files, result.folderName); + void this.router.navigate(["/add-send"], { + replaceUrl: true, + queryParams: { + type: SendType.File, + dragDropFiles: Date.now().toString(), + isFolderMode: result.folderName != null ? "true" : "false", + }, + }); + }, + ); + } + private async recordActivity() { if (this.activeUserId == null) { return; diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index f09c5f258d79..08291560b7e0 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -5,7 +5,7 @@ import { Component, inject, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; -import { map, switchMap } from "rxjs"; +import { switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -29,6 +29,7 @@ import { SendFormGenerationService, SendFormMode, SendFormModule, + PendingDragDropFilesService, } from "@bitwarden/send-ui"; import { PopupBackBrowserDirective } from "../../../../platform/popup/layout/popup-back.directive"; @@ -109,6 +110,7 @@ export class SendAddEditComponent { config: SendFormConfig; private sendFormGenerationService = inject(SendFormGenerationService); + private pendingDragDropService = inject(PendingDragDropFilesService); private readonly sendFormComponent = viewChild(SendFormComponent); constructor( @@ -189,8 +191,25 @@ export class SendAddEditComponent { this.route.queryParams .pipe( takeUntilDestroyed(), - map((params) => new QueryParams(params)), - switchMap(async (params) => { + switchMap(async (queryParams) => { + // Handle drag-and-drop files from app-level drop zone + if (queryParams["dragDropFiles"]) { + const pending = this.pendingDragDropService.takeFiles(); + if (pending != null && pending.files.length > 0) { + const config = await this.addEditFormConfigService.buildConfig( + "add", + undefined, + SendType.File, + ); + config.preloadedFiles = pending.files; + if (pending.folderName != null) { + config.isFolderMode = true; + } + return config; + } + } + + const params = new QueryParams(queryParams); let mode: SendFormMode; if (params.sendId == null) { mode = "add"; diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 41a253ae488d..bf89661ff2c0 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -7,6 +7,7 @@ import { NgZone, OnDestroy, OnInit, + signal, Type, ViewChild, ViewContainerRef, @@ -76,6 +77,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { DialogRef, DialogService, ToastOptions, ToastService } from "@bitwarden/components"; import { CredentialGeneratorHistoryDialogComponent } from "@bitwarden/generator-components"; import { KeyService, BiometricStateService } from "@bitwarden/key-management"; +import { PendingDragDropFilesService, setupAppDragDrop } from "@bitwarden/send-ui"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult } from "@bitwarden/vault"; import { DeleteAccountComponent } from "../auth/delete-account.component"; @@ -110,6 +112,27 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
+ + @if (isDragOverApp()) { +
+ {{ "dropFilesToCreateSend" | i18n }} +
+ } @@ -130,6 +153,7 @@ export class AppComponent implements OnInit, OnDestroy { showHeader$ = this.accountService.showHeader$; loading = false; + readonly isDragOverApp = signal(false); private lastActivity: Date = null; private modal: ModalRef = null; @@ -140,6 +164,7 @@ export class AppComponent implements OnInit, OnDestroy { private processingPendingAuthRequests = false; private shouldRerunAuthRequestProcessing = false; private pendingSendService = inject(PendingSendService); + private pendingDragDropFilesService = inject(PendingDragDropFilesService); private destroy$ = new Subject(); @@ -192,7 +217,9 @@ export class AppComponent implements OnInit, OnDestroy { this.destroyRef.onDestroy(() => langSubscription.unsubscribe()); } - ngOnInit() { + async ngOnInit() { + await this.setupDragDropListeners(); + this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => { this.activeUserId = account?.id; }); @@ -928,6 +955,39 @@ export class AppComponent implements OnInit, OnDestroy { }); } + private async setupDragDropListeners(): Promise { + const enabled = await this.configService.getFeatureFlag(FeatureFlag.SendFolder); + if (!enabled) { + return; + } + + setupAppDragDrop( + (active) => this.isDragOverApp.set(active), + (result) => { + this.pendingDragDropFilesService.setFiles(result.files, result.folderName); + void this.handlePendingDragDrop(); + }, + ); + } + + private async handlePendingDragDrop(): Promise { + if (!this.pendingDragDropFilesService.hasPending()) { + return; + } + + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + if (authStatus !== AuthenticationStatus.Unlocked) { + // Files stay in PendingDragDropFilesService — the vault route guard will redirect after unlock + return; + } + + await this.router.navigate(["/send"], { + queryParams: { + dragDropFiles: Date.now().toString(), + }, + }); + } + private async deleteAccount() { const userIsManaged = await firstValueFrom( this.accountService.activeAccount$.pipe( diff --git a/apps/desktop/src/app/tools/send/pending-send.guard.ts b/apps/desktop/src/app/tools/send/pending-send.guard.ts index 05ba55c562f3..9793143589c3 100644 --- a/apps/desktop/src/app/tools/send/pending-send.guard.ts +++ b/apps/desktop/src/app/tools/send/pending-send.guard.ts @@ -1,11 +1,14 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; +import { PendingDragDropFilesService } from "@bitwarden/send-ui"; + import { PendingSendService } from "./pending-send.service"; /** * Route guard that redirects from `/vault` to `/send` when there are - * pending file paths from the Windows Explorer "Create Send" context menu. + * pending file paths from the Windows Explorer "Create Send" context menu + * or pending File objects from drag-and-drop. * * On cold start, pulls any --send-path arguments from the main process * via IPC (the push-based deep link may not have arrived yet). @@ -14,6 +17,7 @@ import { PendingSendService } from "./pending-send.service"; */ export const pendingSendGuard: CanActivateFn = async () => { const pendingSendService = inject(PendingSendService); + const pendingDragDropService = inject(PendingDragDropFilesService); const router = inject(Router); // Pull any paths the main process collected from --send-path arguments. @@ -24,6 +28,15 @@ export const pendingSendGuard: CanActivateFn = async () => { pendingSendService.addPaths(mainProcessPaths); } + // Check for drag-and-drop files first (in-memory File objects) + if (pendingDragDropService.hasPending()) { + return router.createUrlTree(["/send"], { + queryParams: { + dragDropFiles: "true", + }, + }); + } + if (!pendingSendService.hasPending()) { return true; } diff --git a/apps/desktop/src/app/tools/send/send.component.ts b/apps/desktop/src/app/tools/send/send.component.ts index 43dd609fe7cc..c0f82454d1f3 100644 --- a/apps/desktop/src/app/tools/send/send.component.ts +++ b/apps/desktop/src/app/tools/send/send.component.ts @@ -27,6 +27,7 @@ import { SendAddEditDialogComponent, DefaultSendFormConfigService, SendItemDialogResult, + PendingDragDropFilesService, } from "@bitwarden/send-ui"; import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; @@ -60,6 +61,7 @@ export class SendComponent implements OnInit { private logService = inject(LogService); private destroyRef = inject(DestroyRef); private route = inject(ActivatedRoute); + private pendingDragDropService = inject(PendingDragDropFilesService); private activeDrawerRef?: DialogRef; @@ -109,6 +111,12 @@ export class SendComponent implements OnInit { async ngOnInit(): Promise { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + // Handle drag-and-drop files (in-memory File objects) + if (params["dragDropFiles"]) { + void this.openSendFromDragDropFiles(); + return; + } + const sendPathsParam = params["sendPaths"]; if (sendPathsParam) { try { @@ -123,6 +131,41 @@ export class SendComponent implements OnInit { }); } + private async openSendFromDragDropFiles(): Promise { + const pending = this.pendingDragDropService.takeFiles(); + if (pending == null || pending.files.length === 0) { + return; + } + + try { + const formConfig = await this.sendFormConfigService.buildConfig( + "add", + undefined, + SendType.File, + ); + + if (pending.folderName != null) { + formConfig.isFolderMode = true; + } + + formConfig.preloadedFiles = pending.files; + + this.activeDrawerRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { + formConfig, + }); + + await lastValueFrom(this.activeDrawerRef.closed); + this.activeDrawerRef = null; + } catch (e) { + this.logService.error("Error opening send from drag-drop files: " + e); + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + } + } + private async openSendFromPaths(filePaths: string[]): Promise { try { const pathInfos = await Promise.all( diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index c1b773742d0f..060eb728afdb 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2496,6 +2496,13 @@ "maxFolderSize": { "message": "Maximum folder size is 500 MB." }, + "dropFilesToCreateSend": { + "message": "Drop files here to create a Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "dropFilesHere": { + "message": "Drop files here" + }, "sendTypeText": { "message": "Text" }, diff --git a/apps/web/src/app/app.component.html b/apps/web/src/app/app.component.html index 638ad47fe4e1..2de16a877c74 100644 --- a/apps/web/src/app/app.component.html +++ b/apps/web/src/app/app.component.html @@ -201,7 +201,23 @@ - +
+ + @if (isDragOverApp()) { +
+ + {{ "dropFilesToCreateSend" | i18n }} + +
+ } +
diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 005b1565a2cc..a790db52797a 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, DestroyRef, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { Component, DestroyRef, NgZone, OnDestroy, OnInit, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { Subject, filter, firstValueFrom, map, timeout } from "rxjs"; @@ -16,6 +16,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EventUploadService } from "@bitwarden/common/dirt/event-logs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -29,6 +30,7 @@ import { InternalFolderService } from "@bitwarden/common/vault/abstractions/fold import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { DialogService, RouterFocusManagerService, ToastService } from "@bitwarden/components"; import { KeyService, BiometricStateService } from "@bitwarden/key-management"; +import { DragDropResult, PendingDragDropFilesService } from "@bitwarden/send-ui"; const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes @@ -47,6 +49,7 @@ export class AppComponent implements OnDestroy, OnInit { private destroy$ = new Subject(); loading = false; + readonly isDragOverApp = signal(false); constructor( private broadcasterService: BroadcasterService, @@ -77,6 +80,7 @@ export class AppComponent implements OnDestroy, OnInit { private readonly documentLangSetter: DocumentLangSetter, private readonly tokenService: TokenService, private readonly routerFocusManager: RouterFocusManagerService, + private readonly pendingDragDropService: PendingDragDropFilesService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -227,6 +231,18 @@ export class AppComponent implements OnDestroy, OnInit { this.destroy$.complete(); } + async onFilesDropped(result: DragDropResult) { + const enabled = await this.configService.getFeatureFlag(FeatureFlag.SendFolder); + if (!enabled || result.files.length === 0) { + return; + } + + this.pendingDragDropService.setFiles(result.files, result.folderName); + await this.router.navigate(["/sends"], { + queryParams: { dragDropFiles: Date.now().toString() }, + }); + } + private async logOut(redirect = true) { // Ensure the loading state is applied before proceeding to avoid a flash // of the login screen before the process reload fires. diff --git a/apps/web/src/app/app.module.ts b/apps/web/src/app/app.module.ts index ed48e4a73430..d3e93de368e6 100644 --- a/apps/web/src/app/app.module.ts +++ b/apps/web/src/app/app.module.ts @@ -4,6 +4,8 @@ import { NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { DragDropZoneDirective } from "@bitwarden/send-ui"; + import { AppComponent } from "./app.component"; import { CoreModule } from "./core"; import { OssRoutingModule } from "./oss-routing.module"; @@ -26,6 +28,7 @@ import { WildcardRoutingModule } from "./wildcard-routing.module"; LayoutModule, OssRoutingModule, WildcardRoutingModule, // Needs to be last to catch all non-existing routes + DragDropZoneDirective, ], declarations: [AppComponent], bootstrap: [AppComponent], diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index b4bd3182cc67..1918ded9ceb7 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -42,6 +42,7 @@ import { SendListComponent, SendListState, SendListFiltersService, + PendingDragDropFilesService, } from "@bitwarden/send-ui"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -134,10 +135,16 @@ export class SendComponent implements OnDestroy { private sendItemsService: SendItemsService, private sendItemsFiltersService: SendListFiltersService, private validationService: ValidationService, + private pendingDragDropService: PendingDragDropFilesService, ) { this.SendUIRefresh$ = this.configService.getFeatureFlag$(FeatureFlag.SendUIRefresh); this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => { + if (params.get("dragDropFiles")) { + void this.openSendFromDragDropFiles(); + return; + } + const typeParam = params.get("type"); let toggleValue: SendFilterType = SendFilterType.All; let sendType: SendType | null = null; @@ -159,6 +166,22 @@ export class SendComponent implements OnDestroy { this.dialogService.closeDrawer(); } + private async openSendFromDragDropFiles(): Promise { + const pending = this.pendingDragDropService.takeFiles(); + if (pending == null || pending.files.length === 0) { + return; + } + + const config = await this.addEditFormConfigService.buildConfig("add", null, SendType.File); + config.preloadedFiles = pending.files; + + if (pending.folderName != null) { + config.isFolderMode = true; + } + + await this.openSendItemDialog(config); + } + async addSend() { if (this.disableSend()) { return; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 80060c5fcc07..90e53551db71 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1335,6 +1335,9 @@ "maxFileSize": { "message": "Maximum file size is 500 MB." }, + "fileToShare": { + "message": "File to share" + }, "addedItem": { "message": "Item added" }, @@ -5767,6 +5770,13 @@ "maxFolderSize": { "message": "Maximum folder size is 500 MB." }, + "dropFilesToCreateSend": { + "message": "Drop files here to create a Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "dropFilesHere": { + "message": "Drop files here" + }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts index 9e592e351312..a55369c0ac98 100644 --- a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -53,6 +53,12 @@ type BaseSendFormConfig = { name: string; size: number; }>; + + /** + * Pre-loaded File objects from drag-and-drop. + * Uses the same shape as `zipBrowserFiles()` input — no IPC needed. + */ + preloadedFiles?: Array<{ file: File; path: string }>; }; /** diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index 56185ca68e47..5f707ab3d99d 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule, DatePipe } from "@angular/common"; -import { Component, OnInit, Input, output } from "@angular/core"; +import { Component, OnInit, Input, output, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, @@ -130,6 +130,9 @@ export class SendDetailsComponent implements OnInit { readonly openPasswordGenerator = output(); + readonly fileDetailsComponent = viewChild(SendFileDetailsComponent); + readonly folderDetailsComponent = viewChild(SendFolderDetailsComponent); + FileSendType = SendType.File; TextSendType = SendType.Text; readonly AuthType = AuthType; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index bf736cbce9d0..020885c1507d 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -16,6 +16,7 @@ import { import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; +import { DragDropResult } from "../../utils/drag-drop-entries"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -81,6 +82,32 @@ export class SendFileDetailsComponent implements OnInit { } }; + onFilesDropped(result: DragDropResult): void { + if (result.files.length === 0) { + return; + } + + if (result.files.length === 1) { + this.fileName = result.files[0].file.name; + this.sendFormContainer.onFileSelected(result.files[0].file); + } else { + const totalSize = result.files.reduce((sum, f) => sum + f.file.size, 0); + this.fileName = `${result.files.length} files, ${this.formatFileSize(totalSize)}`; + + // Create a FileList-like for the container + const dt = new DataTransfer(); + for (const f of result.files) { + dt.items.add(f.file); + } + this.sendFormContainer.onMultipleFilesSelected(dt.files); + + const placeholderView = new SendFileView(); + placeholderView.fileName = this.fileName; + placeholderView.size = String(totalSize); + this.sendFileDetailsForm.patchValue({ file: placeholderView }); + } + } + private formatFileSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; @@ -118,6 +145,23 @@ export class SendFileDetailsComponent implements OnInit { this.sendFileDetailsForm.patchValue({ file: preloadedFileView }); } + // Pre-populate from drag-and-drop preloaded files + const preloadedFiles = this.config().preloadedFiles; + if (preloadedFiles != null && preloadedFiles.length > 0) { + const totalSize = preloadedFiles.reduce((sum, f) => sum + f.file.size, 0); + + if (preloadedFiles.length === 1) { + this.fileName = preloadedFiles[0].file.name; + } else { + this.fileName = `${preloadedFiles.length} files, ${this.formatFileSize(totalSize)}`; + } + + const preloadedFileView = new SendFileView(); + preloadedFileView.fileName = this.fileName; + preloadedFileView.size = String(totalSize); + this.sendFileDetailsForm.patchValue({ file: preloadedFileView }); + } + if (!this.config().areSendsAllowed) { this.sendFileDetailsForm.disable(); } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts index a6aeb40f87f0..71bb16c2611d 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts @@ -14,6 +14,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; +import { DragDropResult } from "../../utils/drag-drop-entries"; @Component({ selector: "tools-send-folder-details", @@ -76,6 +77,40 @@ export class SendFolderDetailsComponent implements OnInit { this.sendFormContainer.onFolderSelected(files); } + onFolderDropped(result: DragDropResult): void { + if (result.files.length === 0) { + return; + } + + const totalSize = result.files.reduce((sum, f) => sum + f.file.size, 0); + + this.folderPreview.set( + this.i18nService.t( + "folderPreview", + result.files.length.toString(), + this.formatBytes(totalSize), + ), + ); + + if (totalSize > SendFolderDetailsComponent.MAX_FOLDER_SIZE_BYTES) { + this.sendFolderDetailsForm.controls.folder.setValue(null); + this.sendFolderDetailsForm.controls.folder.setErrors({ + maxSize: { message: this.i18nService.t("maxFolderSize") }, + }); + this.sendFolderDetailsForm.controls.folder.markAsTouched(); + return; + } + + this.sendFolderDetailsForm.controls.folder.setValue(this.folderPreview()); + + // Create a FileList from the dropped files for the container + const dt = new DataTransfer(); + for (const f of result.files) { + dt.items.add(f.file); + } + this.sendFormContainer.onFolderSelected(dt.files); + } + ngOnInit() { // Pre-populate from context menu path (single directory) const preloadedPaths = this.config().preloadedPaths; @@ -85,6 +120,20 @@ export class SendFolderDetailsComponent implements OnInit { this.sendFolderDetailsForm.controls.folder.setValue(preview); } + // Pre-populate from drag-and-drop preloaded files (folder mode) + const preloadedFiles = this.config().preloadedFiles; + if (preloadedFiles != null && preloadedFiles.length > 0) { + const totalSize = preloadedFiles.reduce((sum, f) => sum + f.file.size, 0); + const folderName = preloadedFiles[0].path.split("/")[0]; + const preview = this.i18nService.t( + "folderPreview", + preloadedFiles.length.toString(), + this.formatBytes(totalSize), + ); + this.folderPreview.set(preview); + this.sendFolderDetailsForm.controls.folder.setValue(folderName); + } + if (!this.config().areSendsAllowed) { this.sendFolderDetailsForm.disable(); } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.html b/libs/tools/send/send-ui/src/send-form/components/send-form.component.html index f270b3e3b43b..25193ff22120 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.html @@ -1,4 +1,12 @@ -
+ + @if (dragDropEnabled() && isDragActive() && config.sendType === SendType.File) { +
+ {{ "dropFilesHere" | i18n }} +
+ }
diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index e202f1c5865a..73d5ce327cf3 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -15,13 +15,16 @@ import { Optional, output, Output, + signal, viewChild, ViewChild, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; @@ -39,11 +42,14 @@ import { TypographyModule, } from "@bitwarden/components"; import { MakeSendMultiFileEntry } from "@bitwarden/sdk-internal"; +import { I18nPipe } from "@bitwarden/ui-common"; import { SendFileProviderService } from "../abstractions/send-file-provider.service"; import { SendFormConfig } from "../abstractions/send-form-config.service"; import { SendFormService } from "../abstractions/send-form.service"; +import { DragDropZoneDirective } from "../directives/drag-drop-zone.directive"; import { SendForm, SendFormContainer } from "../send-form-container"; +import { DragDropResult } from "../utils/drag-drop-entries"; import { SendDetailsComponent } from "./send-details/send-details.component"; @@ -67,6 +73,8 @@ import { SendDetailsComponent } from "./send-details/send-details.component"; SelectModule, NgIf, SendDetailsComponent, + DragDropZoneDirective, + I18nPipe, ], }) export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, SendFormContainer { @@ -143,6 +151,11 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send protected loading: boolean = true; SendType = SendType; + readonly isDragActive = signal(false); + protected readonly dragDropEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.SendFolder), + { initialValue: false }, + ); ngAfterViewInit(): void { if (this.submitBtn) { @@ -228,6 +241,7 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send private sdkService: SdkService, private logService: LogService, @Optional() @Inject(SendFileProviderService) private sendFileProvider: SendFileProviderService, + private configService: ConfigService, ) {} onFileSelected(file: File): void { @@ -242,6 +256,27 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send this.multipleFiles = files; } + /** + * Handle files dropped anywhere on the send form. + * Relays to the file/folder detail component that already handles drops. + */ + onFormFilesDropped(result: DragDropResult): void { + if ( + !this.dragDropEnabled() || + result.files.length === 0 || + this.config.sendType !== SendType.File + ) { + return; + } + + const details = this.sendDetailsComponent(); + if (this.config.isFolderMode) { + details?.folderDetailsComponent()?.onFolderDropped(result); + } else { + details?.fileDetailsComponent()?.onFilesDropped(result); + } + } + submit = async () => { if (this.sendForm.invalid) { this.sendForm.markAllAsTouched(); @@ -250,8 +285,18 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send let fileOrBuffer: File | ArrayBuffer = this.file; - // Handle preloaded paths from desktop context menu (single or multi-select) - if ( + // Handle pre-loaded File objects from drag-and-drop + if (this.config.preloadedFiles != null && this.config.preloadedFiles.length > 0) { + if (this.config.preloadedFiles.length === 1 && !this.config.isFolderMode) { + fileOrBuffer = this.config.preloadedFiles[0].file; + } else { + const folderName = this.config.isFolderMode + ? this.config.preloadedFiles[0].path.split("/")[0] + : "Send"; + fileOrBuffer = await this.zipBrowserFiles(this.config.preloadedFiles, folderName); + } + } else if ( + // Handle preloaded paths from desktop context menu (single or multi-select) this.config.preloadedPaths != null && this.config.preloadedPaths.length > 0 && this.sendFileProvider != null diff --git a/libs/tools/send/send-ui/src/send-form/directives/drag-drop-zone.directive.ts b/libs/tools/send/send-ui/src/send-form/directives/drag-drop-zone.directive.ts new file mode 100644 index 000000000000..35cc60ec6530 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/directives/drag-drop-zone.directive.ts @@ -0,0 +1,118 @@ +import { Directive, ElementRef, NgZone, OnDestroy, OnInit, output } from "@angular/core"; + +import { DragDropResult, readDragDropEntries } from "../utils/drag-drop-entries"; + +/** + * Attribute directive that turns any host element into a file drop target. + * + * Usage: + * ```html + *
+ * ``` + */ +@Directive({ + selector: "[toolsDragDropZone]", + standalone: true, +}) +export class DragDropZoneDirective implements OnInit, OnDestroy { + readonly filesDropped = output(); + readonly dragActive = output(); + + /** + * Counter tracks nested dragenter/dragleave pairs from child elements + * to avoid flicker when dragging over children. + */ + private enterCount = 0; + + private boundDragEnter = this.onDragEnter.bind(this); + private boundDragOver = this.onDragOver.bind(this); + private boundDragLeave = this.onDragLeave.bind(this); + private boundDrop = this.onDrop.bind(this); + + constructor( + private el: ElementRef, + private ngZone: NgZone, + ) {} + + ngOnInit(): void { + // Register outside Angular zone for performance — only re-enter for state changes + this.ngZone.runOutsideAngular(() => { + const el = this.el.nativeElement; + el.addEventListener("dragenter", this.boundDragEnter); + el.addEventListener("dragover", this.boundDragOver); + el.addEventListener("dragleave", this.boundDragLeave); + el.addEventListener("drop", this.boundDrop); + }); + } + + ngOnDestroy(): void { + const el = this.el.nativeElement; + el.removeEventListener("dragenter", this.boundDragEnter); + el.removeEventListener("dragover", this.boundDragOver); + el.removeEventListener("dragleave", this.boundDragLeave); + el.removeEventListener("drop", this.boundDrop); + } + + private onDragEnter(event: DragEvent): void { + if (!this.hasFiles(event)) { + return; + } + event.preventDefault(); + this.enterCount++; + if (this.enterCount === 1) { + this.ngZone.run(() => this.dragActive.emit(true)); + } + } + + private onDragOver(event: DragEvent): void { + if (!this.hasFiles(event)) { + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + } + + private onDragLeave(event: DragEvent): void { + if (!this.hasFiles(event)) { + return; + } + this.enterCount--; + if (this.enterCount <= 0) { + this.enterCount = 0; + this.ngZone.run(() => this.dragActive.emit(false)); + } + } + + private onDrop(event: DragEvent): void { + event.preventDefault(); + this.enterCount = 0; + this.ngZone.run(() => this.dragActive.emit(false)); + + if (!event.dataTransfer || event.dataTransfer.items.length === 0) { + return; + } + + // Only process file drops + const hasFileItems = Array.from(event.dataTransfer.items).some((item) => item.kind === "file"); + if (!hasFileItems) { + return; + } + + readDragDropEntries(event.dataTransfer) + .then((result) => { + if (result.files.length > 0) { + this.ngZone.run(() => this.filesDropped.emit(result)); + } + }) + .catch(() => { + // Silently ignore — file reading can fail if DataTransfer is cleared + }); + } + + /** Check whether the drag event contains files (as opposed to text/URLs). */ + private hasFiles(event: DragEvent): boolean { + return event.dataTransfer?.types?.includes("Files") ?? false; + } +} diff --git a/libs/tools/send/send-ui/src/send-form/index.ts b/libs/tools/send/send-ui/src/send-form/index.ts index 47eb64d10505..0fd43ce0b0f5 100644 --- a/libs/tools/send/send-ui/src/send-form/index.ts +++ b/libs/tools/send/send-ui/src/send-form/index.ts @@ -8,3 +8,7 @@ export { export { SendFormGenerationService } from "./abstractions/send-form-generation.service"; export { SendFileProviderService } from "./abstractions/send-file-provider.service"; export { DefaultSendFormConfigService } from "./services/default-send-form-config.service"; +export { PendingDragDropFilesService } from "./services/pending-drag-drop-files.service"; +export { DragDropZoneDirective } from "./directives/drag-drop-zone.directive"; +export { DragDropResult, readDragDropEntries } from "./utils/drag-drop-entries"; +export { setupAppDragDrop } from "./utils/setup-app-drag-drop"; diff --git a/libs/tools/send/send-ui/src/send-form/services/pending-drag-drop-files.service.ts b/libs/tools/send/send-ui/src/send-form/services/pending-drag-drop-files.service.ts new file mode 100644 index 000000000000..6367dc13bf81 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/services/pending-drag-drop-files.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@angular/core"; + +/** + * Holds File objects from drag-and-drop that have not yet been handed + * to the Send creation dialog. Used by app-level drop zones to pass + * dropped files to the Send page after navigation. + */ +@Injectable({ providedIn: "root" }) +export class PendingDragDropFilesService { + private files: Array<{ file: File; path: string }> | null = null; + private folderName: string | null = null; + + setFiles(files: Array<{ file: File; path: string }>, folderName: string | null): void { + this.files = files; + this.folderName = folderName; + } + + takeFiles(): { files: Array<{ file: File; path: string }>; folderName: string | null } | null { + if (this.files == null) { + return null; + } + const result = { files: this.files, folderName: this.folderName }; + this.files = null; + this.folderName = null; + return result; + } + + hasPending(): boolean { + return this.files != null && this.files.length > 0; + } +} diff --git a/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts b/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts new file mode 100644 index 000000000000..de63c19649a6 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts @@ -0,0 +1,117 @@ +/** + * Result of reading files from a drag-and-drop event. + * Shape matches the input to `SendFormComponent.zipBrowserFiles()`. + */ +export type DragDropResult = { + files: Array<{ file: File; path: string }>; + folderName: string | null; +}; + +/** + * Process a `DataTransfer` from a drop event, recursively reading directories + * via the `webkitGetAsEntry()` / `FileSystemDirectoryReader` APIs. + * + * Plain files are captured synchronously from `dataTransfer.files` to avoid + * issues with the DataTransfer being cleared after the event handler returns. + * Only directory traversal requires async operations via the entries API. + */ +export async function readDragDropEntries(dataTransfer: DataTransfer): Promise { + const files: Array<{ file: File; path: string }> = []; + let folderName: string | null = null; + + // Capture plain File objects synchronously — dataTransfer.files only + // contains non-directory entries and is available during the event handler. + const plainFiles = Array.from(dataTransfer.files); + + // Check for directories via the entries API (synchronous detection) + const items = Array.from(dataTransfer.items); + const directoryEntries: FileSystemDirectoryEntry[] = []; + + for (const item of items) { + if (item.kind !== "file") { + continue; + } + const entry = item.webkitGetAsEntry?.(); + if (entry?.isDirectory) { + directoryEntries.push(entry as FileSystemDirectoryEntry); + } + } + + // If there are directories, only add files that aren't the directory entries + // (dataTransfer.files may include a 0-byte placeholder for directories in some browsers) + if (directoryEntries.length > 0) { + const dirNames = new Set(directoryEntries.map((d) => d.name)); + for (const file of plainFiles) { + if (!dirNames.has(file.name) || file.size > 0) { + files.push({ file, path: file.name }); + } + } + + // Recursively read directory contents (async) + for (const dirEntry of directoryEntries) { + folderName = dirEntry.name; + const dirFiles = await readDirectoryEntry(dirEntry, dirEntry.name); + files.push(...dirFiles); + } + } else { + // No directories — just use the plain files + for (const file of plainFiles) { + files.push({ file, path: file.name }); + } + } + + return { files, folderName }; +} + +/** + * Recursively read all files in a directory entry. + */ +async function readDirectoryEntry( + dirEntry: FileSystemDirectoryEntry, + basePath: string, +): Promise> { + const results: Array<{ file: File; path: string }> = []; + const reader = dirEntry.createReader(); + + let batch: FileSystemEntry[]; + do { + batch = await readEntries(reader); + for (const entry of batch) { + const entryPath = `${basePath}/${entry.name}`; + if (entry.isDirectory) { + const subFiles = await readDirectoryEntry(entry as FileSystemDirectoryEntry, entryPath); + results.push(...subFiles); + } else { + const file = await fileEntryToFile(entry as FileSystemFileEntry); + results.push({ file, path: entryPath }); + } + } + } while (batch.length > 0); + + return results; +} + +/** + * Promisified wrapper around `FileSystemDirectoryReader.readEntries()`. + * Must be called repeatedly until it returns an empty array. + */ +function readEntries(reader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + reader.readEntries( + (entries) => resolve(entries), + (err) => reject(err), + ); + }); +} + +/** + * Convert a `FileSystemFileEntry` to a `File`. + */ +function fileEntryToFile(fileEntry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + fileEntry.file( + (file) => resolve(file), + (err) => reject(err), + ); + }); +} diff --git a/libs/tools/send/send-ui/src/send-form/utils/setup-app-drag-drop.ts b/libs/tools/send/send-ui/src/send-form/utils/setup-app-drag-drop.ts new file mode 100644 index 000000000000..35d14ed62570 --- /dev/null +++ b/libs/tools/send/send-ui/src/send-form/utils/setup-app-drag-drop.ts @@ -0,0 +1,70 @@ +import { DragDropResult, readDragDropEntries } from "./drag-drop-entries"; + +/** + * Registers drag-and-drop event listeners on `document.body` for app-level + * file drop support. Handles enter/leave counting to prevent flicker, + * calls `preventDefault()` on dragover to allow drops, and reads dropped + * files via the entries API. + * + * @param onDragActive Called when the drag-active state changes (true when files are being dragged over) + * @param onFilesDropped Called with the dropped files result + */ +export function setupAppDragDrop( + onDragActive: (active: boolean) => void, + onFilesDropped: (result: DragDropResult) => void, +): void { + let enterCount = 0; + const el = document.body; + + el.addEventListener("dragenter", (e: DragEvent) => { + if (!e.dataTransfer?.types?.includes("Files")) { + return; + } + e.preventDefault(); + enterCount++; + if (enterCount === 1) { + onDragActive(true); + } + }); + + el.addEventListener("dragover", (e: DragEvent) => { + if (!e.dataTransfer?.types?.includes("Files")) { + return; + } + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + } + }); + + el.addEventListener("dragleave", (e: DragEvent) => { + if (!e.dataTransfer?.types?.includes("Files")) { + return; + } + enterCount--; + if (enterCount <= 0) { + enterCount = 0; + onDragActive(false); + } + }); + + el.addEventListener("drop", (e: DragEvent) => { + e.preventDefault(); + enterCount = 0; + onDragActive(false); + + if (!e.dataTransfer || !Array.from(e.dataTransfer.items).some((i) => i.kind === "file")) { + return; + } + + readDragDropEntries(e.dataTransfer) + .then((result) => { + if (result.files.length > 0) { + onFilesDropped(result); + } + }) + .catch(() => { + // Silently ignore — file reading can fail if DataTransfer is cleared + }); + }); +} From 6f5ab0f51c10f907de1e8f8a528e209a4287646c Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 3 Apr 2026 16:57:28 +0100 Subject: [PATCH 2/5] broken folder drag and drop --- .../send/send-ui/src/send-form/utils/drag-drop-entries.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts b/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts index de63c19649a6..eb71d08a7a40 100644 --- a/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts +++ b/libs/tools/send/send-ui/src/send-form/utils/drag-drop-entries.ts @@ -42,9 +42,13 @@ export async function readDragDropEntries(dataTransfer: DataTransfer): Promise 0) { const dirNames = new Set(directoryEntries.map((d) => d.name)); for (const file of plainFiles) { - if (!dirNames.has(file.name) || file.size > 0) { - files.push({ file, path: file.name }); + // Skip any File whose name matches a detected directory — on some platforms + // (e.g. macOS) the directory placeholder has a non-zero size, so we cannot + // rely on size alone to distinguish it from a real file. + if (dirNames.has(file.name)) { + continue; } + files.push({ file, path: file.name }); } // Recursively read directory contents (async) From cb7ae7c6f64a2f1e45e288e7fabd7df17957e101 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 3 Apr 2026 17:41:43 +0100 Subject: [PATCH 3/5] broken folder drag and drop --- .../send-details/send-file-details.component.html | 10 +--------- .../send-details/send-file-details.component.ts | 10 ++++++++++ .../send-details/send-folder-details.component.ts | 7 +------ .../src/send-form/components/send-form.component.ts | 13 +++++++++++++ .../send-ui/src/send-form/send-form-container.ts | 2 ++ 5 files changed, 27 insertions(+), 15 deletions(-) diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html index 371645f5ec40..589f4ae074f7 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html @@ -19,15 +19,7 @@ {{ fileName || ("noFileChosen" | i18n) }}
- + {{ "maxFileSize" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 020885c1507d..d2bd794e7f86 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -69,6 +69,11 @@ export class SendFileDetailsComponent implements OnInit { if (files.length === 1) { this.fileName = files[0].name; this.sendFormContainer.onFileSelected(files[0]); + + const fileView = new SendFileView(); + fileView.fileName = files[0].name; + fileView.size = String(files[0].size); + this.sendFileDetailsForm.patchValue({ file: fileView }); } else { const totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0); this.fileName = `${files.length} files, ${this.formatFileSize(totalSize)}`; @@ -90,6 +95,11 @@ export class SendFileDetailsComponent implements OnInit { if (result.files.length === 1) { this.fileName = result.files[0].file.name; this.sendFormContainer.onFileSelected(result.files[0].file); + + const fileView = new SendFileView(); + fileView.fileName = result.files[0].file.name; + fileView.size = String(result.files[0].file.size); + this.sendFileDetailsForm.patchValue({ file: fileView }); } else { const totalSize = result.files.reduce((sum, f) => sum + f.file.size, 0); this.fileName = `${result.files.length} files, ${this.formatFileSize(totalSize)}`; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts index 71bb16c2611d..d4b9af06bc29 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-folder-details.component.ts @@ -103,12 +103,7 @@ export class SendFolderDetailsComponent implements OnInit { this.sendFolderDetailsForm.controls.folder.setValue(this.folderPreview()); - // Create a FileList from the dropped files for the container - const dt = new DataTransfer(); - for (const f of result.files) { - dt.items.add(f.file); - } - this.sendFormContainer.onFolderSelected(dt.files); + this.sendFormContainer.onFolderFilesDropped(result.files); } ngOnInit() { diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 73d5ce327cf3..e7fe31a76c48 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -86,6 +86,7 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send private _firstInitialized = false; private file: File | null = null; private folderFiles: FileList | null = null; + private droppedFolderFiles: Array<{ file: File; path: string }> | null = null; private multipleFiles: FileList | null = null; /** @@ -211,6 +212,7 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send this.originalSendView = null; this.file = null; this.folderFiles = null; + this.droppedFolderFiles = null; this.multipleFiles = null; this.sendForm.reset(); @@ -250,6 +252,12 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send onFolderSelected(files: FileList): void { this.folderFiles = files; + this.droppedFolderFiles = null; + } + + onFolderFilesDropped(files: Array<{ file: File; path: string }>): void { + this.droppedFolderFiles = files; + this.folderFiles = null; } onMultipleFilesSelected(files: FileList): void { @@ -308,7 +316,12 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send Array.from(this.multipleFiles).map((f) => ({ file: f, path: f.name })), "Send", ); + } else if (this.droppedFolderFiles != null && this.droppedFolderFiles.length > 0) { + // Drag-and-drop folder files — paths come from the entries API + const folderName = this.droppedFolderFiles[0].path.split("/")[0]; + fileOrBuffer = await this.zipBrowserFiles(this.droppedFolderFiles, folderName); } else if (this.folderFiles != null && this.folderFiles.length > 0) { + // Folder picker files — paths come from webkitRelativePath const firstPath = this.folderFiles[0].webkitRelativePath; const folderName = firstPath.split("/")[0]; fileOrBuffer = await this.zipBrowserFiles( diff --git a/libs/tools/send/send-ui/src/send-form/send-form-container.ts b/libs/tools/send/send-ui/src/send-form/send-form-container.ts index 01589ec6df9d..d5bd87d9ab62 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form-container.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form-container.ts @@ -51,5 +51,7 @@ export abstract class SendFormContainer { abstract onFolderSelected(files: FileList): void; + abstract onFolderFilesDropped(files: Array<{ file: File; path: string }>): void; + abstract patchSend(updateFn: (current: SendView) => SendView): void; } From ab92a5b45350e93f47653af1eba5ad2ac1022b60 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 3 Apr 2026 18:18:05 +0100 Subject: [PATCH 4/5] disable drag and drop when browser extension in popup. Needs to be in separate pop out window to work. --- apps/browser/src/popup/app.component.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 3a1453963627..684ad34346a7 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -318,6 +318,10 @@ export class AppComponent implements OnInit, OnDestroy { } private async setupDragDropListeners(): Promise { + if (BrowserPopupUtils.inPopup(window)) { + return; + } + const enabled = await this.configService.getFeatureFlag(FeatureFlag.SendFolder); if (!enabled) { return; From b210a89a69307e6ac49f72c03813f5cf0175b100 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 3 Apr 2026 19:16:13 +0100 Subject: [PATCH 5/5] pending files not being processed when locked --- apps/browser/src/popup/app.component.ts | 45 ++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 684ad34346a7..1058b037f893 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -86,6 +86,8 @@ export class AppComponent implements OnInit, OnDestroy { private lastActivity: Date; private activeUserId: UserId; + private isUnlocked = false; + private pendingDragDropFolderMode = false; private routerAnimations = false; private processingPendingAuthRequests = false; private shouldRerunAuthRequestProcessing = false; @@ -142,6 +144,10 @@ export class AppComponent implements OnInit, OnDestroy { this.activeUserId = account?.id; }); + this.authService.activeAccountStatus$.pipe(takeUntil(this.destroy$)).subscribe((status) => { + this.isUnlocked = status === AuthenticationStatus.Unlocked; + }); + this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$); this.authService.activeAccountStatus$ @@ -283,6 +289,25 @@ export class AppComponent implements OnInit, OnDestroy { this.router.events.pipe(takeUntil(this.destroy$)).subscribe(async (event) => { if (event instanceof NavigationEnd) { const url = event.urlAfterRedirects || event.url || ""; + + // After unlock, if files were dropped while locked, redirect to the send form. + // This runs after the lock screen's post-unlock navigation completes to avoid a race. + if ( + this.isUnlocked && + !url.startsWith("/add-send") && + this.pendingDragDropService.hasPending() + ) { + void this.router.navigate(["/add-send"], { + replaceUrl: true, + queryParams: { + type: SendType.File, + dragDropFiles: Date.now().toString(), + isFolderMode: this.pendingDragDropFolderMode ? "true" : "false", + }, + }); + return; + } + if (url.startsWith("/tabs/")) { await this.cipherService.setAddEditCipherInfo(null, this.activeUserId); } @@ -331,14 +356,18 @@ export class AppComponent implements OnInit, OnDestroy { (active) => this.isDragOverApp.set(active), (result) => { this.pendingDragDropService.setFiles(result.files, result.folderName); - void this.router.navigate(["/add-send"], { - replaceUrl: true, - queryParams: { - type: SendType.File, - dragDropFiles: Date.now().toString(), - isFolderMode: result.folderName != null ? "true" : "false", - }, - }); + this.pendingDragDropFolderMode = result.folderName != null; + + if (this.isUnlocked) { + void this.router.navigate(["/add-send"], { + replaceUrl: true, + queryParams: { + type: SendType.File, + dragDropFiles: Date.now().toString(), + isFolderMode: this.pendingDragDropFolderMode ? "true" : "false", + }, + }); + } }, ); }