diff --git a/apps/desktop/src/app/accounts/settings-dialog.component.html b/apps/desktop/src/app/accounts/settings-dialog.component.html index 16085b3e07eb..7d5185f55ec7 100644 --- a/apps/desktop/src/app/accounts/settings-dialog.component.html +++ b/apps/desktop/src/app/accounts/settings-dialog.component.html @@ -216,6 +216,21 @@

{{ "sessionTimeoutHeader" | i18n }}

} + @if (showQuickCopyActionsSetting()) { + + + + {{ "showQuickCopyActionsInVault" | i18n }} + + + + } + {{ "theme" | i18n }} diff --git a/apps/desktop/src/app/accounts/settings-dialog.component.spec.ts b/apps/desktop/src/app/accounts/settings-dialog.component.spec.ts index 96885ef22d9e..0a3dc7a4535a 100644 --- a/apps/desktop/src/app/accounts/settings-dialog.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings-dialog.component.spec.ts @@ -33,6 +33,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -54,6 +55,7 @@ import { BiometricStateService, BiometricsStatus, KeyService } from "@bitwarden/ import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; // eslint-disable-next-line no-restricted-imports import { SymmetricCryptoKey } from "@bitwarden/legacy-crypto"; +import { VaultCopyButtonsService } from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting"; @@ -103,6 +105,7 @@ describe("SettingsDialogComponent", () => { const billingAccountProfileStateService = mock(); const configService = mock(); const userVerificationService = mock(); + const vaultCopyButtonsService = mock(); const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; @@ -165,6 +168,7 @@ describe("SettingsDialogComponent", () => { { provide: ToastService, useValue: mock() }, { provide: DesktopAutotypeMvpService, useValue: desktopAutotypeMvpService }, { provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService }, + { provide: VaultCopyButtonsService, useValue: vaultCopyButtonsService }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -208,6 +212,7 @@ describe("SettingsDialogComponent", () => { desktopAutotypeMvpService.autotypeKeyboardShortcut$ = of(["Control", "Alt", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); configService.getFeatureFlag$.mockReturnValue(of(false)); + vaultCopyButtonsService.showQuickCopyActions$ = of(false); fixture = TestBed.createComponent(SettingsDialogComponent); component = fixture.componentInstance; @@ -950,6 +955,70 @@ describe("SettingsDialogComponent", () => { }); }); + describe("quick copy actions", () => { + /** + * `showQuickCopyActionsSetting` is a `toSignal()` initialized at class level, so the feature + * flag mock must be in place before the component is constructed. + */ + function createComponentWithFlag(enabled: boolean) { + configService.getFeatureFlag$.mockImplementation((flag) => + of(flag === FeatureFlag.PM40435_QuickCopyIconSetting ? enabled : false), + ); + + fixture = TestBed.createComponent(SettingsDialogComponent); + component = fixture.componentInstance; + } + + it("is not visible when the feature flag is disabled", async () => { + createComponentWithFlag(false); + + await component.ngOnInit(); + fixture.detectChanges(); + + const showQuickCopyActionsInput = fixture.debugElement.query( + By.css("input[formControlName='showQuickCopyActions']"), + ); + expect(showQuickCopyActionsInput).toBeNull(); + expect((component as any).showQuickCopyActionsSetting()).toBe(false); + }); + + it("is visible when the feature flag is enabled", async () => { + createComponentWithFlag(true); + + await component.ngOnInit(); + fixture.detectChanges(); + + const showQuickCopyActionsInput = fixture.debugElement.query( + By.css("input[formControlName='showQuickCopyActions']"), + ); + expect(showQuickCopyActionsInput).not.toBeNull(); + expect(showQuickCopyActionsInput.attributes).toMatchObject({ + type: "checkbox", + }); + expect((component as any).showQuickCopyActionsSetting()).toBe(true); + }); + + test.each([true, false])( + "initializes the form control from the stored setting when it is %s", + async (stored) => { + vaultCopyButtonsService.showQuickCopyActions$ = of(stored); + + await component.ngOnInit(); + + expect(component["form"].controls.showQuickCopyActions.value).toBe(stored); + }, + ); + + test.each([true, false])("saves the new value when set to %s", async (value) => { + await component.ngOnInit(); + + component["form"].controls.showQuickCopyActions.setValue(value); + await component.saveQuickCopyActions(); + + expect(vaultCopyButtonsService.setShowQuickCopyActions).toHaveBeenLastCalledWith(value); + }); + }); + describe("clearClipboard valueChanges", () => { it("saves the new clear clipboard value when changed", async () => { await component.ngOnInit(); diff --git a/apps/desktop/src/app/accounts/settings-dialog.component.ts b/apps/desktop/src/app/accounts/settings-dialog.component.ts index 35b9fd6252b9..4c5b8588561d 100644 --- a/apps/desktop/src/app/accounts/settings-dialog.component.ts +++ b/apps/desktop/src/app/accounts/settings-dialog.component.ts @@ -55,7 +55,11 @@ import { import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; import { I18nPipe } from "@bitwarden/ui-common"; -import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; +import { + PermitCipherDetailsPopoverComponent, + VaultCopyButtonsService, + ShowQuickCopyActionsDetailsPopoverComponent, +} from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; import { AutotypeShortcutComponent } from "../../autofill/components/autotype-shortcut.component"; @@ -98,6 +102,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, + ShowQuickCopyActionsDetailsPopoverComponent, ], }) export class SettingsDialogComponent implements OnInit { @@ -127,6 +132,7 @@ export class SettingsDialogComponent implements OnInit { private readonly validationService = inject(ValidationService); private readonly billingAccountProfileStateService = inject(BillingAccountProfileStateService); private readonly destroyRef = inject(DestroyRef); + private readonly vaultCopyButtonsService = inject(VaultCopyButtonsService); protected readonly localeOptions: Option[]; protected readonly themeOptions: Option[]; @@ -151,6 +157,12 @@ export class SettingsDialogComponent implements OnInit { protected readonly userHasMasterPassword = signal(false); protected readonly userHasPinSet = signal(false); + /** Controls whether the quick copy actions setting is shown */ + protected readonly showQuickCopyActionsSetting = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM40435_QuickCopyIconSetting), + { initialValue: false }, + ); + protected readonly pinEnabled = toSignal( this.accountService.activeAccount$.pipe( getUserId, @@ -174,6 +186,7 @@ export class SettingsDialogComponent implements OnInit { clearClipboard: [null], minimizeOnCopyToClipboard: false, enableFavicons: false, + showQuickCopyActions: false, // App Settings runInBackground: false, openAtLogin: false, @@ -273,6 +286,9 @@ export class SettingsDialogComponent implements OnInit { clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$), enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), + showQuickCopyActions: await firstValueFrom( + this.vaultCopyButtonsService.showQuickCopyActions$, + ), runInBackground: await firstValueFrom(this.desktopSettingsService.runInBackground$), openAtLogin: await firstValueFrom(this.desktopSettingsService.openAtLogin$), enableDuckDuckGoBrowserIntegration: await firstValueFrom( @@ -532,6 +548,12 @@ export class SettingsDialogComponent implements OnInit { this.messagingService.send("refreshCiphers"); } + async saveQuickCopyActions() { + await this.vaultCopyButtonsService.setShowQuickCopyActions( + this.form.value.showQuickCopyActions, + ); + } + protected async saveRunInBackground() { await this.desktopSettingsService.setRunInBackground(this.form.value.runInBackground); } diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html index 5d23e972d2a0..d59af6d8d926 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html @@ -46,7 +46,8 @@ @if (showOwner()) { - + + @if (showOwner()) { - + + } + - + {{ "name" | i18n }} @if (showOwner()) { @@ -48,12 +49,13 @@ bitCell bitSortable="owner" [fn]="sortByOwner" - class="tw-hidden tw-w-1/6 @md:tw-table-cell" + class="tw-hidden @xl:tw-table-cell" + [ngClass]="ownerColumnWidthClass" > {{ "owner" | i18n }} } - + {{ "options" | i18n }} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-list.component.spec.ts b/apps/desktop/src/vault/app/vault-v3/vault-list.component.spec.ts new file mode 100644 index 000000000000..d4404f4ac7c6 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.spec.ts @@ -0,0 +1,22 @@ +import { optionsColumnWidthClass, OWNER_COLUMN_WIDTH_CLASS } from "./vault-list.component"; + +describe("vault list column widths", () => { + describe("optionsColumnWidthClass", () => { + it("reserves room for an icon per copyable field when quick copy actions are shown", () => { + // launch + 3 copy icons + overflow trigger + cell padding ≈ 209px + expect(optionsColumnWidthClass(true)).toBe("tw-w-56"); + }); + + it("reserves room for a single combined copy button otherwise", () => { + // launch + 1 copy button + overflow trigger + cell padding ≈ 145px + expect(optionsColumnWidthClass(false)).toBe("tw-w-40"); + }); + }); + + describe("OWNER_COLUMN_WIDTH_CLASS", () => { + it("reserves room for the owner badge", () => { + // The badge is truncated to 13 characters, running to ~120px plus cell padding. + expect(OWNER_COLUMN_WIDTH_CLASS).toBe("tw-w-40"); + }); + }); +}); diff --git a/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts index e7fab1079c2e..8ab985521282 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts @@ -36,7 +36,12 @@ import { CheckboxModule, } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { NewCipherMenuComponent, VaultBatchBarService, VaultItem } from "@bitwarden/vault"; +import { + NewCipherMenuComponent, + VaultBatchBarService, + VaultCopyButtonsService, + VaultItem, +} from "@bitwarden/vault"; import { VaultCipherRowComponent } from "./vault-items/vault-cipher-row.component"; import { VaultCollectionRowComponent } from "./vault-items/vault-collection-row.component"; @@ -45,6 +50,35 @@ import { VaultItemEvent } from "./vault-items/vault-item-event"; // Fixed manual row height required due to how cdk-virtual-scroll works export const RowHeight = 76.5; export const RowHeightClass = `tw-h-[76.5px]`; + +/** + * Width of the Options column, sized to the widest action strip a row can draw. + * + * The strip is icon buttons, so its width doesn't scale with the window — but a fractional column + * width does. Under `table-layout: fixed` a px width holds even once the rest of the table has to + * give, whereas a fraction keeps shrinking past what the buttons need. The cell is + * `whitespace-nowrap`, so once it's too narrow the strip overflows to the right rather than + * wrapping, and the overflow menu trigger lands past the table's edge, off screen. + * + * Budget is 40px each for the launch and overflow triggers (`bitIconButton` at its default size), + * 32px per quick-copy icon (`size="small"`), ~4px per collapsed whitespace gap between them, and + * 12px of cell padding each side. Both cases round up to the next step on the spacing scale: + * - quick copy — launch + 3 copy icons + trigger: `40 + 3*32 + 40 + 2*4 + 24` ≈ 209 → `w-56`. + * - collapsed — launch + one combined copy button + trigger: `40 + 32 + 40 + 2*4 + 24` ≈ 145 → `w-40`. + */ +export const optionsColumnWidthClass = (showQuickCopyActions: boolean): string => + showQuickCopyActions ? "tw-w-56" : "tw-w-40"; + +/** + * Width of the Owner column, sized to hold its badge. + * + * The badge is a chip truncated to 13 characters, which runs to ~120px — wider than a fraction of + * a narrow table leaves it. Sized as a px column for the same reason as + * {@link optionsColumnWidthClass}: otherwise the chip overflows into the Options column and the + * action buttons render on top of it. + */ +export const OWNER_COLUMN_WIDTH_CLASS = "tw-w-40"; + type EmptyStateItem = { title: string; description: string; @@ -102,6 +136,25 @@ export class VaultListComponent { private batchBarService = inject>(VaultBatchBarService, { optional: true, }); + private vaultCopyButtonsService = inject(VaultCopyButtonsService); + + /** + * Whether copy actions render as an icon per copyable field rather than a single combined menu. + * Mirrors {@link VaultCipherRowComponent}'s own check, so the column reserves what the rows draw. + */ + private readonly showQuickCopyActions = toSignal( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM40435_QuickCopyIconSetting), + this.vaultCopyButtonsService.showQuickCopyActions$, + ]).pipe(map(([flagEnabled, settingEnabled]) => flagEnabled && settingEnabled)), + { initialValue: false }, + ); + + protected readonly optionsColumnWidthClass = computed(() => + optionsColumnWidthClass(this.showQuickCopyActions()), + ); + + protected readonly ownerColumnWidthClass = OWNER_COLUMN_WIDTH_CLASS; protected readonly showBatchBar = toSignal( combineLatest([ @@ -155,8 +208,6 @@ export class VaultListComponent { }); } - protected readonly showExtraColumn = computed(() => this.showOwner()); - protected event(event: VaultItemEvent) { this.onEvent.emit(event); }