Skip to content

Commit 9cbe035

Browse files
author
hanlinyu1030
committed
fix(workspace): drag safety net, onDrag/Tooltip, ResizeObserver menu, input border (win port)
Cherry-pick of dd5177f + 5e02270 common parts onto hly/win-bugfix. Covers all PR #188 + #207 commits: native ghost + safety net (window dragend/mousedown + mousemove + 1.5s timeout + dragover reset), WorkspaceItem onDrag prop / Tooltip disabled while dragging / ResizeObserver menu popover repositioning, drag :focus outline suppression, suppress hover-show elements, NewProjectDialog input border moved to container (token-adapted to --bf-appearance-token-*). HarmonyOS-only CommonUtils.ets / open_oh_file_dialog picker excluded.
1 parent b7bd6af commit 9cbe035

4 files changed

Lines changed: 221 additions & 40 deletions

File tree

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ interface WorkspaceItemProps {
6262
draggable?: boolean;
6363
isDragging?: boolean;
6464
onDragStart?: React.DragEventHandler<HTMLDivElement>;
65+
onDrag?: React.DragEventHandler<HTMLDivElement>;
6566
onDragEnd?: React.DragEventHandler<HTMLDivElement>;
6667
}
6768

@@ -79,6 +80,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
7980
draggable = false,
8081
isDragging = false,
8182
onDragStart,
83+
onDrag,
8284
onDragEnd,
8385
}) => {
8486
const { t } = useI18n('common');
@@ -122,7 +124,8 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
122124
const [acpClientsLoading, setAcpClientsLoading] = useState(false);
123125
const menuRef = useRef<HTMLDivElement>(null);
124126
const menuAnchorRef = useRef<HTMLDivElement>(null);
125-
const menuPopoverRef = useRef<HTMLDivElement>(null);
127+
const menuPopoverRef = useRef<HTMLDivElement | null>(null);
128+
const popoverResizeObserverRef = useRef<ResizeObserver | null>(null);
126129
const cardRef = useRef<HTMLDivElement>(null);
127130
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
128131
const isDefaultAssistantWorkspace =
@@ -387,6 +390,26 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
387390
requestAnimationFrame(apply);
388391
}, []);
389392

393+
// Callback ref for the menu popover. The popover only mounts once menuPosition
394+
// is set (chicken-and-egg: menuPosition needs the popover's size), so a
395+
// ResizeObserver created in the menuOpen effect would attach to a null ref.
396+
// Attaching here ties the observer to the element's actual mount/unmount:
397+
// on mount it fires once with the real size (fixing the stale initial height)
398+
// and again whenever async content (ACP client rows, loading toggle, remote
399+
// /git conditional rows, locale label width) changes the popover height.
400+
const setMenuPopoverRef = useCallback((node: HTMLDivElement | null) => {
401+
if (popoverResizeObserverRef.current) {
402+
popoverResizeObserverRef.current.disconnect();
403+
popoverResizeObserverRef.current = null;
404+
}
405+
menuPopoverRef.current = node;
406+
if (node && typeof ResizeObserver !== 'undefined') {
407+
const ro = new ResizeObserver(() => updateMenuPosition());
408+
ro.observe(node);
409+
popoverResizeObserverRef.current = ro;
410+
}
411+
}, [updateMenuPosition]);
412+
390413
const handleMenuTriggerClick = useCallback(() => {
391414
setMenuOpen(open => !open);
392415
}, []);
@@ -784,6 +807,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
784807
className="bitfun-nav-panel__assistant-item-card"
785808
draggable={draggable}
786809
onDragStart={onDragStart}
810+
onDrag={onDrag}
787811
onDragEnd={onDragEnd}
788812
onClick={() => { void handleCardNameClick(); }}
789813
style={{ cursor: 'pointer' }}
@@ -816,7 +840,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
816840
</span>
817841
</span>
818842
</button>
819-
<Tooltip content={workspace.rootPath} placement="right" followCursor>
843+
<Tooltip content={workspace.rootPath} placement="right" followCursor disabled={isDragging}>
820844
<button
821845
data-bf-component="workspace-item"
822846
data-bf-part="name"
@@ -873,7 +897,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
873897
data-bf-component="workspace-item"
874898
data-bf-part="menuPopover"
875899
data-bf-state="open"
876-
ref={menuPopoverRef}
900+
ref={setMenuPopoverRef}
877901
className="bitfun-nav-panel__workspace-item-menu-popover"
878902
role="menu"
879903
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
@@ -1078,6 +1102,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
10781102
className="bitfun-nav-panel__workspace-item-card"
10791103
draggable={draggable}
10801104
onDragStart={onDragStart}
1105+
onDrag={onDrag}
10811106
onDragEnd={onDragEnd}
10821107
onClick={() => { void handleCardNameClick(); }}
10831108
style={{ cursor: 'pointer' }}
@@ -1115,7 +1140,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
11151140
<div className="bitfun-nav-panel__workspace-item-name-cluster">
11161141
<div className={`bitfun-nav-panel__workspace-item-name-stack${remoteMeta ? ' is-remote' : ''}`}>
11171142
<div className="bitfun-nav-panel__workspace-item-name-row">
1118-
<Tooltip content={workspace.rootPath} placement="right" followCursor>
1143+
<Tooltip content={workspace.rootPath} placement="right" followCursor disabled={isDragging}>
11191144
<button
11201145
data-bf-component="workspace-item"
11211146
data-bf-part="name"
@@ -1319,7 +1344,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
13191344
data-bf-component="workspace-item"
13201345
data-bf-part="menuPopover"
13211346
data-bf-state="open"
1322-
ref={menuPopoverRef}
1347+
ref={setMenuPopoverRef}
13231348
className="bitfun-nav-panel__workspace-item-menu-popover"
13241349
role="menu"
13251350
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@
1717
.bitfun-nav-panel__workspace-item {
1818
cursor: default;
1919
}
20+
21+
.bitfun-nav-panel__inline-item-actions {
22+
visibility: hidden !important;
23+
opacity: 0 !important;
24+
pointer-events: none !important;
25+
}
26+
27+
.bitfun-nav-panel__workspace-item-branch {
28+
max-width: 0 !important;
29+
margin-left: 0 !important;
30+
padding-left: 0 !important;
31+
padding-right: 0 !important;
32+
opacity: 0 !important;
33+
overflow: hidden !important;
34+
pointer-events: none !important;
35+
}
36+
37+
.bitfun-nav-panel__workspace-item-menu,
38+
.bitfun-nav-panel__assistant-item-menu {
39+
opacity: 0 !important;
40+
visibility: hidden !important;
41+
pointer-events: none !important;
42+
}
2043
}
2144
}
2245

