Skip to content

Commit 86ebc7c

Browse files
committed
fix(client): synchronize workspace pane state
1 parent b608dff commit 86ebc7c

6 files changed

Lines changed: 408 additions & 4 deletions

File tree

‎chat2db-community-client/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"test:sql-in-clipboard": "tsx src/utils/sqlInClipboard.test.ts && tsx src/components/SQLEditor/helper/sqlInsertValueDefaults.test.ts",
8080
"test:console-tab-name": "tsx src/store/workspace/utils/consoleTabName.test.ts && tsx src/utils/workspaceObjectTabTitle.test.ts",
8181
"test:workspace-tab-scroll": "tsx src/store/workspace/utils/workspaceTabScrollRequest.test.ts && tsx src/components/Tabs/activeTabScroll.test.ts && tsx src/components/Tabs/wheelScroll.test.ts",
82-
"test:workspace-split-lifecycle": "tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabContentLayout.test.ts",
82+
"test:workspace-split-lifecycle": "tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabContentLayout.test.ts && tsx src/pages/main/workspace/components/WorkspaceTabs/workspaceTabContentInteraction.test.ts",
8383
"test:tree-title-highlight": "tsx src/blocks/NewTree/components/TitleRender/highlightSearchText.test.ts",
8484
"test:tree-search-lifecycle": "tsx src/pages/main/workspace/components/WorkspaceTreeSearch/lifecycle.test.ts && tsx src/pages/main/workspace/components/WorkspaceTreeSearch/refresh.test.ts",
8585
"test:tree-data-update": "tsx src/store/tree/treeDataUpdate.test.ts",

