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
6 changes: 6 additions & 0 deletions frontend/src/components/App/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,12 @@ const mdiIcons = {
'select-group': {
body: '\u003Cpath fill="currentColor" d="M5 3a2 2 0 0 0-2 2h2m2-2v2h2V3m2 0v2h2V3m2 0v2h2V3m2 0v2h2a2 2 0 0 0-2-2M3 7v2h2V7m2 0v4h4V7m2 0v4h4V7m2 0v2h2V7M3 11v2h2v-2m14 0v2h2v-2M7 13v4h4v-4m2 0v4h4v-4M3 15v2h2v-2m14 0v2h2v-2M3 19a2 2 0 0 0 2 2v-2m2 0v2h2v-2m2 0v2h2v-2m2 0v2h2v-2m2 0v2a2 2 0 0 0 2-2Z"/\u003E',
},
pin: {
body: '\u003Cpath fill="currentColor" d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2z"/\u003E',
},
'pin-outline': {
body: '\u003Cpath fill="currentColor" d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2zm-6 2H8v-.5l2-2V4h4v7.5l2 2v.5z"/\u003E',
},
},
aliases: {
'more-vert': {
Expand Down
165 changes: 165 additions & 0 deletions frontend/src/components/activity/Activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface Activity {
temporary?: boolean;
/** Cluster of the launched activity */
cluster?: string;
/** Whether this activity is pinned (won't close on click-outside) */
pinned?: boolean;
}

export interface ActivityState {
Expand Down Expand Up @@ -178,6 +180,21 @@ export const Activity = {
update(id: string, diff: Partial<Activity>) {
store.dispatch(activitySlice.actions.update({ ...diff, id }));
},
/**
* Closes or minimizes activity based on pinned state
* - Pinned activities: minimized (kept in ActivityBar)
* - Regular activities: closed completely
*/
closeOrMinimize(id: string) {
const state = store.getState();
const activity = state.activity.activities[id];

if (activity?.pinned) {
this.update(id, { minimized: true });
} else {
this.close(id);
}
},
reset() {
store.dispatch(activitySlice.actions.reset());
},
Expand Down Expand Up @@ -335,6 +352,125 @@ export function SingleActivityRenderer({
};
}, [location]);

// Close or minimize activity when clicking outside (only for split modes)
useEffect(() => {
if (isOverview || minimized || (location !== 'split-left' && location !== 'split-right')) {
return;
}

// Record when the listener is registered to ignore immediate clicks
const listenerRegistrationTime = Date.now();

const handleClickOutside = (event: MouseEvent) => {
// Ignore clicks that happened within 100ms of listener registration
// This prevents the click that opened the activity from closing it
if (event.timeStamp && Date.now() - listenerRegistrationTime < 100) {
return;
}

const activityElement = activityElementRef.current;
if (!activityElement) return;

// Check if click is outside the activity panel
if (!activityElement.contains(event.target as Node)) {
const target = event.target as HTMLElement;

// Don't close if clicking on:
// 1. Another activity panel (let that activity handle it)
const isAnotherActivity = !!target.closest('[role="complementary"]');

// 2. Resource links (let Link.tsx handle the transition)
const isResourceLink = target.closest('a[href*="/"], a[role="button"]');

// 3. ActivityBar (taskbar at the bottom)
const isInActivityBar = !!target.closest('[data-activity-bar="true"]');

// 4. Pagination buttons and table controls
const isPaginationButton = !!target.closest(
'button[aria-label*="page"], button[aria-label*="Page"], ' +
'button[title*="page"], button[title*="Page"], ' +
'.MuiPagination-root, .MuiPagination-root *, ' +
'[role="navigation"], [role="navigation"] *, ' +
'button[aria-label*="next"], button[aria-label*="previous"], ' +
'button[aria-label*="first"], button[aria-label*="last"]'
);

// 5. Table control buttons (sort, filter, search, etc.)
const isTableControl = !!target.closest(
// Table header and controls
'thead, thead *, ' +
'.MuiTableHead-root, .MuiTableHead-root *, ' +
// Sort buttons
'button[aria-label*="sort"], button[aria-label*="Sort"], ' +
'[role="columnheader"], [role="columnheader"] *, ' +
// Filter buttons and inputs
'button[aria-label*="filter"], button[aria-label*="Filter"], ' +
'button[title*="filter"], button[title*="Filter"], ' +
'[aria-label*="filter"], [aria-label*="Filter"], ' +
'[aria-label*="Namespace"], [aria-label*="namespace"], ' +
// Search inputs and toggle buttons
'input[type="search"], input[type="text"][placeholder*="Search"], ' +
'input[type="text"][placeholder*="search"], ' +
'input[type="text"][aria-label*="Search"], ' +
'input[type="text"][aria-label*="search"], ' +
'button[aria-label*="Search"], button[aria-label*="search"], ' +
'button[title*="Search"], button[title*="search"], ' +
// Show/Hide buttons (columns, search, etc.)
'button[aria-label*="show"], button[aria-label*="Show"], ' +
'button[aria-label*="hide"], button[aria-label*="Hide"], ' +
'button[title*="show"], button[title*="Show"], ' +
'button[title*="hide"], button[title*="Hide"], ' +
'button[aria-label*="column"], button[aria-label*="Column"], ' +
// MUI Select and Autocomplete components
'.MuiSelect-root, .MuiSelect-root *, ' +
'.MuiAutocomplete-root, .MuiAutocomplete-root *, ' +
'.MuiAutocomplete-popper, .MuiAutocomplete-popper *, ' +
'.MuiAutocomplete-listbox, .MuiAutocomplete-listbox *, ' +
'[role="combobox"], [role="combobox"] *, ' +
'[role="listbox"], [role="listbox"] *, ' +
'[role="option"], [role="option"] *, ' +
// MUI Input and FormControl
'.MuiInputBase-root, .MuiInputBase-root *, ' +
'.MuiFormControl-root, .MuiFormControl-root *, ' +
'.MuiOutlinedInput-root, .MuiOutlinedInput-root *, ' +
// MUI Table components
'.MuiTablePagination-root, .MuiTablePagination-root *, ' +
'.MuiTableSortLabel-root, .MuiTableSortLabel-root *, ' +
// Toolbar and action areas
'.MuiToolbar-root, .MuiToolbar-root *, ' +
'[role="toolbar"], [role="toolbar"] *, ' +
// Popover, Menu, Dialog (for filters, column selection, etc.)
'.MuiPopover-root, .MuiPopover-root *, ' +
'.MuiMenu-root, .MuiMenu-root *, ' +
'.MuiDialog-root, .MuiDialog-root *, ' +
'.MuiPaper-root[role="dialog"], .MuiPaper-root[role="dialog"] *, ' +
'[role="menu"], [role="menu"] *, ' +
'[role="dialog"], [role="dialog"] *, ' +
'[role="presentation"], [role="presentation"] *'
);

// If clicking UI controls or another activity panel, don't close
// Otherwise, close or minimize based on pinned state
if (
!isAnotherActivity &&
!isResourceLink &&
!isInActivityBar &&
!isPaginationButton &&
!isTableControl
) {
Activity.closeOrMinimize(id);
}
}
};

// Add listener immediately (no delay)
document.addEventListener('mousedown', handleClickOutside);

return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [id, isOverview, minimized, location, activity.pinned]);

return (
<ActivityContext.Provider value={activity}>
<Box
Expand Down Expand Up @@ -850,8 +986,16 @@ export const ActivitiesRenderer = React.memo(function ActivitiesRenderer() {
}
});

// Close or minimize the last activity when ESC is pressed
useHotkeys('Escape', () => {
if (lastElement && !isOverview) {
Activity.closeOrMinimize(lastElement);
}
});

return (
<>
{/* Backdrop for overview mode */}
<Box
sx={{
background: 'rgba(0,0,0,0.1)',
Expand Down Expand Up @@ -927,6 +1071,7 @@ export const ActivityBar = React.memo(function ({

return (
<Box
data-activity-bar="true"
sx={theme => ({
background: theme.palette.background.muted,
borderTop: '1px solid',
Expand Down Expand Up @@ -1004,6 +1149,26 @@ export const ActivityBar = React.memo(function ({
</Box>
</Box>
</Button>
<Tooltip title={it.pinned ? t('Unpin') : t('Pin')}>
<IconButton
size="small"
onClick={e => {
e.preventDefault();
e.stopPropagation();
Activity.update(it.id, { pinned: !it.pinned });
}}
sx={{
width: '42px',
height: '100%',
borderRadius: 1,
flexShrink: 0,
color: it.pinned ? 'primary.main' : undefined,
}}
aria-label={it.pinned ? t('Unpin') : t('Pin')}
>
<Icon icon={it.pinned ? 'mdi:pin' : 'mdi:pin-outline'} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={e => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
</div>
<div
class="MuiBox-root css-3ykv34"
data-activity-bar="true"
>
<div
class="MuiBox-root css-16dgwon"
Expand All @@ -192,6 +193,17 @@
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<button
aria-label="Pin"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
data-mui-internal-clone-element="true"
tabindex="0"
type="button"
>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<button
aria-label="Close"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
Expand Down Expand Up @@ -228,6 +240,17 @@
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<button
aria-label="Pin"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
data-mui-internal-clone-element="true"
tabindex="0"
type="button"
>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<button
aria-label="Close"
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall css-182yqqz-MuiButtonBase-root-MuiIconButton-root"
Expand Down
73 changes: 46 additions & 27 deletions frontend/src/components/common/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ function PureLink(

export default function Link(props: React.PropsWithChildren<LinkProps | LinkObjectProps>) {
const drawerEnabled = useTypedSelector(state => state.drawerMode.isDetailDrawerEnabled);
const activities = useTypedSelector(state => state.activity?.activities || {});
const activityHistory = useTypedSelector(state => state.activity?.history || []);

const { tooltip, ...propsRest } = props as LinkObjectProps;

Expand Down Expand Up @@ -175,33 +177,50 @@ export default function Link(props: React.PropsWithChildren<LinkProps | LinkObje
}
: { kind, metadata: { name, namespace }, cluster };

Activity.launch({
id:
'details' +
selectedResource.kind +
' ' +
selectedResource.metadata.name +
selectedResource.cluster,
title: selectedResource.kind + ' ' + selectedResource.metadata.name,
hideTitleInHeader: true,
location: 'split-right',
cluster: selectedResource.cluster,
temporary: true,
content: (
<KubeObjectDetails
resource={{
kind: selectedResource.kind,
metadata: {
name: selectedResource.metadata.name,
namespace: selectedResource.metadata.namespace,
},
cluster: selectedResource.cluster,
}}
customResourceDefinition={selectedResource.customResourceDefinition}
/>
),
icon: <KubeIcon kind={selectedResource.kind} width="100%" height="100%" />,
});
const activityId =
'details' +
selectedResource.kind +
' ' +
selectedResource.metadata.name +
selectedResource.cluster;

// Get the currently active (last) activity
const currentActivityId = activityHistory[activityHistory.length - 1];
const currentActivity = activities[activityId];

// Check if clicking the same resource that's currently open
if (currentActivityId === activityId && currentActivity) {
// Same resource clicked again - close or minimize based on pinned state
Activity.closeOrMinimize(activityId);
} else if (currentActivity && currentActivity.minimized) {
// If the activity exists but is minimized, restore it
Activity.update(activityId, { minimized: false });
} else {
// Different resource or not open - launch it
// (Activity.launch will automatically close other temporary activities)
Activity.launch({
id: activityId,
title: selectedResource.kind + ' ' + selectedResource.metadata.name,
hideTitleInHeader: true,
location: 'split-right',
cluster: selectedResource.cluster,
temporary: true,
content: (
<KubeObjectDetails
resource={{
kind: selectedResource.kind,
metadata: {
name: selectedResource.metadata.name,
namespace: selectedResource.metadata.namespace,
},
cluster: selectedResource.cluster,
}}
customResourceDefinition={selectedResource.customResourceDefinition}
/>
),
icon: <KubeIcon kind={selectedResource.kind} width="100%" height="100%" />,
});
}
}
: undefined;

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/i18next-parser.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
Copy link
Preview

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

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

[nitpick] The import should be moved to the top with other imports (after line 18) to follow consistent import ordering conventions.

Copilot uses AI. Check for mistakes.

import sharedConfig from './i18nextSharedConfig.mjs';

const directoryPath = path.join(import.meta.dirname, sharedConfig.localesPath);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const directoryPath = path.join(__dirname, sharedConfig.localesPath);
const currentLocales = [];

fs.readdirSync(directoryPath).forEach(file => currentLocales.push(file));
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"Fullscreen": "",
"Close": "Schließen",
"Close All": "",
"Unpin": "",
"Pin": "",
"Overview": "Übersicht",
"Loading": "",
"Advanced Search (Beta)": "",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"Fullscreen": "Fullscreen",
"Close": "Close",
"Close All": "Close All",
"Unpin": "Unpin",
"Pin": "Pin",
"Overview": "Overview",
"Loading": "Loading",
"Advanced Search (Beta)": "Advanced Search (Beta)",
Expand Down
Loading
Loading