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
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface WorkspaceItemProps {
draggable?: boolean;
isDragging?: boolean;
onDragStart?: React.DragEventHandler<HTMLDivElement>;
onDrag?: React.DragEventHandler<HTMLDivElement>;
onDragEnd?: React.DragEventHandler<HTMLDivElement>;
}

Expand All @@ -79,6 +80,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
draggable = false,
isDragging = false,
onDragStart,
onDrag,
onDragEnd,
}) => {
const { t } = useI18n('common');
Expand Down Expand Up @@ -122,7 +124,8 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
const [acpClientsLoading, setAcpClientsLoading] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const menuAnchorRef = useRef<HTMLDivElement>(null);
const menuPopoverRef = useRef<HTMLDivElement>(null);
const menuPopoverRef = useRef<HTMLDivElement | null>(null);
const popoverResizeObserverRef = useRef<ResizeObserver | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const isDefaultAssistantWorkspace =
Expand Down Expand Up @@ -387,6 +390,26 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
requestAnimationFrame(apply);
}, []);

// Callback ref for the menu popover. The popover only mounts once menuPosition
// is set (chicken-and-egg: menuPosition needs the popover's size), so a
// ResizeObserver created in the menuOpen effect would attach to a null ref.
// Attaching here ties the observer to the element's actual mount/unmount:
// on mount it fires once with the real size (fixing the stale initial height)
// and again whenever async content (ACP client rows, loading toggle, remote
// /git conditional rows, locale label width) changes the popover height.
const setMenuPopoverRef = useCallback((node: HTMLDivElement | null) => {
if (popoverResizeObserverRef.current) {
popoverResizeObserverRef.current.disconnect();
popoverResizeObserverRef.current = null;
}
menuPopoverRef.current = node;
if (node && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => updateMenuPosition());
ro.observe(node);
popoverResizeObserverRef.current = ro;
}
}, [updateMenuPosition]);

const handleMenuTriggerClick = useCallback(() => {
setMenuOpen(open => !open);
}, []);
Expand Down Expand Up @@ -784,6 +807,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
className="bitfun-nav-panel__assistant-item-card"
draggable={draggable}
onDragStart={onDragStart}
onDrag={onDrag}
onDragEnd={onDragEnd}
onClick={() => { void handleCardNameClick(); }}
style={{ cursor: 'pointer' }}
Expand Down Expand Up @@ -816,7 +840,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
</span>
</span>
</button>
<Tooltip content={workspace.rootPath} placement="right" followCursor>
<Tooltip content={workspace.rootPath} placement="right" followCursor disabled={isDragging}>
<button
data-bf-component="workspace-item"
data-bf-part="name"
Expand Down Expand Up @@ -873,7 +897,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
data-bf-component="workspace-item"
data-bf-part="menuPopover"
data-bf-state="open"
ref={menuPopoverRef}
ref={setMenuPopoverRef}
className="bitfun-nav-panel__workspace-item-menu-popover"
role="menu"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
Expand Down Expand Up @@ -1078,6 +1102,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
className="bitfun-nav-panel__workspace-item-card"
draggable={draggable}
onDragStart={onDragStart}
onDrag={onDrag}
onDragEnd={onDragEnd}
onClick={() => { void handleCardNameClick(); }}
style={{ cursor: 'pointer' }}
Expand Down Expand Up @@ -1115,7 +1140,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
<div className="bitfun-nav-panel__workspace-item-name-cluster">
<div className={`bitfun-nav-panel__workspace-item-name-stack${remoteMeta ? ' is-remote' : ''}`}>
<div className="bitfun-nav-panel__workspace-item-name-row">
<Tooltip content={workspace.rootPath} placement="right" followCursor>
<Tooltip content={workspace.rootPath} placement="right" followCursor disabled={isDragging}>
<button
data-bf-component="workspace-item"
data-bf-part="name"
Expand Down Expand Up @@ -1319,7 +1344,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
data-bf-component="workspace-item"
data-bf-part="menuPopover"
data-bf-state="open"
ref={menuPopoverRef}
ref={setMenuPopoverRef}
className="bitfun-nav-panel__workspace-item-menu-popover"
role="menu"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@
.bitfun-nav-panel__workspace-item {
cursor: default;
}

.bitfun-nav-panel__inline-item-actions {
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}