‎chat2db-community-client/src/pages/main/workspace/components/WorkspaceTabs/index.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ import {
9898
import {
9999
areWorkspacePaneContentBoundsEqual,
100100
resolveActiveWorkspaceTabPaneIds,
101+
resolveWorkspaceTabContentActivation,
101102
resolveWorkspacePaneContentBounds,
102103
type WorkspacePaneContentBoundsMap,
103104
} from './workspaceTabContentLayout';
105+
import { WorkspaceTabContentInteraction } from './workspaceTabContentInteraction';
104106

105107
const SplitPaneAny = SplitPane as any;
106108
const MAIN_WORKSPACE_TAB_PANE: WorkspaceTabPaneId = 'main';
@@ -1942,7 +1944,7 @@ const WorkspaceTabs = memo(() => {
19421944
// Tab list.
19431945
const workspaceTabItems = useMemo(() => {
19441946
return getWorkspaceTabItems(workspaceTabList || []);
1945-
}, [workspaceTabList, activeConsoleId, dataSourceList]);
1947+
}, [workspaceTabList, activeConsoleId, dataSourceList, runtimeAvailabilityByDataSourceId]);
19461948
const workspaceTabItemMap = useMemo(
19471949
() => new Map(workspaceTabItems.map((item) => [item.key, item])),
19481950
[workspaceTabItems],
@@ -2152,11 +2154,28 @@ const WorkspaceTabs = memo(() => {
21522154
if (item.destroyOnHide && !isActive) {
21532155
return null;
21542156
}
2157+
const activateWorkspaceTabContent = () => {
2158+
const activation = resolveWorkspaceTabContentActivation({
2159+
paneId,
2160+
tabId: item.key,
2161+
activePaneId: workspaceTabSplitLayout?.activePane || MAIN_WORKSPACE_TAB_PANE,
2162+
activeConsoleId,
2163+
});
2164+
if (activation) {
2165+
onPaneTabChange(activation.paneId, activation.tabId);
2166+
}
2167+
};
21552168
return (
2156-
<div
2169+
<WorkspaceTabContentInteraction
21572170
key={item.key}
21582171
aria-hidden={!bounds}
21592172
className={`${styles.workspaceTabContentItem} ${bounds ? styles.workspaceTabContentItemActive : ''}`}
2173+
isActive={
2174+
paneId === (workspaceTabSplitLayout?.activePane || MAIN_WORKSPACE_TAB_PANE) &&
2175+
item.key === activeConsoleId
2176+
}
2177+
isVisible={!!bounds}
2178+
onActivate={activateWorkspaceTabContent}
21602179
style={
21612180
bounds
21622181
? {
@@ -2169,7 +2188,7 @@ const WorkspaceTabs = memo(() => {
21692188
}
21702189
>
21712190
{item.children}
2172-
</div>
2191+
</WorkspaceTabContentInteraction>
21732192
);
21742193
})}
21752194
</div>
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import assert from 'node:assert/strict';
2+
import { JSDOM } from 'jsdom';
3+
4+
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
5+
pretendToBeVisual: true,
6+
url: 'http://localhost',
7+
});
8+
const domWindow = dom.window;
9+
10+
Object.defineProperties(globalThis, {
11+
window: { configurable: true, value: domWindow },
12+
document: { configurable: true, value: domWindow.document },
13+
navigator: { configurable: true, value: domWindow.navigator },
14+
Node: { configurable: true, value: domWindow.Node },
15+
Element: { configurable: true, value: domWindow.Element },
16+
HTMLElement: { configurable: true, value: domWindow.HTMLElement },
17+
HTMLIFrameElement: { configurable: true, value: domWindow.HTMLIFrameElement },
18+
Event: { configurable: true, value: domWindow.Event },
19+
FocusEvent: { configurable: true, value: domWindow.FocusEvent },
20+
MouseEvent: { configurable: true, value: domWindow.MouseEvent },
21+
PointerEvent: { configurable: true, value: domWindow.MouseEvent },
22+
IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
23+
});
24+
25+
async function main() {
26+
const [{ default: React, act }, { createRoot }, { createStore }, { WorkspaceTabContentInteraction }] =
27+
await Promise.all([
28+
import('react'),
29+
import('react-dom/client'),
30+
import('zustand/vanilla'),
31+
import('./workspaceTabContentInteraction'),
32+
]);
33+
34+
const activeTabStore = createStore(() => ({ activePane: 'main', activeTabId: 'tab-a' }));
35+
let activationCalls = 0;
36+
const activateSplitTab = () => {
37+
activationCalls += 1;
38+
activeTabStore.setState({ activePane: 'pane-right', activeTabId: 'tab-b' });
39+
};
40+
const resetActiveTab = () => activeTabStore.setState({ activePane: 'main', activeTabId: 'tab-a' });
41+
let pointerTargetPane: string | undefined;
42+
let shortcutPane: string | undefined;
43+
44+
const outsideFocusTarget = document.createElement('button');
45+
const container = document.createElement('div');
46+
document.body.append(outsideFocusTarget, container);
47+
const root = createRoot(container);
48+
49+
await act(async () => {
50+
root.render(
51+
React.createElement(
52+
WorkspaceTabContentInteraction,
53+
{ isActive: false, isVisible: true, onActivate: activateSplitTab },
54+
React.createElement('button', {
55+
'data-testid': 'editor-control',
56+
onKeyDown: () => {
57+
shortcutPane = activeTabStore.getState().activePane;
58+
},
59+
onPointerDown: () => {
60+
pointerTargetPane = activeTabStore.getState().activePane;
61+
},
62+
}),
63+
React.createElement('iframe', { title: 'embedded editor', tabIndex: 0 }),
64+
),
65+
);
66+
});
67+
68+
const editorControl = container.querySelector<HTMLButtonElement>('[data-testid="editor-control"]');
69+
const embeddedEditor = container.querySelector<HTMLIFrameElement>('iframe');
70+
assert.ok(editorControl);
71+
assert.ok(embeddedEditor);
72+
73+
resetActiveTab();
74+
activationCalls = 0;
75+
outsideFocusTarget.focus();
76+
await act(async () => {
77+
editorControl.dispatchEvent(new domWindow.MouseEvent('pointerdown', { bubbles: true, cancelable: true }));
78+
editorControl.focus();
79+
});
80+
assert.equal(pointerTargetPane, 'pane-right', 'capture activation must precede the target pointer handler');
81+
assert.equal(activationCalls, 1, 'the focus following a pointer activation must not activate the tab twice');
82+
83+
await act(async () => {
84+
root.render(
85+
React.createElement(
86+
WorkspaceTabContentInteraction,
87+
{ isActive: false, isVisible: true, onActivate: activateSplitTab },
88+
React.createElement('button', {
89+
'data-testid': 'editor-control',
90+
onKeyDown: () => {
91+
shortcutPane = activeTabStore.getState().activePane;
92+
},
93+
}),
94+
React.createElement('iframe', { title: 'embedded editor', tabIndex: 0 }),
95+
),
96+
);
97+
});
98+
99+
const rerenderedEditorControl = container.querySelector<HTMLButtonElement>('[data-testid="editor-control"]');
100+
const rerenderedEmbeddedEditor = container.querySelector<HTMLIFrameElement>('iframe');
101+
assert.ok(rerenderedEditorControl);
102+
assert.ok(rerenderedEmbeddedEditor);
103+
104+
resetActiveTab();
105+
outsideFocusTarget.focus();
106+
await act(async () => rerenderedEditorControl.focus());
107+
assert.deepEqual(
108+
activeTabStore.getState(),
109+
{ activePane: 'pane-right', activeTabId: 'tab-b' },
110+
'keyboard or programmatic focus should activate the owning pane and tab',
111+
);
112+
await act(async () => {
113+
rerenderedEditorControl.dispatchEvent(new domWindow.KeyboardEvent('keydown', { bubbles: true, key: 'w' }));
114+
});
115+
assert.equal(shortcutPane, 'pane-right', 'shortcut handlers must observe the focused pane as active');
116+
117+
await act(async () => {
118+
root.render(
119+
React.createElement(
120+
WorkspaceTabContentInteraction,
121+
{ isActive: false, isVisible: true, onActivate: activateSplitTab },
122+
React.createElement('iframe', { title: 'embedded editor', tabIndex: 0 }),
123+
),
124+
);
125+
});
126+
const innerFocusedFrame = container.querySelector<HTMLIFrameElement>('iframe');
127+
assert.ok(innerFocusedFrame?.contentDocument);
128+
const embeddedControl = innerFocusedFrame.contentDocument.createElement('button');
129+
innerFocusedFrame.contentDocument.body.append(embeddedControl);
130+
131+
resetActiveTab();
132+
activationCalls = 0;
133+
outsideFocusTarget.focus();
134+
await act(async () => embeddedControl.focus());
135+
assert.equal(innerFocusedFrame.contentDocument.activeElement, embeddedControl);
136+
assert.equal(document.activeElement, innerFocusedFrame);
137+
assert.equal(
138+
activeTabStore.getState().activePane,
139+
'pane-right',
140+
'focus inside an embedded document should activate its owning pane',
141+
);
142+
assert.equal(activationCalls, 1, 'an embedded focus transition should activate its owner once');
143+
144+
await act(async () => {
145+
root.render(
146+
React.createElement(
147+
WorkspaceTabContentInteraction,
148+
{ isActive: false, isVisible: true, onActivate: activateSplitTab },
149+
React.createElement('iframe', { title: 'embedded editor', tabIndex: 0 }),
150+
),
151+
);
152+
});
153+
resetActiveTab();
154+
activationCalls = 0;
155+
await act(async () => {
156+
domWindow.dispatchEvent(new domWindow.Event('blur'));
157+
await new Promise((resolve) => domWindow.setTimeout(resolve, 0));
158+
});
159+
assert.equal(
160+
activeTabStore.getState().activePane,
161+
'pane-right',
162+
'parent window focus transitions should recover activation for embedded documents',
163+
);
164+
assert.equal(activationCalls, 1, 'the parent focus bridge should activate the embedded owner once');
165+
166+
outsideFocusTarget.focus();
167+
const callsBeforeUnmount = activationCalls;
168+
await act(async () => root.unmount());
169+
embeddedControl.focus();
170+
assert.equal(activationCalls, callsBeforeUnmount, 'unmount must detach embedded document listeners');
171+
outsideFocusTarget.remove();
172+
container.remove();
173+
domWindow.close();
174+
console.log('Workspace tab content interaction tests passed');
175+
}
176+
177+
main().catch((error) => {
178+
console.error(error);
179+
domWindow.close();
180+
process.exit(1);
181+
});

0 commit comments

Comments
 (0)