@creezio/shell-ui 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +336 -0
  2. package/dist/adapters/nav-shell.d.ts +54 -0
  3. package/dist/adapters/nav-shell.d.ts.map +1 -0
  4. package/dist/adapters/nav-shell.js +112 -0
  5. package/dist/adapters/nav-shell.js.map +1 -0
  6. package/dist/brand.d.ts +64 -0
  7. package/dist/brand.d.ts.map +1 -0
  8. package/dist/brand.js +59 -0
  9. package/dist/brand.js.map +1 -0
  10. package/dist/core-nav.d.ts +17 -0
  11. package/dist/core-nav.d.ts.map +1 -0
  12. package/dist/core-nav.js +40 -0
  13. package/dist/core-nav.js.map +1 -0
  14. package/dist/index.d.ts +29 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/api-scopes.d.ts +24 -0
  19. package/dist/lib/api-scopes.d.ts.map +1 -0
  20. package/dist/lib/api-scopes.js +78 -0
  21. package/dist/lib/api-scopes.js.map +1 -0
  22. package/dist/lib/catalog-suspense-key.d.ts +3 -0
  23. package/dist/lib/catalog-suspense-key.d.ts.map +1 -0
  24. package/dist/lib/catalog-suspense-key.js +12 -0
  25. package/dist/lib/catalog-suspense-key.js.map +1 -0
  26. package/dist/lib/data-changed.d.ts +51 -0
  27. package/dist/lib/data-changed.d.ts.map +1 -0
  28. package/dist/lib/data-changed.js +227 -0
  29. package/dist/lib/data-changed.js.map +1 -0
  30. package/dist/lib/desktop-home-path.d.ts +14 -0
  31. package/dist/lib/desktop-home-path.d.ts.map +1 -0
  32. package/dist/lib/desktop-home-path.js +29 -0
  33. package/dist/lib/desktop-home-path.js.map +1 -0
  34. package/dist/lib/geo-distance.d.ts +6 -0
  35. package/dist/lib/geo-distance.d.ts.map +1 -0
  36. package/dist/lib/geo-distance.js +27 -0
  37. package/dist/lib/geo-distance.js.map +1 -0
  38. package/dist/lib/img.d.ts +2 -0
  39. package/dist/lib/img.d.ts.map +1 -0
  40. package/dist/lib/img.js +40 -0
  41. package/dist/lib/img.js.map +1 -0
  42. package/dist/lib/keepalive-eviction.d.ts +14 -0
  43. package/dist/lib/keepalive-eviction.d.ts.map +1 -0
  44. package/dist/lib/keepalive-eviction.js +44 -0
  45. package/dist/lib/keepalive-eviction.js.map +1 -0
  46. package/dist/lib/ops-track.d.ts +29 -0
  47. package/dist/lib/ops-track.d.ts.map +1 -0
  48. package/dist/lib/ops-track.js +37 -0
  49. package/dist/lib/ops-track.js.map +1 -0
  50. package/dist/lib/optimize-cover-url.d.ts +10 -0
  51. package/dist/lib/optimize-cover-url.d.ts.map +1 -0
  52. package/dist/lib/optimize-cover-url.js +80 -0
  53. package/dist/lib/optimize-cover-url.js.map +1 -0
  54. package/dist/lib/page-trails.d.ts +9 -0
  55. package/dist/lib/page-trails.d.ts.map +1 -0
  56. package/dist/lib/page-trails.js +20 -0
  57. package/dist/lib/page-trails.js.map +1 -0
  58. package/dist/lib/public-origin.d.ts +32 -0
  59. package/dist/lib/public-origin.d.ts.map +1 -0
  60. package/dist/lib/public-origin.js +125 -0
  61. package/dist/lib/public-origin.js.map +1 -0
  62. package/dist/lib/server-incident.d.ts +12 -0
  63. package/dist/lib/server-incident.d.ts.map +1 -0
  64. package/dist/lib/server-incident.js +72 -0
  65. package/dist/lib/server-incident.js.map +1 -0
  66. package/dist/lib/tab-document-url.d.ts +13 -0
  67. package/dist/lib/tab-document-url.d.ts.map +1 -0
  68. package/dist/lib/tab-document-url.js +60 -0
  69. package/dist/lib/tab-document-url.js.map +1 -0
  70. package/dist/lib/utils.d.ts +20 -0
  71. package/dist/lib/utils.d.ts.map +1 -0
  72. package/dist/lib/utils.js +87 -0
  73. package/dist/lib/utils.js.map +1 -0
  74. package/dist/nav-catalog.d.ts +97 -0
  75. package/dist/nav-catalog.d.ts.map +1 -0
  76. package/dist/nav-catalog.js +282 -0
  77. package/dist/nav-catalog.js.map +1 -0
  78. package/dist/registry.d.ts +11 -0
  79. package/dist/registry.d.ts.map +1 -0
  80. package/dist/registry.js +60 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/types.d.ts +22 -0
  83. package/dist/types.d.ts.map +1 -0
  84. package/dist/types.js +2 -0
  85. package/dist/types.js.map +1 -0
  86. package/dist-cjs/adapters/nav-shell.js +115 -0
  87. package/dist-cjs/adapters/nav-shell.js.map +1 -0
  88. package/dist-cjs/brand.js +66 -0
  89. package/dist-cjs/brand.js.map +1 -0
  90. package/dist-cjs/core-nav.js +43 -0
  91. package/dist-cjs/core-nav.js.map +1 -0
  92. package/dist-cjs/index.js +95 -0
  93. package/dist-cjs/index.js.map +1 -0
  94. package/dist-cjs/lib/api-scopes.js +86 -0
  95. package/dist-cjs/lib/api-scopes.js.map +1 -0
  96. package/dist-cjs/lib/catalog-suspense-key.js +15 -0
  97. package/dist-cjs/lib/catalog-suspense-key.js.map +1 -0
  98. package/dist-cjs/lib/data-changed.js +236 -0
  99. package/dist-cjs/lib/data-changed.js.map +1 -0
  100. package/dist-cjs/lib/desktop-home-path.js +34 -0
  101. package/dist-cjs/lib/desktop-home-path.js.map +1 -0
  102. package/dist-cjs/lib/geo-distance.js +30 -0
  103. package/dist-cjs/lib/geo-distance.js.map +1 -0
  104. package/dist-cjs/lib/img.js +43 -0
  105. package/dist-cjs/lib/img.js.map +1 -0
  106. package/dist-cjs/lib/keepalive-eviction.js +49 -0
  107. package/dist-cjs/lib/keepalive-eviction.js.map +1 -0
  108. package/dist-cjs/lib/ops-track.js +41 -0
  109. package/dist-cjs/lib/ops-track.js.map +1 -0
  110. package/dist-cjs/lib/optimize-cover-url.js +83 -0
  111. package/dist-cjs/lib/optimize-cover-url.js.map +1 -0
  112. package/dist-cjs/lib/page-trails.js +25 -0
  113. package/dist-cjs/lib/page-trails.js.map +1 -0
  114. package/dist-cjs/lib/public-origin.js +130 -0
  115. package/dist-cjs/lib/public-origin.js.map +1 -0
  116. package/dist-cjs/lib/server-incident.js +75 -0
  117. package/dist-cjs/lib/server-incident.js.map +1 -0
  118. package/dist-cjs/lib/tab-document-url.js +65 -0
  119. package/dist-cjs/lib/tab-document-url.js.map +1 -0
  120. package/dist-cjs/lib/utils.js +97 -0
  121. package/dist-cjs/lib/utils.js.map +1 -0
  122. package/dist-cjs/nav-catalog.js +293 -0
  123. package/dist-cjs/nav-catalog.js.map +1 -0
  124. package/dist-cjs/package.json +3 -0
  125. package/dist-cjs/registry.js +64 -0
  126. package/dist-cjs/registry.js.map +1 -0
  127. package/dist-cjs/types.js +3 -0
  128. package/dist-cjs/types.js.map +1 -0
  129. package/package.json +163 -0
  130. package/ui/app-error-boundary.tsx +59 -0
  131. package/ui/data-table.tsx +121 -0
  132. package/ui/desktop/auth-window-chrome.tsx +51 -0
  133. package/ui/desktop/desktop-bridge.tsx +87 -0
  134. package/ui/desktop/external-site-slot.tsx +313 -0
  135. package/ui/desktop/external-site-surface.ts +58 -0
  136. package/ui/desktop/site-link.tsx +105 -0
  137. package/ui/desktop/window-chrome-controls.tsx +5 -0
  138. package/ui/desktop-types.ts +30 -0
  139. package/ui/faceted-filters.tsx +119 -0
  140. package/ui/global-search-host.ts +20 -0
  141. package/ui/global-search.tsx +66 -0
  142. package/ui/index.ts +178 -0
  143. package/ui/kit.ts +30 -0
  144. package/ui/layout/app-shell.tsx +58 -0
  145. package/ui/layout/desktop-update-banner.tsx +100 -0
  146. package/ui/layout/entity-header.tsx +44 -0
  147. package/ui/layout/native-os-nav.ts +104 -0
  148. package/ui/layout/nav-catalog-loader.tsx +98 -0
  149. package/ui/layout/nav-icons.ts +67 -0
  150. package/ui/layout/page-chrome.tsx +90 -0
  151. package/ui/layout/page-toolbar-context.tsx +82 -0
  152. package/ui/layout/sandbox-banner.tsx +38 -0
  153. package/ui/layout/section-view-shell.tsx +73 -0
  154. package/ui/layout/sidebar-host.ts +144 -0
  155. package/ui/layout/sidebar.tsx +1150 -0
  156. package/ui/lib/ai-screencast-hub.ts +127 -0
  157. package/ui/lib/ai-workspace-client.ts +46 -0
  158. package/ui/lib/aid.ts +22 -0
  159. package/ui/lib/desktop-host.ts +114 -0
  160. package/ui/lib/fleet-tracker-client.ts +237 -0
  161. package/ui/lib/hermes-ui.ts +114 -0
  162. package/ui/lib/n8n-ui.ts +115 -0
  163. package/ui/lib/use-creezio-resource.tsx +121 -0
  164. package/ui/lib/use-shell-ui-brand.ts +22 -0
  165. package/ui/list-toolbar.tsx +309 -0
  166. package/ui/os-pages/desktop-settings-page.tsx +158 -0
  167. package/ui/os-pages/index.ts +8 -0
  168. package/ui/page-loading/entity-page-loading.tsx +59 -0
  169. package/ui/page-loading/list-page-loading.tsx +61 -0
  170. package/ui/pagination.tsx +56 -0
  171. package/ui/primitives/avatar.tsx +33 -0
  172. package/ui/primitives/badge.tsx +36 -0
  173. package/ui/primitives/breadcrumb.tsx +93 -0
  174. package/ui/primitives/button.tsx +44 -0
  175. package/ui/primitives/card.tsx +44 -0
  176. package/ui/primitives/chart.tsx +317 -0
  177. package/ui/primitives/command.tsx +111 -0
  178. package/ui/primitives/dialog.tsx +100 -0
  179. package/ui/primitives/dropdown-menu.tsx +187 -0
  180. package/ui/primitives/input.tsx +19 -0
  181. package/ui/primitives/label.tsx +17 -0
  182. package/ui/primitives/resizable.tsx +45 -0
  183. package/ui/primitives/scroll-area.tsx +41 -0
  184. package/ui/primitives/select.tsx +73 -0
  185. package/ui/primitives/separator.tsx +26 -0
  186. package/ui/primitives/sheet.tsx +90 -0
  187. package/ui/primitives/skeleton.tsx +7 -0
  188. package/ui/primitives/sonner.tsx +22 -0
  189. package/ui/primitives/tabs.tsx +51 -0
  190. package/ui/primitives/textarea.tsx +22 -0
  191. package/ui/primitives/tooltip.tsx +31 -0
  192. package/ui/pwa/client-error-reporter.tsx +62 -0
  193. package/ui/pwa/register-sw.tsx +50 -0
  194. package/ui/range-filters.tsx +174 -0
  195. package/ui/search/global-search-config.ts +32 -0
  196. package/ui/search/global-search-provider.tsx +528 -0
  197. package/ui/search/search-history.ts +74 -0
  198. package/ui/search-input.tsx +146 -0
  199. package/ui/settings/account-settings.tsx +140 -0
  200. package/ui/settings/agent-profile-settings.tsx +184 -0
  201. package/ui/settings/api-keys-settings.tsx +349 -0
  202. package/ui/settings/desktop-background-settings.tsx +128 -0
  203. package/ui/settings/desktop-connection-settings.tsx +251 -0
  204. package/ui/settings/desktop-embed-env-panel.tsx +257 -0
  205. package/ui/settings/desktop-fleet-telemetry-settings.tsx +269 -0
  206. package/ui/settings/desktop-hermes-settings.tsx +341 -0
  207. package/ui/settings/desktop-llm-keys.tsx +192 -0
  208. package/ui/settings/desktop-n8n-settings.tsx +306 -0
  209. package/ui/settings/desktop-tunnel.tsx +264 -0
  210. package/ui/settings/desktop-update-settings.tsx +175 -0
  211. package/ui/settings/factory-reset-settings.tsx +89 -0
  212. package/ui/settings/host-only-settings.tsx +62 -0
  213. package/ui/settings/locked-config-field.tsx +30 -0
  214. package/ui/settings/ops-diagnostic-settings.tsx +242 -0
  215. package/ui/settings/search-reindex-settings.tsx +158 -0
  216. package/ui/settings/server-mode-cards.tsx +801 -0
  217. package/ui/theme/tailwind-preset.cjs +82 -0
  218. package/ui/theme/theme.css +328 -0
  219. package/ui/workspace/ai-activity-panel-host.tsx +19 -0
  220. package/ui/workspace/ai-workspace-agent-host.tsx +54 -0
  221. package/ui/workspace/ai-workspace-banner.tsx +168 -0
  222. package/ui/workspace/impersonation-banner.tsx +39 -0
  223. package/ui/workspace/keep-alive.tsx +220 -0
  224. package/ui/workspace/tab-workspace-context.tsx +1172 -0
  225. package/ui/workspace/tab-workspace-host.ts +89 -0
  226. package/ui/workspace/types.ts +500 -0
  227. package/ui/workspace/use-location-search.tsx +50 -0
  228. package/ui/workspace/window-chrome-controls.tsx +116 -0
  229. package/ui/workspace/workspace-config.ts +47 -0
  230. package/ui/workspace/workspace-root.tsx +147 -0
  231. package/ui/workspace/workspace-shell.tsx +292 -0
  232. package/ui/workspace/workspace-tab-bar.tsx +572 -0