@@ -105,6 +128,15 @@
105128
.bitfun-nav-panel__workspace-item-card {
106129
cursor: default;
107130
}
131+
132+
// Suppress focus outlines on children (icon/name/menu buttons) while
133+
// dragging. The global :focus-visible ring around the icon, dimmed by the
134+
// 0.42 opacity and baked into the native drag snapshot, reads as a thick
135+
// whitish border around the icon. More specific than the global rule.
136+
:focus,
137+
:focus-visible {
138+
outline: none;
139+
}
108140
}
109141

110142
&.is-active {
@@ -1108,6 +1140,14 @@
11081140
.bitfun-nav-panel__assistant-item-card {
11091141
cursor: default;
11101142
}
1143+
1144+
// See the pro-mode &.is-dragging rule: suppress the focus-visible ring
1145+
// around the icon that, dimmed and snapshotted into the drag image, looks
1146+
// like a thick whitish border.
1147+
:focus,
1148+
:focus-visible {
1149+
outline: none;
1150+
}
11111151
}
11121152

11131153
&.is-active {

‎src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx‎

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

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

73+
const removeDragArtifacts = useCallback(() => {
74+
if (dragSafetyTimeoutRef.current !== null) {
75+
clearTimeout(dragSafetyTimeoutRef.current);
76+
dragSafetyTimeoutRef.current = null;
77+
}
78+
if (windowDragEndHandlerRef.current !== null) {
79+
window.removeEventListener('dragend', windowDragEndHandlerRef.current);
80+
windowDragEndHandlerRef.current = null;
81+
}
82+
if (windowMouseDownHandlerRef.current !== null) {
83+
window.removeEventListener('mousedown', windowMouseDownHandlerRef.current);
84+
windowMouseDownHandlerRef.current = null;
85+
}
86+
if (documentMouseMoveHandlerRef.current !== null) {
87+
document.removeEventListener('mousemove', documentMouseMoveHandlerRef.current);
88+
documentMouseMoveHandlerRef.current = null;
89+
}
90+
}, []);
91+
92+
const clearDragState = useCallback(() => {
93+
draggedWorkspaceIdRef.current = null;
94+
dropTargetRef.current = null;
95+
setDraggedWorkspaceId(null);
96+
setDropTarget(null);
97+
}, []);
98+
99+
const cleanupDrag = useCallback(() => {
100+
removeDragArtifacts();
101+
clearDragState();
102+
}, [removeDragArtifacts, clearDragState]);
103+
104+
useEffect(() => {
105+
return () => {
106+
if (dragSafetyTimeoutRef.current !== null) {
107+
clearTimeout(dragSafetyTimeoutRef.current);
108+
}
109+
if (windowDragEndHandlerRef.current !== null) {
110+
window.removeEventListener('dragend', windowDragEndHandlerRef.current);
111+
}
112+
if (windowMouseDownHandlerRef.current !== null) {
113+
window.removeEventListener('mousedown', windowMouseDownHandlerRef.current);
114+
}
115+
if (documentMouseMoveHandlerRef.current !== null) {
116+
document.removeEventListener('mousemove', documentMouseMoveHandlerRef.current);
117+
}
118+
};
119+
}, []);
120+
67121
const handleDragStart = useCallback((workspaceId: string) => (event: React.DragEvent<HTMLDivElement>) => {
68122
const payload: WorkspaceDragPayload = { workspaceId, variant };
69123
const serializedPayload = JSON.stringify(payload);
@@ -72,14 +126,44 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
72126
event.dataTransfer.setData('text/plain', serializedPayload);
73127
draggedWorkspaceIdRef.current = workspaceId;
74128
setDraggedWorkspaceId(workspaceId);
75-
}, [variant]);
129+
130+
// Native drag ghost is used (no setDragImage / no custom preview). The
131+
// drag-state safety nets below clear stuck is-drag-active state if dragend
132+
// doesn't fire normally.
133+
const windowDragEndHandler = () => { cleanupDrag(); };
134+
windowDragEndHandlerRef.current = windowDragEndHandler;
135+
window.addEventListener('dragend', windowDragEndHandler, { once: true });
136+
137+
const windowMouseDownHandler = () => { cleanupDrag(); };
138+
windowMouseDownHandlerRef.current = windowMouseDownHandler;
139+
window.addEventListener('mousedown', windowMouseDownHandler, { once: true });
140+
141+
// mousemove is suppressed during an active HTML5 drag (per spec) and resumes
142+
// when the drag ends — even if dragend is swallowed. So it's a safe instant
143+
// cleanup signal that never interrupts a normal drag.
144+
const documentMouseMoveHandler = () => { cleanupDrag(); };
145+
documentMouseMoveHandlerRef.current = documentMouseMoveHandler;
146+
document.addEventListener('mousemove', documentMouseMoveHandler, { once: true });
147+
148+
dragSafetyTimeoutRef.current = setTimeout(() => {
149+
cleanupDrag();
150+
}, 1500);
151+
}, [variant, cleanupDrag]);
152+
153+
const handleDrag = useCallback(() => {
154+
// Refresh the stuck-state safety timeout on each drag event so a long drag
155+
// doesn't trip the fallback. (Native ghost is used; no custom preview.)
156+
if (dragSafetyTimeoutRef.current !== null) {
157+
clearTimeout(dragSafetyTimeoutRef.current);
158+
dragSafetyTimeoutRef.current = setTimeout(() => {
159+
cleanupDrag();
160+
}, 1500);
161+
}
162+
}, [cleanupDrag]);
76163

