From f4bb4dbff4a9d257a934334eb707ac0c4256f5cb Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Thu, 30 Apr 2026 13:41:30 +0200 Subject: [PATCH 1/6] wip --- .../webclient/app/Files/FileBrowse.tsx | 38 +++++++++++++++++-- .../webclient/app/UCloud/FilesApi.tsx | 8 ++-- .../webclient/app/ui-components/Operation.tsx | 1 + .../app/ui-components/ResourceBrowser.tsx | 12 +++++- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend-web/webclient/app/Files/FileBrowse.tsx b/frontend-web/webclient/app/Files/FileBrowse.tsx index 3c09b8f4f6..317c9c6e5a 100644 --- a/frontend-web/webclient/app/Files/FileBrowse.tsx +++ b/frontend-web/webclient/app/Files/FileBrowse.tsx @@ -20,6 +20,7 @@ import { favoriteRowIcon, ResourceBrowseHeaderControls, createProjectSwitcherPortal, + OperationGroup, } from "@/ui-components/ResourceBrowser"; import FilesApi, { addFileSensitivityDialog, @@ -653,15 +654,44 @@ function FileBrowse({ browser.on("fetchOperations", () => { function groupOperations(ops: Operation[]): OperationOrGroup[] { const result: OperationOrGroup[] = []; + const restOperations: Operation[] = []; + let splitButtonGroups = new Map[]>(); + + ops.forEach(op => { + if (op.splitButtonGroupId) { + if (!splitButtonGroups.has(op.splitButtonGroupId)){ + splitButtonGroups.set(op.splitButtonGroupId, [op]); + } + else { + splitButtonGroups.get(op.splitButtonGroupId)?.push(op); + } + } + else { + restOperations.push(op); + } + }); + + splitButtonGroups.forEach((v, k) => { + result.push({ + color: "secondaryMain", + icon: "ellipsis", + text: "", + iconRotation: 90, + operations: v, + buttonStyle: "split" + }) + }); + let i = 0; - for (; i < ops.length && result.length < 4; i++) { - const op = ops[i]; + for (; i < restOperations.length && result.length < 4; i++) { + const op = restOperations[i]; result.push(op); } + // too many buttons for the view, then we overflow const overflow: Operation[] = []; - for (; i < ops.length; i++) { - overflow.push(ops[i]); + for (; i < restOperations.length; i++) { + overflow.push(restOperations[i]); } if (overflow.length > 0) { diff --git a/frontend-web/webclient/app/UCloud/FilesApi.tsx b/frontend-web/webclient/app/UCloud/FilesApi.tsx index 0a5a5f8887..db0d972795 100644 --- a/frontend-web/webclient/app/UCloud/FilesApi.tsx +++ b/frontend-web/webclient/app/UCloud/FilesApi.tsx @@ -293,7 +293,7 @@ class FilesApi extends ResourceApi { @@ -312,7 +312,8 @@ class FilesApi extends ResourceApi cb.startFolderCreation!(), - shortcut: ShortcutKey.F + shortcut: ShortcutKey.F, + splitButtonGroupId: 'createOperations' }, { @@ -651,7 +652,8 @@ class FilesApi extends ResourceApi { cb.startFileCreation!(); }, - shortcut: ShortcutKey.L + shortcut: ShortcutKey.L, + splitButtonGroupId: "createOperations" }, { icon: "trash", diff --git a/frontend-web/webclient/app/ui-components/Operation.tsx b/frontend-web/webclient/app/ui-components/Operation.tsx index a5bbca1762..6ca1f4123a 100644 --- a/frontend-web/webclient/app/ui-components/Operation.tsx +++ b/frontend-web/webclient/app/ui-components/Operation.tsx @@ -73,6 +73,7 @@ export interface Operation { primary?: boolean; confirm?: boolean; tag?: string; + splitButtonGroupId?: string } export function defaultOperationType( diff --git a/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx b/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx index 2a4b7b586c..b97ac62296 100644 --- a/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx +++ b/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx @@ -50,6 +50,7 @@ import {callAPI, noopCall} from "@/Authentication/DataHook"; import {injectResourceBrowserStyle, ShortcutClass} from "./ResourceBrowserStyle"; import {ASC, DESC, Filter, FilterCheckbox, FilterInput, FilterOption, FilterWithOptions, MultiOption, MultiOptionFilter, SORT_BY, SORT_DIRECTION} from "./ResourceBrowserFilters"; import {sendInformationNotification} from "@/Notifications"; +import { UFile } from "@/UCloud/UFile"; const CLEAR_FILTER_VALUE = "\n\nCLEAR_FILTER\n\n"; const UTILITY_COLOR: ThemeColor = "textPrimary"; @@ -138,6 +139,8 @@ export interface OperationGroup { backgroundColor?: ThemeColor, operations: Operation[]; iconRotation?: number; + operationGroupId?: string; + buttonStyle?: string } export enum SelectionMode { @@ -1614,6 +1617,10 @@ export class ResourceBrowser { ("operations" in op ? "primaryMain" : "secondaryMain") ) ); + if ("operations" in op) { + // is OperationGroup + console.log("MAMAMMA ", op.operations); + } // Hack(Jonas): Very specific DriveBrowser fix, for Delete Drive coloring of Trash-icon. // The `errorContrast` is white. So kinda works for Dark Theme, not for Light Theme. @@ -1709,7 +1716,7 @@ export class ResourceBrowser { { if (operationText) { - element.append(operationText); + element.append(operationText + "Asdfasdfasf"); } if (operationText && shortcut) { const shortcutItems = shortcut.split("+"); @@ -1879,6 +1886,9 @@ export class ResourceBrowser { const target = this.operations; target.innerHTML = ""; for (const op of operations) { + if (op.buttonStyle === 'split') { + console.log('Split ', op); + } target.append(renderOperation(op)); } } else { From 14220b44ebcfe334b1c5028ad12e727ba8e895f8 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Wed, 6 May 2026 11:58:30 +0200 Subject: [PATCH 2/6] intial vmsplit button for resource browser --- .../webclient/app/Files/FileBrowse.tsx | 10 ++- .../webclient/app/UCloud/FilesApi.tsx | 8 +- .../app/ui-components/ResourceBrowser.tsx | 81 ++++++++++++++++--- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/frontend-web/webclient/app/Files/FileBrowse.tsx b/frontend-web/webclient/app/Files/FileBrowse.tsx index 317c9c6e5a..464c784233 100644 --- a/frontend-web/webclient/app/Files/FileBrowse.tsx +++ b/frontend-web/webclient/app/Files/FileBrowse.tsx @@ -657,6 +657,7 @@ function FileBrowse({ const restOperations: Operation[] = []; let splitButtonGroups = new Map[]>(); + // Finding split buttons and grouping them together ops.forEach(op => { if (op.splitButtonGroupId) { if (!splitButtonGroups.has(op.splitButtonGroupId)){ @@ -671,24 +672,25 @@ function FileBrowse({ } }); - splitButtonGroups.forEach((v, k) => { + // Creating split buttons + splitButtonGroups.forEach((operations, k) => { result.push({ color: "secondaryMain", icon: "ellipsis", text: "", iconRotation: 90, - operations: v, - buttonStyle: "split" + operations: operations, + buttonStyle: "split", }) }); let i = 0; + // A max of 4 buttons for the view else we collapse them for (; i < restOperations.length && result.length < 4; i++) { const op = restOperations[i]; result.push(op); } - // too many buttons for the view, then we overflow const overflow: Operation[] = []; for (; i < restOperations.length; i++) { overflow.push(restOperations[i]); diff --git a/frontend-web/webclient/app/UCloud/FilesApi.tsx b/frontend-web/webclient/app/UCloud/FilesApi.tsx index db0d972795..636504470c 100644 --- a/frontend-web/webclient/app/UCloud/FilesApi.tsx +++ b/frontend-web/webclient/app/UCloud/FilesApi.tsx @@ -293,7 +293,7 @@ class FilesApi extends ResourceApi { @@ -313,7 +313,8 @@ class FilesApi extends ResourceApi cb.startFolderCreation!(), shortcut: ShortcutKey.F, - splitButtonGroupId: 'createOperations' + splitButtonGroupId: 'createOperations', + color: "secondaryMain" }, { @@ -653,7 +654,8 @@ class FilesApi extends ResourceApi { } } + private renderVmActionSplitButton( + opGroup: OperationGroup, + selected: T[], + callbacks: unknown, + page: T[] + ): HTMLElement { + const container = document.createElement("div"); + container.className = "operation"; + container.style.display = "flex"; + + const root = ReactClient.createRoot(container); + + const mainOp = opGroup.operations[0]; + const rest = opGroup.operations.slice(1); + + const enableResult: OperationEnabled = mainOp.enabled(selected, callbacks, page); + const isEnabled = enableResult === true; + + + const getText = (op): string => { + return typeof op.text === "string" ? op.text : op.text(selected, callbacks); + } + + // Rest are menu items + const menuItems: VmActionItem[] = rest.map((childOp, idx) => ({ + key: idx.toString(), + value: getText(childOp), + icon: childOp.icon, + color: childOp.color ?? "primaryMain" + + })); + + root.render( +
+ { + const foundOp = rest[parseInt(item.key)]; + if (foundOp && foundOp.enabled(selected, callbacks, page) === true) { + foundOp.onClick(selected, callbacks, page); + } + }} + onButtonClick={() => { + if (isEnabled) { + console.log("Enabled", mainOp); + mainOp.onClick(selected, callbacks, page); + } + }} + /> +
+ ); + + return container; + } + + private renderOperationsIn(useContextMenu: boolean, contextOpts?: { x: number, y: number, @@ -1617,10 +1680,6 @@ export class ResourceBrowser { ("operations" in op ? "primaryMain" : "secondaryMain") ) ); - if ("operations" in op) { - // is OperationGroup - console.log("MAMAMMA ", op.operations); - } // Hack(Jonas): Very specific DriveBrowser fix, for Delete Drive coloring of Trash-icon. // The `errorContrast` is white. So kinda works for Dark Theme, not for Light Theme. @@ -1716,7 +1775,7 @@ export class ResourceBrowser { { if (operationText) { - element.append(operationText + "Asdfasdfasf"); + element.append(operationText); } if (operationText && shortcut) { const shortcutItems = shortcut.split("+"); @@ -1886,10 +1945,13 @@ export class ResourceBrowser { const target = this.operations; target.innerHTML = ""; for (const op of operations) { - if (op.buttonStyle === 'split') { - console.log('Split ', op); + if (op.buttonStyle === "split") { + // Rendering SplitButton + target.append(this.renderVmActionSplitButton(op, selected, callbacks, page)); + } + else { + target.append(renderOperation(op)); } - target.append(renderOperation(op)); } } else { const posX = contextOpts?.x ?? 0; @@ -3878,3 +3940,4 @@ export function favoriteRowIcon(row: ResourceBrowserRow) { } return favoriteIcon; } + From 2c99782e4b0ebcb51bd9ff15ab15689409cd7630 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Tue, 26 May 2026 08:40:35 +0200 Subject: [PATCH 3/6] Update frontend-web/webclient/app/ui-components/ResourceBrowser.tsx Co-authored-by: Jonas Malte Hinchely --- frontend-web/webclient/app/ui-components/ResourceBrowser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx b/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx index f66a143722..9d74b9fa1b 100644 --- a/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx +++ b/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx @@ -1945,7 +1945,7 @@ export class ResourceBrowser { const target = this.operations; target.innerHTML = ""; for (const op of operations) { - if (op.buttonStyle === "split") { + if ("buttonStyle" in op && op.buttonStyle === "split") { // Rendering SplitButton target.append(this.renderVmActionSplitButton(op, selected, callbacks, page)); } From 2981e39a1c1f27efb2f54ec9e7f2e88dc8d921b8 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Tue, 26 May 2026 08:51:29 +0200 Subject: [PATCH 4/6] added fallback icon --- frontend-web/webclient/app/ui-components/ResourceBrowser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx b/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx index 9d74b9fa1b..e38524d648 100644 --- a/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx +++ b/frontend-web/webclient/app/ui-components/ResourceBrowser.tsx @@ -1601,7 +1601,7 @@ export class ResourceBrowser { const menuItems: VmActionItem[] = rest.map((childOp, idx) => ({ key: idx.toString(), value: getText(childOp), - icon: childOp.icon, + icon: childOp.icon ?? "questionSolid", color: childOp.color ?? "primaryMain" })); From 366851c5fa66cd5b4a30d0a917ab0aa2c87bb4c6 Mon Sep 17 00:00:00 2001 From: Dan Vu Date: Tue, 26 May 2026 09:27:46 +0200 Subject: [PATCH 5/6] added none tone --- .../app/Applications/Jobs/VirtualMachines.tsx | 4 +- .../Applications/Jobs/VmActionSplitButton.tsx | 74 +++++++++++++++++-- .../app/ui-components/ResourceBrowser.tsx | 2 +- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/frontend-web/webclient/app/Applications/Jobs/VirtualMachines.tsx b/frontend-web/webclient/app/Applications/Jobs/VirtualMachines.tsx index eee6531f89..56512bc907 100644 --- a/frontend-web/webclient/app/Applications/Jobs/VirtualMachines.tsx +++ b/frontend-web/webclient/app/Applications/Jobs/VirtualMachines.tsx @@ -34,7 +34,7 @@ import {NetworkIPBrowse} from "@/Applications/NetworkIP/NetworkIPBrowse"; import {VirtualMachineRestartReminder} from "./VirtualMachineRestartReminder"; import {VirtualMachineIconButton} from "@/Applications/Jobs/VirtualMachineIconButton"; import {HeroHeaderCard, HeroHeaderGrid, HeroMetric} from "@/Applications/Jobs/HeroHeader"; -import {SplitDropdownTrigger, VmActionItem, VmActionRow, VmActionSplitButton} from "@/Applications/Jobs/VmActionSplitButton"; +import {PrimarySplitDropdownTrigger, VmActionItem, VmActionRow, VmActionSplitButton} from "@/Applications/Jobs/VmActionSplitButton"; import PublicLinkApi, {PublicLink} from "@/UCloud/PublicLinkApi"; import PrivateNetworkApi, {PrivateNetwork} from "@/UCloud/PrivateNetworkApi"; import NetworkIPApi, {NetworkIP} from "@/UCloud/NetworkIPApi"; @@ -774,7 +774,7 @@ export const VirtualMachineStatus: React.FunctionComponent<{ dropdownWidth="300px" matchTriggerWidth={false} trigger={ -
+
} diff --git a/frontend-web/webclient/app/Applications/Jobs/VmActionSplitButton.tsx b/frontend-web/webclient/app/Applications/Jobs/VmActionSplitButton.tsx index 8856c1b97a..3ebefb66a0 100644 --- a/frontend-web/webclient/app/Applications/Jobs/VmActionSplitButton.tsx +++ b/frontend-web/webclient/app/Applications/Jobs/VmActionSplitButton.tsx @@ -5,7 +5,7 @@ import {ThemeColor} from "@/ui-components/theme"; import {RichSelect, RichSelectChildComponent} from "@/ui-components/RichSelect"; import {classConcat, injectStyle} from "@/Unstyled"; -export type VmPowerTone = "success" | "warning" | "neutral"; +export type VmPowerTone = "success" | "warning" | "neutral" | "none"; export interface VmActionItem { key: string; @@ -24,7 +24,55 @@ export const VmActionRow: RichSelectChildComponent = ({element, on ; }; -export const SplitDropdownTrigger = injectStyle("split-dropdown-trigger", k => ` +function getDefaultToneLook(tone : VmPowerTone) : string { + if (tone === "none") { + return SecondarySplitDropdownTrigger; + } + return PrimarySplitDropdownTrigger; + +} + +export const SecondarySplitDropdownTrigger = injectStyle("secondary-split-dropdown-trigger", k => ` + ${k} { + position: relative; + width: 35px; + height: 35px; + border-radius: 8px; + user-select: none; + -webkit-user-select: none; + background: var(--secondaryMain); + box-shadow: inset 0 .0625em .125em rgba(10,10,10,.05); + padding: 6px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left 1px; + cursor: pointer; + } + + ${k}:hover { + background: var(--secondaryDark); + } + + ${k}[data-disabled="true"] { + opacity: 0.25; + cursor: not-allowed; + } + + ${k}[data-disabled="true"]:hover { + background: var(--secondaryMain); + } + + ${k} > svg { + color: var(--secondaryContrast); + position: absolute; + bottom: 9px; + right: 10px; + height: 16px; + } +`); + + +export const PrimarySplitDropdownTrigger = injectStyle("primary-split-dropdown-trigger", k => ` ${k} { position: relative; width: 35px; @@ -98,6 +146,22 @@ const SuccessSplitDropdownTrigger = injectStyle("success-split-dropdown-trigger" } `); +function getToneLook(tone: VmPowerTone) { + let toneClass = ""; + switch (tone) { + case "success": + toneClass = SuccessSplitDropdownTrigger; + break; + + case "warning": + toneClass = DangerSplitDropdownTrigger; + break; + default: + break; + } + return toneClass; +} + export const VmActionSplitButton: React.FunctionComponent<{ tone: VmPowerTone; disabled: boolean; @@ -120,11 +184,7 @@ export const VmActionSplitButton: React.FunctionComponent<{ dropdownWidth = "260px", }) => { const powerDropdownClass = - tone === "success" - ? classConcat(SplitDropdownTrigger, SuccessSplitDropdownTrigger) - : tone === "warning" - ? classConcat(SplitDropdownTrigger, DangerSplitDropdownTrigger) - : SplitDropdownTrigger; + classConcat(getDefaultToneLook(tone), getToneLook(tone)); return