.bitfun-nav-panel__workspace-item-branch {
max-width: 0 !important;
margin-left: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
opacity: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}

.bitfun-nav-panel__workspace-item-menu,
.bitfun-nav-panel__assistant-item-menu {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
}

Expand Down Expand Up @@ -105,6 +128,15 @@
.bitfun-nav-panel__workspace-item-card {
cursor: default;
}

// Suppress focus outlines on children (icon/name/menu buttons) while
// dragging. The global :focus-visible ring around the icon, dimmed by the
// 0.42 opacity and baked into the native drag snapshot, reads as a thick
// whitish border around the icon. More specific than the global rule.
:focus,
:focus-visible {
outline: none;
}
}

&.is-active {
Expand Down Expand Up @@ -1108,6 +1140,14 @@
.bitfun-nav-panel__assistant-item-card {
cursor: default;
}

// See the pro-mode &.is-dragging rule: suppress the focus-visible ring
// around the icon that, dimmed and snapshotted into the drag image, looks
// like a thick whitish border.
:focus,
:focus-visible {
outline: none;
}
}

&.is-active {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '@/infrastructure/i18n';
import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
import { notificationService } from '@/shared/notification-system';
Expand Down Expand Up @@ -39,6 +39,12 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
// Refs for values that must be read inside event handlers without stale closures
const draggedWorkspaceIdRef = useRef<string | null>(null);
const dropTargetRef = useRef<{ workspaceId: string; position: WorkspaceDragPosition } | null>(null);
// Drag-state safety nets (window dragend/mousedown + reset timeout) to clear
// stuck is-drag-active state when dragend doesn't fire normally.
const dragSafetyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const windowDragEndHandlerRef = useRef<(() => void) | null>(null);
const windowMouseDownHandlerRef = useRef<(() => void) | null>(null);
const documentMouseMoveHandlerRef = useRef<(() => void) | null>(null);

const sectionWorkspaces = variant === 'assistants'
? assistantWorkspacesList
Expand All @@ -64,6 +70,54 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
? t('nav.workspaces.emptyAssistants')
: t('nav.workspaces.emptyProjects');

const removeDragArtifacts = useCallback(() => {
if (dragSafetyTimeoutRef.current !== null) {
clearTimeout(dragSafetyTimeoutRef.current);
dragSafetyTimeoutRef.current = null;
}
if (windowDragEndHandlerRef.current !== null) {
window.removeEventListener('dragend', windowDragEndHandlerRef.current);
windowDragEndHandlerRef.current = null;
}
if (windowMouseDownHandlerRef.current !== null) {
window.removeEventListener('mousedown', windowMouseDownHandlerRef.current);
windowMouseDownHandlerRef.current = null;
}
if (documentMouseMoveHandlerRef.current !== null) {
document.removeEventListener('mousemove', documentMouseMoveHandlerRef.current);
documentMouseMoveHandlerRef.current = null;
}
}, []);

const clearDragState = useCallback(() => {
draggedWorkspaceIdRef.current = null;
dropTargetRef.current = null;
setDraggedWorkspaceId(null);
setDropTarget(null);
}, []);

const cleanupDrag = useCallback(() => {
removeDragArtifacts();
clearDragState();
}, [removeDragArtifacts, clearDragState]);

useEffect(() => {
return () => {
if (dragSafetyTimeoutRef.current !== null) {
clearTimeout(dragSafetyTimeoutRef.current);
}
if (windowDragEndHandlerRef.current !== null) {
window.removeEventListener('dragend', windowDragEndHandlerRef.current);
}
if (windowMouseDownHandlerRef.current !== null) {
window.removeEventListener('mousedown', windowMouseDownHandlerRef.current);
}
if (documentMouseMoveHandlerRef.current !== null) {
document.removeEventListener('mousemove', documentMouseMoveHandlerRef.current);
}
};
}, []);

const handleDragStart = useCallback((workspaceId: string) => (event: React.DragEvent<HTMLDivElement>) => {
const payload: WorkspaceDragPayload = { workspaceId, variant };
const serializedPayload = JSON.stringify(payload);
Expand All @@ -72,14 +126,44 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
event.dataTransfer.setData('text/plain', serializedPayload);
draggedWorkspaceIdRef.current = workspaceId;
setDraggedWorkspaceId(workspaceId);
}, [variant]);

// Native drag ghost is used (no setDragImage / no custom preview). The
// drag-state safety nets below clear stuck is-drag-active state if dragend
// doesn't fire normally.
const windowDragEndHandler = () => { cleanupDrag(); };
windowDragEndHandlerRef.current = windowDragEndHandler;
window.addEventListener('dragend', windowDragEndHandler, { once: true });

const windowMouseDownHandler = () => { cleanupDrag(); };
windowMouseDownHandlerRef.current = windowMouseDownHandler;
window.addEventListener('mousedown', windowMouseDownHandler, { once: true });

// mousemove is suppressed during an active HTML5 drag (per spec) and resumes
// when the drag ends — even if dragend is swallowed. So it's a safe instant
// cleanup signal that never interrupts a normal drag.
const documentMouseMoveHandler = () => { cleanupDrag(); };
documentMouseMoveHandlerRef.current = documentMouseMoveHandler;
document.addEventListener('mousemove', documentMouseMoveHandler, { once: true });

dragSafetyTimeoutRef.current = setTimeout(() => {
cleanupDrag();
}, 1500);
}, [variant, cleanupDrag]);