@@ -0,0 +1,572 @@
1
+ "use client";
2
+
3
+ import { getShellDesktopApi, getShellUiBrand } from "@creezio/shell-ui";
4
+
5
+ import {
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type MouseEvent as ReactMouseEvent,
13
+ type PointerEvent as ReactPointerEvent,
14
+ } from "react";
15
+ import {
16
+ ChevronLeft,
17
+ ChevronRight,
18
+ FileText,
19
+ Globe,
20
+ LayoutDashboard,
21
+ Lock,
22
+ LockOpen,
23
+ Pin,
24
+ Plus,
25
+ X,
26
+ type LucideIcon,
27
+ } from "lucide-react";
28
+ import { cn } from "@creezio/shell-ui";
29
+ import { PageChrome } from "../layout/page-chrome";
30
+ import { useGlobalSearch } from "../global-search-host";
31
+ import { useTabWorkspace } from "./tab-workspace-host";
32
+ import { WindowChromeControls } from "./window-chrome-controls";
33
+ import { resolvePageKind, isFullscreenHref, type WorkspaceTab } from "./types";
34
+
35
+ /* Géométrie type Chrome : onglets posés en bas du bandeau, largeur adaptative. */
36
+ const TAB_MAX = 240;
37
+ const TAB_MIN = 64;
38
+ const TAB_ABS_MIN = 46;
39
+ const TAB_GAP = 0;
40
+ const NEW_BTN = 28;
41
+ const NEW_BTN_GAP = 6;
42
+ const DRAG_THRESHOLD = 5;
43
+ /** Marge gauche du strip : laisse la place à la courbe inversée du 1er onglet. */
44
+ const PAD_L = 8;
45
+
46
+ /**
47
+ * Icônes onglets = plateforme neutre uniquement.
48
+ * Routes métier (panier, fournisseurs, relevés…) → injecter via
49
+ * `configureWorkspaceTabIcons` depuis la marque (pas dans le kit).
50
+ */
51
+ let brandIconForSeg: ((seg: string) => LucideIcon | null | undefined) | null =
52
+ null;
53
+
54
+ export function configureWorkspaceTabIcons(
55
+ fn: ((seg: string) => LucideIcon | null | undefined) | null,
56
+ ): void {
57
+ brandIconForSeg = fn;
58
+ }
59
+
60
+ function iconForHref(href: string) {
61
+ const seg = (href.split("?")[0] || "/").split("/").filter(Boolean)[0] || "";
62
+ const fromBrand = brandIconForSeg?.(seg);
63
+ if (fromBrand) return fromBrand;
64
+ switch (seg) {
65
+ case "dashboard":
66
+ return LayoutDashboard;
67
+ case "site":
68
+ return Globe;
69
+ default:
70
+ return FileText;
71
+ }
72
+ }
73
+
74
+ type DragState = {
75
+ id: string;
76
+ pointerId: number;
77
+ startX: number;
78
+ originLeft: number;
79
+ moved: boolean;
80
+ dx: number;
81
+ };
82
+
83
+ type TabMenuState = {
84
+ tabId: string;
85
+ x: number;
86
+ y: number;
87
+ } | null;
88
+
89
+ export function WorkspaceTabBar() {
90
+ const {
91
+ tabs,
92
+ activeTabId,
93
+ activateTab,
94
+ closeTab,
95
+ setTabLocked,
96
+ moveTab,
97
+ goBack,
98
+ goForward,
99
+ canGoBack,
100
+ canGoForward,
101
+ activeTab,
102
+ pinnedTabId,
103
+ tabBadges,
104
+ } = useTabWorkspace();
105
+ const { openForNewTab } = useGlobalSearch();
106
+
107
+ const stripRef = useRef<HTMLDivElement>(null);
108
+ const [stripW, setStripW] = useState(0);
109
+ const [animate, setAnimate] = useState(false);
110
+ const [drag, setDrag] = useState<DragState | null>(null);
111
+ const [menu, setMenu] = useState<TabMenuState>(null);
112
+ const dragRef = useRef<DragState | null>(null);
113
+ const menuRef = useRef<HTMLDivElement>(null);
114
+ const tabsRef = useRef(tabs);
115
+ tabsRef.current = tabs;
116
+
117
+ // Ids déjà vus → les nouveaux onglets ont une animation d'entrée
118
+ const seenIdsRef = useRef<Set<string>>(new Set());
119
+
120
+ useLayoutEffect(() => {
121
+ const el = stripRef.current;
122
+ if (!el) return;
123
+ const measure = () => setStripW(el.clientWidth);
124
+ measure();
125
+ const ro = new ResizeObserver(measure);
126
+ ro.observe(el);
127
+ return () => ro.disconnect();
128
+ }, []);
129
+
130
+ // Active les transitions seulement après la première mesure (pas d'anim au chargement)
131
+ useEffect(() => {
132
+ if (!stripW || animate) return;
133
+ const raf = requestAnimationFrame(() =>
134
+ requestAnimationFrame(() => setAnimate(true)),
135
+ );
136
+ return () => cancelAnimationFrame(raf);
137
+ }, [stripW, animate]);
138
+
139
+ useEffect(() => {
140
+ if (!menu) return;
141
+ function close() {
142
+ setMenu(null);
143
+ }
144
+ function onKey(e: KeyboardEvent) {
145
+ if (e.key === "Escape") close();
146
+ }
147
+ function onPointer(e: MouseEvent) {
148
+ if (menuRef.current?.contains(e.target as Node)) return;
149
+ close();
150
+ }
151
+ window.addEventListener("keydown", onKey);
152
+ window.addEventListener("mousedown", onPointer);
153
+ window.addEventListener("scroll", close, true);
154
+ const focusTimer = window.setTimeout(() => {
155
+ menuRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
156
+ }, 0);
157
+ return () => {
158
+ window.removeEventListener("keydown", onKey);
159
+ window.removeEventListener("mousedown", onPointer);
160
+ window.removeEventListener("scroll", close, true);
161
+ window.clearTimeout(focusTimer);
162
+ };
163
+ }, [menu]);
164
+
165
+ const count = tabs.length || 1;
166
+ const avail = Math.max(0, stripW - PAD_L - NEW_BTN - NEW_BTN_GAP);
167
+ const tabW = stripW
168
+ ? Math.max(
169
+ TAB_ABS_MIN,
170
+ Math.min(TAB_MAX, Math.max(TAB_MIN, Math.floor(avail / count))),
171
+ )
172
+ : 200;
173
+ const slotW = tabW + TAB_GAP;
174
+ const compact = tabW < 90;
175
+
176
+ const orderIndex = useMemo(() => {
177
+ const m = new Map<string, number>();
178
+ tabs.forEach((t: WorkspaceTab, i: number) => m.set(t.id, i));
179
+ return m;
180
+ }, [tabs]);
181
+
182
+ const clampDragLeft = useCallback(
183
+ (left: number) => {
184
+ const max = PAD_L + Math.max(0, (tabsRef.current.length - 1) * slotW);
185
+ return Math.min(Math.max(PAD_L, left), max);
186
+ },
187
+ [slotW],
188
+ );
189
+
190
+ const onTabPointerDown = useCallback(
191
+ (e: ReactPointerEvent<HTMLDivElement>, tabId: string) => {
192
+ if (e.button !== 0) return;
193
+ // Chrome active l'onglet dès le mousedown
194
+ activateTab(tabId);
195
+ const idx = tabsRef.current.findIndex((t: WorkspaceTab) => t.id === tabId);
196
+ if (idx < 0) return;
197
+ const state: DragState = {
198
+ id: tabId,
199
+ pointerId: e.pointerId,
200
+ startX: e.clientX,
201
+ originLeft: PAD_L + idx * slotW,
202
+ moved: false,
203
+ dx: 0,
204
+ };
205
+ dragRef.current = state;
206
+ try {
207
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
208
+ } catch {
209
+ /* pointerId synthétique (tests) — le drag suit quand même les pointermove */
210
+ }
211
+ },
212
+ [activateTab, slotW],
213
+ );
214
+
215
+ const onTabPointerMove = useCallback(
216
+ (e: ReactPointerEvent<HTMLDivElement>) => {
217
+ const st = dragRef.current;
218
+ if (!st || e.pointerId !== st.pointerId) return;
219
+ const dx = e.clientX - st.startX;
220
+ if (!st.moved && Math.abs(dx) < DRAG_THRESHOLD) return;
221
+ const next = { ...st, moved: true, dx };
222
+ dragRef.current = next;
223
+ setDrag(next);
224
+
225
+ // Slot cible = centre de l'onglet déplacé
226
+ const left = clampDragLeft(st.originLeft + dx);
227
+ const target = Math.min(
228
+ tabsRef.current.length - 1,
229
+ Math.max(0, Math.round((left - PAD_L) / slotW)),
230
+ );
231
+ const current = tabsRef.current.findIndex((t: WorkspaceTab) => t.id === st.id);
232
+ if (target !== current) moveTab(st.id, target);
233
+ },
234
+ [clampDragLeft, moveTab, slotW],
235
+ );
236
+
237
+ const endDrag = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
238
+ const st = dragRef.current;
239
+ if (!st || e.pointerId !== st.pointerId) return;
240
+ dragRef.current = null;
241
+ setDrag(null);
242
+ }, []);
243
+
244
+ const onTitlebarDoubleClick = useCallback(() => {
245
+ if (!getShellDesktopApi()?.customWindowChrome) return;
246
+ void getShellDesktopApi().toggleMaximizeWindow?.();
247
+ }, []);
248
+
249
+ const onTabContextMenu = useCallback(
250
+ (e: ReactMouseEvent<HTMLDivElement>, tabId: string) => {
251
+ e.preventDefault();
252
+ e.stopPropagation();
253
+ dragRef.current = null;
254
+ setDrag(null);
255
+ const rect = e.currentTarget.getBoundingClientRect();
256
+ const menuWidth = 190;
257
+ const menuHeight = tabId === pinnedTabId ? 48 : 92;
258
+ const pad = 8;
259
+ const anchorX = e.clientX || rect.left + Math.min(rect.width / 2, 48);
260
+ const anchorY = e.clientY || rect.bottom;
261
+ setMenu({
262
+ tabId,
263
+ x: Math.max(pad, Math.min(anchorX, window.innerWidth - menuWidth - pad)),
264
+ y: Math.max(pad, Math.min(anchorY, window.innerHeight - menuHeight - pad)),
265
+ });
266
+ },
267
+ [pinnedTabId],
268
+ );
269
+
270
+ const menuTab = menu ? tabs.find((tab: WorkspaceTab) => tab.id === menu.tabId) : null;
271
+
272
+ return (
273
+ <div className="flex min-w-0 flex-col">
274
+ {/* Bandeau navigateur — drag Electron (frameless Windows) */}
275
+ <div
276
+ className={`${getShellUiBrand().titlebarDragClass} flex h-10 min-w-0 items-stretch`}
277
+ onDoubleClick={onTitlebarDoubleClick}
278
+ >
279
+ {/* Toolbar ← → */}
280
+ <div className={`${getShellUiBrand().titlebarNoDragClass} flex shrink-0 items-center gap-0.5 self-center pr-1`}>
281
+ <button
282
+ type="button"
283
+ disabled={!canGoBack}
284
+ onClick={goBack}
285
+ aria-label="Page précédente"
286
+ title="Précédent"
287
+ className={cn(
288
+ "flex h-7 w-7 items-center justify-center rounded-full text-slate-600 transition-colors",
289
+ canGoBack
290
+ ? "hover:bg-slate-400/25 hover:text-slate-900 active:bg-slate-400/40"
291
+ : "cursor-default opacity-35",
292
+ )}
293
+ >
294
+ <ChevronLeft className="h-4 w-4" />
295
+ </button>
296
+ <button
297
+ type="button"
298
+ disabled={!canGoForward}
299
+ onClick={goForward}
300
+ aria-label="Page suivante"
301
+ title="Suivant"
302
+ className={cn(
303
+ "flex h-7 w-7 items-center justify-center rounded-full text-slate-600 transition-colors",
304
+ canGoForward
305
+ ? "hover:bg-slate-400/25 hover:text-slate-900 active:bg-slate-400/40"
306
+ : "cursor-default opacity-35",
307
+ )}
308
+ >
309
+ <ChevronRight className="h-4 w-4" />
310
+ </button>
311
+ </div>
312
+
313
+ {/* Rangée d'onglets — layout mesuré, positions animées par transform */}
314
+ <div
315
+ ref={stripRef}
316
+ className="relative min-w-0 flex-1 overflow-hidden"
317
+ role="tablist"
318
+ aria-label="Onglets workspace"
319
+ >
320
+ {tabs.map((tab: WorkspaceTab) => {
321
+ const idx = orderIndex.get(tab.id) ?? 0;
322
+ const active = tab.id === activeTabId;
323
+ const pinned = tab.id === pinnedTabId;
324
+ const locked = pinned || tab.locked === true;
325
+ const badge = tabBadges[tab.id] || 0;
326
+ const dragging = drag?.moved && drag.id === tab.id;
327
+ const left = dragging
328
+ ? clampDragLeft(drag!.originLeft + drag!.dx)
329
+ : PAD_L + idx * slotW;
330
+ const isNew = !seenIdsRef.current.has(tab.id);
331
+ if (stripW) seenIdsRef.current.add(tab.id);
332
+ const Icon = iconForHref(tab.href);
333
+ const showClose = !locked && (!compact || active);
334
+
335
+ return (
336
+ <div
337
+ key={tab.id}
338
+ role="tab"
339
+ aria-selected={active}
340
+ data-state={active ? "active" : "inactive"}
341
+ data-tab-locked={locked ? "true" : "false"}
342
+ onPointerDown={(e) => onTabPointerDown(e, tab.id)}
343
+ onPointerMove={onTabPointerMove}
344
+ onPointerUp={endDrag}
345
+ onPointerCancel={endDrag}
346
+ onContextMenu={(e) => onTabContextMenu(e, tab.id)}
347
+ onAuxClick={(e) => {
348
+ if (e.button === 1) {
349
+ e.preventDefault();
350
+ if (!locked) closeTab(tab.id);
351
+ }
352
+ }}
353
+ title={
354
+ `${tab.subtitle ? `${tab.title} — ${tab.subtitle}` : tab.title}${
355
+ locked ? " — Verrouillé" : ""
356
+ }`
357
+ }
358
+ className={cn(
359
+ `${getShellUiBrand().titlebarNoDragClass} tf-tab group absolute bottom-0 h-[34px] cursor-default select-none [touch-action:none]`,
360
+ animate &&
361
+ !dragging &&
362
+ "transition-[transform,width] duration-200 ease-out",
363
+ dragging && "z-20",
364
+ active && !dragging && "z-10",
365
+ active && "is-active",
366
+ )}
367
+ style={{
368
+ width: tabW,
369
+ transform: `translateX(${left}px)`,
370
+ }}
371
+ >
372
+ {/* Séparateur gauche (masqué près de l'actif / au hover) */}
373
+ <span
374
+ aria-hidden
375
+ className="tf-sep pointer-events-none absolute left-0 top-[9px] h-4 w-px bg-slate-500/40 transition-opacity duration-100"
376
+ />
377
+
378
+ {/* Fond de l'onglet actif : blanc + courbes inversées, fusion avec la page */}
379
+ {active ? (
380
+ <span
381
+ aria-hidden
382
+ className="tf-tab-bg pointer-events-none absolute inset-0 rounded-t-lg bg-white shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
383
+ />
384
+ ) : (
385
+ <span
386
+ aria-hidden
387
+ className={cn(
388
+ "pointer-events-none absolute inset-x-0 bottom-[2px] top-0 rounded-lg transition-colors duration-100",
389
+ dragging
390
+ ? "bg-white/70 shadow-[0_2px_8px_rgba(0,0,0,0.18)]"
391
+ : "group-hover:bg-[rgba(255,255,255,0.55)]",
392
+ )}
393
+ />
394
+ )}
395
+
396
+ {/* Contenu */}
397
+ <div
398
+ className={cn(
399
+ "relative flex h-full min-w-0 items-center pl-2.5",
400
+ showClose ? "pr-1" : "pr-2",
401
+ isNew && animate && "tf-tab-enter",
402
+ )}
403
+ >
404
+ {pinned ? (
405
+ <Pin
406
+ className={cn(
407
+ "h-[13px] w-[13px] shrink-0 fill-current",
408
+ active ? "text-sky-600" : "text-slate-500",
409
+ )}
410
+ aria-label="Onglet épinglé"
411
+ />
412
+ ) : locked ? (
413
+ <Lock
414
+ className={cn(
415
+ "h-[13px] w-[13px] shrink-0",
416
+ active ? "text-sky-600" : "text-slate-500",
417
+ )}
418
+ aria-label="Onglet verrouillé"
419
+ />
420
+ ) : (
421
+ <Icon
422
+ className={cn(
423
+ "h-[15px] w-[15px] shrink-0",
424
+ active ? "text-slate-700" : "text-slate-500",
425
+ )}
426
+ />
427
+ )}
428
+ {tabW >= 72 ? (
429
+ <span
430
+ className={cn(
431
+ "tf-tab-title ml-2 min-w-0 flex-1 truncate text-[12px] leading-none tracking-tight",
432
+ active
433
+ ? "font-medium text-slate-900"
434
+ : "text-slate-700",
435
+ )}
436
+ >
437
+ {tab.title}
438
+ </span>
439
+ ) : (
440
+ <span className="min-w-0 flex-1" />
441
+ )}
442
+ {badge > 0 ? (
443
+ <span
444
+ aria-label={`${badge} nouveauté${badge > 1 ? "s" : ""}`}
445
+ className={cn(
446
+ "pointer-events-none absolute -top-0.5 z-30 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white shadow-sm ring-2 ring-[#ebe7df]",
447
+ showClose ? "right-1" : "right-1.5",
448
+ )}
449
+ >
450
+ {badge > 9 ? "9+" : badge}
451
+ </span>
452
+ ) : null}
453
+ {showClose ? (
454
+ <button
455
+ type="button"
456
+ tabIndex={-1}
457
+ onPointerDown={(e) => e.stopPropagation()}
458
+ onClick={(e) => {
459
+ e.stopPropagation();
460
+ closeTab(tab.id);
461
+ }}
462
+ className={cn(
463
+ "ml-0.5 mr-1 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full transition-colors",
464
+ active
465
+ ? "text-slate-500 hover:bg-slate-200 hover:text-slate-800 active:bg-slate-300"
466
+ : "text-slate-500 opacity-0 hover:bg-slate-400/30 hover:text-slate-800 focus:opacity-100 group-hover:opacity-100",
467
+ )}
468
+ aria-label={`Fermer ${tab.title}`}
469
+ title="Fermer"
470
+ >
471
+ <X className="h-3 w-3" strokeWidth={2.25} />
472
+ </button>
473
+ ) : null}
474
+ </div>
475
+ </div>
476
+ );
477
+ })}
478
+
479
+ {/* Bouton nouvel onglet — ouvre la recherche, le résultat choisi
480
+ s'ouvrira dans un nouvel onglet (façon Notion) */}
481
+ <button
482
+ type="button"
483
+ onClick={openForNewTab}
484
+ aria-label="Nouvel onglet"
485
+ title="Nouvel onglet (recherche)"
486
+ className={cn(
487
+ `${getShellUiBrand().titlebarNoDragClass} absolute bottom-[6px] flex h-7 w-7 items-center justify-center rounded-full text-slate-600 hover:bg-slate-400/25 hover:text-slate-900 active:bg-slate-400/40`,
488
+ animate && "transition-transform duration-200 ease-out",
489
+ )}
490
+ style={{
491
+ transform: `translateX(${Math.min(
492
+ PAD_L + count * slotW + NEW_BTN_GAP,
493
+ Math.max(0, stripW - NEW_BTN),
494
+ )}px)`,
495
+ }}
496
+ >
497
+ <Plus className="h-4 w-4" />
498
+ </button>
499
+ </div>
500
+
501
+ <WindowChromeControls />
502
+ </div>
503
+
504
+ {menu && menuTab ? (
505
+ <div
506
+ ref={menuRef}
507
+ role="menu"
508
+ aria-label={`Actions pour l’onglet ${menuTab.title}`}
509
+ className={`${getShellUiBrand().titlebarNoDragClass} fixed z-[100] min-w-[11.875rem] rounded-md border border-slate-200 bg-white p-1 text-slate-900 shadow-lg`}
510
+ style={{ left: menu.x, top: menu.y }}
511
+ >
512
+ {menuTab.id === pinnedTabId ? (
513
+ <button
514
+ type="button"
515
+ role="menuitem"
516
+ disabled
517
+ className="flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-slate-500 opacity-70"
518
+ >
519
+ <Lock className="h-3.5 w-3.5" />
520
+ Verrouillé par défaut
521
+ </button>
522
+ ) : (
523
+ <>
524
+ <button
525
+ type="button"
526
+ role="menuitem"
527
+ onClick={() => {
528
+ setTabLocked(menuTab.id, !menuTab.locked);
529
+ setMenu(null);
530
+ }}
531
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-slate-100 focus:bg-slate-100"
532
+ >
533
+ {menuTab.locked ? (
534
+ <LockOpen className="h-3.5 w-3.5" />
535
+ ) : (
536
+ <Lock className="h-3.5 w-3.5" />
537
+ )}
538
+ {menuTab.locked ? "Déverrouiller" : "Verrouiller"}
539
+ </button>
540
+ {!menuTab.locked ? (
541
+ <>
542
+ <div className="my-1 h-px bg-slate-100" role="separator" />
543
+ <button
544
+ type="button"
545
+ role="menuitem"
546
+ onClick={() => {
547
+ closeTab(menuTab.id);
548
+ setMenu(null);
549
+ }}
550
+ className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-slate-100 focus:bg-slate-100"
551
+ >
552
+ <X className="h-3.5 w-3.5" />
553
+ Fermer
554
+ </button>
555
+ </>
556
+ ) : null}
557
+ </>
558
+ )}
559
+ </div>
560
+ ) : null}
561
+
562
+ {/* Bandeau sticky sous les onglets — recherche + trail (entity) + actions */}
563
+ {activeTab && !activeTab.fullscreen && !isFullscreenHref(activeTab.href) ? (
564
+ <PageChrome
565
+ kind={resolvePageKind(activeTab)}
566
+ href={activeTab.href}
567
+ trail={activeTab.trail}
568
+ />
569
+ ) : null}
570
+ </div>
571
+ );
572
+ }