Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions frontend-web/webclient/app/Files/FileBrowse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
favoriteRowIcon,
ResourceBrowseHeaderControls,
createProjectSwitcherPortal,
OperationGroup,
} from "@/ui-components/ResourceBrowser";
import FilesApi, {
addFileSensitivityDialog,
Expand Down Expand Up @@ -653,15 +654,46 @@ function FileBrowse({
browser.on("fetchOperations", () => {
function groupOperations<R>(ops: Operation<UFile, R>[]): OperationOrGroup<UFile, R>[] {
const result: OperationOrGroup<UFile, R>[] = [];
const restOperations: Operation<UFile, R>[] = [];
let splitButtonGroups = new Map<string, Operation<UFile, R>[]>();

// Finding split buttons and grouping them together
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);
}
});

// Creating split buttons
splitButtonGroups.forEach((operations, k) => {
result.push({
color: "secondaryMain",
icon: "ellipsis",
text: "",
iconRotation: 90,
operations: operations,
buttonStyle: "split",
})
});

let i = 0;
for (; i < ops.length && result.length < 4; i++) {
const op = ops[i];
// 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);
}

const overflow: Operation<UFile, R>[] = [];
for (; i < ops.length; i++) {
overflow.push(ops[i]);
for (; i < restOperations.length; i++) {
overflow.push(restOperations[i]);
}

if (overflow.length > 0) {
Expand Down
8 changes: 6 additions & 2 deletions frontend-web/webclient/app/UCloud/FilesApi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,9 @@ class FilesApi extends ResourceApi<UFile, ProductStorage, UFileSpecification,
return true;
},
onClick: (selected, cb) => cb.startFolderCreation!(),
shortcut: ShortcutKey.F
shortcut: ShortcutKey.F,
splitButtonGroupId: 'createOperations',
color: "secondaryMain"
},

{
Expand Down Expand Up @@ -651,7 +653,9 @@ class FilesApi extends ResourceApi<UFile, ProductStorage, UFileSpecification,
onClick: (selected, cb) => {
cb.startFileCreation!();
},
shortcut: ShortcutKey.L
shortcut: ShortcutKey.L,
splitButtonGroupId: "createOperations",
color: "secondaryDark"
},
{
icon: "trash",
Expand Down
1 change: 1 addition & 0 deletions frontend-web/webclient/app/ui-components/Operation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface Operation<T, R = undefined> {
primary?: boolean;
confirm?: boolean;
tag?: string;
splitButtonGroupId?: string
}

export function defaultOperationType(
Expand Down
77 changes: 75 additions & 2 deletions frontend-web/webclient/app/ui-components/ResourceBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Operation, ShortcutKey} from "@/ui-components/Operation";
import {Operation, OperationEnabled, ShortcutKey} from "@/ui-components/Operation";
import {IconName} from "@/ui-components/Icon";
import {
ThemeColor,
Expand Down Expand Up @@ -50,6 +50,9 @@ 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";
import ReactClient from "react-dom/client";
import {VmActionItem, VmActionSplitButton} from "@/Applications/Jobs/VmActionSplitButton";

const CLEAR_FILTER_VALUE = "\n\nCLEAR_FILTER\n\n";
const UTILITY_COLOR: ThemeColor = "textPrimary";
Expand Down Expand Up @@ -138,6 +141,8 @@ export interface OperationGroup<T, R> {
backgroundColor?: ThemeColor,
operations: Operation<T, R>[];
iconRotation?: number;
operationGroupId?: string;
buttonStyle?: string
}

export enum SelectionMode {
Expand Down Expand Up @@ -1569,6 +1574,67 @@ export class ResourceBrowser<T> {
}
}

private renderVmActionSplitButton(
opGroup: OperationGroup<T, unknown>,
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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

childOp.icon needs a fallback value here, otherwise is a compilation error.

color: childOp.color ?? "primaryMain"

}));

root.render(
<div onClick={stopPropagationAndPreventDefault}>
<VmActionSplitButton
tone="neutral"
disabled={!isEnabled}
buttonColor={mainOp.color ?? "secondaryMain"}
buttonText={getText(mainOp)}
buttonIcon={mainOp.icon ?? "ellipsis" }
menuItems={menuItems}
onSelectMenuItem={(item) => {
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);
}
}}
/>
</div>
);

return container;
}


private renderOperationsIn(useContextMenu: boolean, contextOpts?: {
x: number,
y: number,
Expand Down Expand Up @@ -1879,7 +1945,13 @@ export class ResourceBrowser<T> {
const target = this.operations;
target.innerHTML = "";
for (const op of operations) {
target.append(renderOperation(op));
if (op.buttonStyle === "split") {
Copy link
Copy Markdown
Contributor

@Tiggles Tiggles May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buttonStyle exists for OperationGroup and not Operation, so this check can fix this:

Suggested change
if (op.buttonStyle === "split") {
if ("buttonStyle" in op && op.buttonStyle === "split") {

// Rendering SplitButton
target.append(this.renderVmActionSplitButton(op, selected, callbacks, page));
}
else {
target.append(renderOperation(op));
}
}
} else {
const posX = contextOpts?.x ?? 0;
Expand Down Expand Up @@ -3868,3 +3940,4 @@ export function favoriteRowIcon(row: ResourceBrowserRow) {
}
return favoriteIcon;
}