const handleDrag = useCallback(() => {
// Refresh the stuck-state safety timeout on each drag event so a long drag
// doesn't trip the fallback. (Native ghost is used; no custom preview.)
if (dragSafetyTimeoutRef.current !== null) {
clearTimeout(dragSafetyTimeoutRef.current);
dragSafetyTimeoutRef.current = setTimeout(() => {
cleanupDrag();
}, 1500);
}
}, [cleanupDrag]);

const handleDragEnd = useCallback(() => {
draggedWorkspaceIdRef.current = null;
dropTargetRef.current = null;
setDraggedWorkspaceId(null);
setDropTarget(null);
}, []);
cleanupDrag();
}, [cleanupDrag]);

const handleDragOver = useCallback((workspaceId: string) => (event: React.DragEvent<HTMLDivElement>) => {
// Browsers block reading dataTransfer data during dragover for security.
Expand All @@ -95,6 +179,13 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';

if (dragSafetyTimeoutRef.current !== null) {
clearTimeout(dragSafetyTimeoutRef.current);
dragSafetyTimeoutRef.current = setTimeout(() => {
cleanupDrag();
}, 1500);
}

// Measure only the workspace card, not the wrapper that includes the drop-line.
const itemEl = event.currentTarget.querySelector<HTMLElement>(
'.bitfun-nav-panel__workspace-item'
Expand All @@ -115,7 +206,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
dropTargetRef.current = next;
return next;
});
}, []); // Intentionally empty: reads from refs, not closed-over state
}, [cleanupDrag]); // cleanupDrag is stable; reads refs for the rest

const handleDragLeave = useCallback((workspaceId: string) => (event: React.DragEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
Expand All @@ -133,16 +224,23 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
event.dataTransfer.getData(WORKSPACE_DRAG_MIME_TYPE) ||
event.dataTransfer.getData('text/plain');

if (!payloadText) return;
if (!payloadText) {
cleanupDrag();
return;
}

let payload: WorkspaceDragPayload;
try {
payload = JSON.parse(payloadText) as WorkspaceDragPayload;
} catch {
cleanupDrag();
return;
}

if (!payload.workspaceId || payload.variant !== variant) return;
if (!payload.workspaceId || payload.variant !== variant) {
cleanupDrag();
return;
}

event.preventDefault();
event.stopPropagation();
Expand All @@ -154,6 +252,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
? dropTargetRef.current.position
: 'after';

removeDragArtifacts();
draggedWorkspaceIdRef.current = null;
dropTargetRef.current = null;
setDropTarget(null);
Expand All @@ -168,7 +267,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
} finally {
setDraggedWorkspaceId(null);
}
}, [reorderOpenedWorkspacesInSection, t, variant]);
}, [reorderOpenedWorkspacesInSection, t, variant, cleanupDrag, removeDragArtifacts]);

return (
<div data-bf-component="workspace-list-section" data-bf-part="root" data-bf-state={draggedWorkspaceId ? 'dragging' : undefined}
Expand Down Expand Up @@ -220,6 +319,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
draggable={workspaces.length > 1}
isDragging={draggedWorkspaceId === workspace.id}
onDragStart={handleDragStart(workspace.id)}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
/>
{dropTarget?.workspaceId === workspace.id && dropTarget.position === 'after' ? (
Expand Down
Loading