77164
const handleDragEnd = useCallback(() => {
78-
draggedWorkspaceIdRef.current = null;
79-
dropTargetRef.current = null;
80-
setDraggedWorkspaceId(null);
81-
setDropTarget(null);
82-
}, []);
165+
cleanupDrag();
166+
}, [cleanupDrag]);
83167

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

182+
if (dragSafetyTimeoutRef.current !== null) {
183+
clearTimeout(dragSafetyTimeoutRef.current);
184+
dragSafetyTimeoutRef.current = setTimeout(() => {
185+
cleanupDrag();
186+
}, 1500);
187+
}
188+
98189
// Measure only the workspace card, not the wrapper that includes the drop-line.
99190
const itemEl = event.currentTarget.querySelector<HTMLElement>(
100191
'.bitfun-nav-panel__workspace-item'
@@ -115,7 +206,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
115206
dropTargetRef.current = next;
116207
return next;
117208
});
118-
}, []); // Intentionally empty: reads from refs, not closed-over state
209+
}, [cleanupDrag]); // cleanupDrag is stable; reads refs for the rest
119210

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

136-
if (!payloadText) return;
227+
if (!payloadText) {
228+
cleanupDrag();
229+
return;
230+
}
137231

138232
let payload: WorkspaceDragPayload;
139233
try {
140234
payload = JSON.parse(payloadText) as WorkspaceDragPayload;
141235
} catch {
236+
cleanupDrag();
142237
return;
143238
}
144239

145-
if (!payload.workspaceId || payload.variant !== variant) return;
240+
if (!payload.workspaceId || payload.variant !== variant) {
241+
cleanupDrag();
242+
return;
243+
}
146244

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

255+
removeDragArtifacts();
157256
draggedWorkspaceIdRef.current = null;
158257
dropTargetRef.current = null;
159258
setDropTarget(null);
@@ -168,7 +267,7 @@ const WorkspaceListSection: React.FC<WorkspaceListSectionProps> = ({ variant })
168267
} finally {
169268
setDraggedWorkspaceId(null);
170269
}
171-
}, [reorderOpenedWorkspacesInSection, t, variant]);
270+
}, [reorderOpenedWorkspacesInSection, t, variant, cleanupDrag, removeDragArtifacts]);
172271

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

0 commit comments

Comments
 (0)