@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.
- package/README.md +336 -0
- package/dist/adapters/nav-shell.d.ts +54 -0
- package/dist/adapters/nav-shell.d.ts.map +1 -0
- package/dist/adapters/nav-shell.js +112 -0
- package/dist/adapters/nav-shell.js.map +1 -0
- package/dist/brand.d.ts +64 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +59 -0
- package/dist/brand.js.map +1 -0
- package/dist/core-nav.d.ts +17 -0
- package/dist/core-nav.d.ts.map +1 -0
- package/dist/core-nav.js +40 -0
- package/dist/core-nav.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-scopes.d.ts +24 -0
- package/dist/lib/api-scopes.d.ts.map +1 -0
- package/dist/lib/api-scopes.js +78 -0
- package/dist/lib/api-scopes.js.map +1 -0
- package/dist/lib/catalog-suspense-key.d.ts +3 -0
- package/dist/lib/catalog-suspense-key.d.ts.map +1 -0
- package/dist/lib/catalog-suspense-key.js +12 -0
- package/dist/lib/catalog-suspense-key.js.map +1 -0
- package/dist/lib/data-changed.d.ts +51 -0
- package/dist/lib/data-changed.d.ts.map +1 -0
- package/dist/lib/data-changed.js +227 -0
- package/dist/lib/data-changed.js.map +1 -0
- package/dist/lib/desktop-home-path.d.ts +14 -0
- package/dist/lib/desktop-home-path.d.ts.map +1 -0
- package/dist/lib/desktop-home-path.js +29 -0
- package/dist/lib/desktop-home-path.js.map +1 -0
- package/dist/lib/geo-distance.d.ts +6 -0
- package/dist/lib/geo-distance.d.ts.map +1 -0
- package/dist/lib/geo-distance.js +27 -0
- package/dist/lib/geo-distance.js.map +1 -0
- package/dist/lib/img.d.ts +2 -0
- package/dist/lib/img.d.ts.map +1 -0
- package/dist/lib/img.js +40 -0
- package/dist/lib/img.js.map +1 -0
- package/dist/lib/keepalive-eviction.d.ts +14 -0
- package/dist/lib/keepalive-eviction.d.ts.map +1 -0
- package/dist/lib/keepalive-eviction.js +44 -0
- package/dist/lib/keepalive-eviction.js.map +1 -0
- package/dist/lib/ops-track.d.ts +29 -0
- package/dist/lib/ops-track.d.ts.map +1 -0
- package/dist/lib/ops-track.js +37 -0
- package/dist/lib/ops-track.js.map +1 -0
- package/dist/lib/optimize-cover-url.d.ts +10 -0
- package/dist/lib/optimize-cover-url.d.ts.map +1 -0
- package/dist/lib/optimize-cover-url.js +80 -0
- package/dist/lib/optimize-cover-url.js.map +1 -0
- package/dist/lib/page-trails.d.ts +9 -0
- package/dist/lib/page-trails.d.ts.map +1 -0
- package/dist/lib/page-trails.js +20 -0
- package/dist/lib/page-trails.js.map +1 -0
- package/dist/lib/public-origin.d.ts +32 -0
- package/dist/lib/public-origin.d.ts.map +1 -0
- package/dist/lib/public-origin.js +125 -0
- package/dist/lib/public-origin.js.map +1 -0
- package/dist/lib/server-incident.d.ts +12 -0
- package/dist/lib/server-incident.d.ts.map +1 -0
- package/dist/lib/server-incident.js +72 -0
- package/dist/lib/server-incident.js.map +1 -0
- package/dist/lib/tab-document-url.d.ts +13 -0
- package/dist/lib/tab-document-url.d.ts.map +1 -0
- package/dist/lib/tab-document-url.js +60 -0
- package/dist/lib/tab-document-url.js.map +1 -0
- package/dist/lib/utils.d.ts +20 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +87 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/nav-catalog.d.ts +97 -0
- package/dist/nav-catalog.d.ts.map +1 -0
- package/dist/nav-catalog.js +282 -0
- package/dist/nav-catalog.js.map +1 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +60 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist-cjs/adapters/nav-shell.js +115 -0
- package/dist-cjs/adapters/nav-shell.js.map +1 -0
- package/dist-cjs/brand.js +66 -0
- package/dist-cjs/brand.js.map +1 -0
- package/dist-cjs/core-nav.js +43 -0
- package/dist-cjs/core-nav.js.map +1 -0
- package/dist-cjs/index.js +95 -0
- package/dist-cjs/index.js.map +1 -0
- package/dist-cjs/lib/api-scopes.js +86 -0
- package/dist-cjs/lib/api-scopes.js.map +1 -0
- package/dist-cjs/lib/catalog-suspense-key.js +15 -0
- package/dist-cjs/lib/catalog-suspense-key.js.map +1 -0
- package/dist-cjs/lib/data-changed.js +236 -0
- package/dist-cjs/lib/data-changed.js.map +1 -0
- package/dist-cjs/lib/desktop-home-path.js +34 -0
- package/dist-cjs/lib/desktop-home-path.js.map +1 -0
- package/dist-cjs/lib/geo-distance.js +30 -0
- package/dist-cjs/lib/geo-distance.js.map +1 -0
- package/dist-cjs/lib/img.js +43 -0
- package/dist-cjs/lib/img.js.map +1 -0
- package/dist-cjs/lib/keepalive-eviction.js +49 -0
- package/dist-cjs/lib/keepalive-eviction.js.map +1 -0
- package/dist-cjs/lib/ops-track.js +41 -0
- package/dist-cjs/lib/ops-track.js.map +1 -0
- package/dist-cjs/lib/optimize-cover-url.js +83 -0
- package/dist-cjs/lib/optimize-cover-url.js.map +1 -0
- package/dist-cjs/lib/page-trails.js +25 -0
- package/dist-cjs/lib/page-trails.js.map +1 -0
- package/dist-cjs/lib/public-origin.js +130 -0
- package/dist-cjs/lib/public-origin.js.map +1 -0
- package/dist-cjs/lib/server-incident.js +75 -0
- package/dist-cjs/lib/server-incident.js.map +1 -0
- package/dist-cjs/lib/tab-document-url.js +65 -0
- package/dist-cjs/lib/tab-document-url.js.map +1 -0
- package/dist-cjs/lib/utils.js +97 -0
- package/dist-cjs/lib/utils.js.map +1 -0
- package/dist-cjs/nav-catalog.js +293 -0
- package/dist-cjs/nav-catalog.js.map +1 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/registry.js +64 -0
- package/dist-cjs/registry.js.map +1 -0
- package/dist-cjs/types.js +3 -0
- package/dist-cjs/types.js.map +1 -0
- package/package.json +163 -0
- package/ui/app-error-boundary.tsx +59 -0
- package/ui/data-table.tsx +121 -0
- package/ui/desktop/auth-window-chrome.tsx +51 -0
- package/ui/desktop/desktop-bridge.tsx +87 -0
- package/ui/desktop/external-site-slot.tsx +313 -0
- package/ui/desktop/external-site-surface.ts +58 -0
- package/ui/desktop/site-link.tsx +105 -0
- package/ui/desktop/window-chrome-controls.tsx +5 -0
- package/ui/desktop-types.ts +30 -0
- package/ui/faceted-filters.tsx +119 -0
- package/ui/global-search-host.ts +20 -0
- package/ui/global-search.tsx +66 -0
- package/ui/index.ts +178 -0
- package/ui/kit.ts +30 -0
- package/ui/layout/app-shell.tsx +58 -0
- package/ui/layout/desktop-update-banner.tsx +100 -0
- package/ui/layout/entity-header.tsx +44 -0
- package/ui/layout/native-os-nav.ts +104 -0
- package/ui/layout/nav-catalog-loader.tsx +98 -0
- package/ui/layout/nav-icons.ts +67 -0
- package/ui/layout/page-chrome.tsx +90 -0
- package/ui/layout/page-toolbar-context.tsx +82 -0
- package/ui/layout/sandbox-banner.tsx +38 -0
- package/ui/layout/section-view-shell.tsx +73 -0
- package/ui/layout/sidebar-host.ts +144 -0
- package/ui/layout/sidebar.tsx +1150 -0
- package/ui/lib/ai-screencast-hub.ts +127 -0
- package/ui/lib/ai-workspace-client.ts +46 -0
- package/ui/lib/aid.ts +22 -0
- package/ui/lib/desktop-host.ts +114 -0
- package/ui/lib/fleet-tracker-client.ts +237 -0
- package/ui/lib/hermes-ui.ts +114 -0
- package/ui/lib/n8n-ui.ts +115 -0
- package/ui/lib/use-creezio-resource.tsx +121 -0
- package/ui/lib/use-shell-ui-brand.ts +22 -0
- package/ui/list-toolbar.tsx +309 -0
- package/ui/os-pages/desktop-settings-page.tsx +158 -0
- package/ui/os-pages/index.ts +8 -0
- package/ui/page-loading/entity-page-loading.tsx +59 -0
- package/ui/page-loading/list-page-loading.tsx +61 -0
- package/ui/pagination.tsx +56 -0
- package/ui/primitives/avatar.tsx +33 -0
- package/ui/primitives/badge.tsx +36 -0
- package/ui/primitives/breadcrumb.tsx +93 -0
- package/ui/primitives/button.tsx +44 -0
- package/ui/primitives/card.tsx +44 -0
- package/ui/primitives/chart.tsx +317 -0
- package/ui/primitives/command.tsx +111 -0
- package/ui/primitives/dialog.tsx +100 -0
- package/ui/primitives/dropdown-menu.tsx +187 -0
- package/ui/primitives/input.tsx +19 -0
- package/ui/primitives/label.tsx +17 -0
- package/ui/primitives/resizable.tsx +45 -0
- package/ui/primitives/scroll-area.tsx +41 -0
- package/ui/primitives/select.tsx +73 -0
- package/ui/primitives/separator.tsx +26 -0
- package/ui/primitives/sheet.tsx +90 -0
- package/ui/primitives/skeleton.tsx +7 -0
- package/ui/primitives/sonner.tsx +22 -0
- package/ui/primitives/tabs.tsx +51 -0
- package/ui/primitives/textarea.tsx +22 -0
- package/ui/primitives/tooltip.tsx +31 -0
- package/ui/pwa/client-error-reporter.tsx +62 -0
- package/ui/pwa/register-sw.tsx +50 -0
- package/ui/range-filters.tsx +174 -0
- package/ui/search/global-search-config.ts +32 -0
- package/ui/search/global-search-provider.tsx +528 -0
- package/ui/search/search-history.ts +74 -0
- package/ui/search-input.tsx +146 -0
- package/ui/settings/account-settings.tsx +140 -0
- package/ui/settings/agent-profile-settings.tsx +184 -0
- package/ui/settings/api-keys-settings.tsx +349 -0
- package/ui/settings/desktop-background-settings.tsx +128 -0
- package/ui/settings/desktop-connection-settings.tsx +251 -0
- package/ui/settings/desktop-embed-env-panel.tsx +257 -0
- package/ui/settings/desktop-fleet-telemetry-settings.tsx +269 -0
- package/ui/settings/desktop-hermes-settings.tsx +341 -0
- package/ui/settings/desktop-llm-keys.tsx +192 -0
- package/ui/settings/desktop-n8n-settings.tsx +306 -0
- package/ui/settings/desktop-tunnel.tsx +264 -0
- package/ui/settings/desktop-update-settings.tsx +175 -0
- package/ui/settings/factory-reset-settings.tsx +89 -0
- package/ui/settings/host-only-settings.tsx +62 -0
- package/ui/settings/locked-config-field.tsx +30 -0
- package/ui/settings/ops-diagnostic-settings.tsx +242 -0
- package/ui/settings/search-reindex-settings.tsx +158 -0
- package/ui/settings/server-mode-cards.tsx +801 -0
- package/ui/theme/tailwind-preset.cjs +82 -0
- package/ui/theme/theme.css +328 -0
- package/ui/workspace/ai-activity-panel-host.tsx +19 -0
- package/ui/workspace/ai-workspace-agent-host.tsx +54 -0
- package/ui/workspace/ai-workspace-banner.tsx +168 -0
- package/ui/workspace/impersonation-banner.tsx +39 -0
- package/ui/workspace/keep-alive.tsx +220 -0
- package/ui/workspace/tab-workspace-context.tsx +1172 -0
- package/ui/workspace/tab-workspace-host.ts +89 -0
- package/ui/workspace/types.ts +500 -0
- package/ui/workspace/use-location-search.tsx +50 -0
- package/ui/workspace/window-chrome-controls.tsx +116 -0
- package/ui/workspace/workspace-config.ts +47 -0
- package/ui/workspace/workspace-root.tsx +147 -0
- package/ui/workspace/workspace-shell.tsx +292 -0
- package/ui/workspace/workspace-tab-bar.tsx +572 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { getShellDesktopApi } from "@creezio/shell-ui";
|
|
3
|
+
import { configureTabWorkspaceHost } from "./tab-workspace-host";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
16
|
+
import {
|
|
17
|
+
configureAssistantTabWorkspace,
|
|
18
|
+
resolveActiveSurface,
|
|
19
|
+
type ActiveSurface,
|
|
20
|
+
} from "@creezio/assistant/ui";
|
|
21
|
+
import { reduceExternalSiteSurfaceCommand } from "../desktop/external-site-surface";
|
|
22
|
+
import { invalidateKeepAlive } from "./keep-alive";
|
|
23
|
+
import {
|
|
24
|
+
DASHBOARD_PATH,
|
|
25
|
+
MAX_TABS,
|
|
26
|
+
WORKSPACE_STORAGE_KEY,
|
|
27
|
+
applyTabMeta,
|
|
28
|
+
createExternalSiteTab,
|
|
29
|
+
createTab,
|
|
30
|
+
ensureTabHistory,
|
|
31
|
+
isDashboardHref,
|
|
32
|
+
isExternalSiteHref,
|
|
33
|
+
isFullscreenHref,
|
|
34
|
+
isWorkspaceTabLocked,
|
|
35
|
+
isWorkspacePath,
|
|
36
|
+
normalizeHref,
|
|
37
|
+
pushTabHistory,
|
|
38
|
+
replaceTabHistory,
|
|
39
|
+
samePathname,
|
|
40
|
+
shouldOpenLockedNavigationInNewTab,
|
|
41
|
+
externalSiteHref,
|
|
42
|
+
titleFromHref,
|
|
43
|
+
type TabMeta,
|
|
44
|
+
type WorkspacePersistedState,
|
|
45
|
+
type WorkspaceTab,
|
|
46
|
+
} from "./types";
|
|
47
|
+
import { useLocationSearch } from "./use-location-search";
|
|
48
|
+
import {
|
|
49
|
+
getDefaultNewTabHref,
|
|
50
|
+
getProductDetailCtxAdapter,
|
|
51
|
+
} from "./workspace-config";
|
|
52
|
+
|
|
53
|
+
export type NavigateOptions = {
|
|
54
|
+
newTab?: boolean;
|
|
55
|
+
replace?: boolean;
|
|
56
|
+
skipHistory?: boolean;
|
|
57
|
+
/** Titre provisoire (texte du lien cliqué) — évite « Catalogue · 123 ». */
|
|
58
|
+
title?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Lien issu du panneau assistant / recherche : forcer la fiche produit
|
|
61
|
+
* autonome (`ctx=catalogue`), sans héritage soft de l'onglet courant.
|
|
62
|
+
*/
|
|
63
|
+
preferCatalogue?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type OpenExternalSiteOpts = {
|
|
67
|
+
siteId: number;
|
|
68
|
+
url: string;
|
|
69
|
+
title?: string;
|
|
70
|
+
electronTabId?: string;
|
|
71
|
+
/**
|
|
72
|
+
* true : forcer opts.url même si un onglet existe déjà (SiteLink, barre
|
|
73
|
+
* d'adresse). false/omit : réactivation - conserver l'URL live SPA.
|
|
74
|
+
*/
|
|
75
|
+
navigateUrl?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** @deprecated Use OpenExternalSiteOpts with siteId. */
|
|
79
|
+
export type OpenSupplierSiteOpts = Omit<OpenExternalSiteOpts, "siteId"> & {
|
|
80
|
+
fournisseurId: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type TabWorkspaceContextValue = {
|
|
84
|
+
ready: boolean;
|
|
85
|
+
tabs: WorkspaceTab[];
|
|
86
|
+
activeTabId: string;
|
|
87
|
+
activeTab: WorkspaceTab | null;
|
|
88
|
+
/** Onglet épinglé (Dashboard) : toujours premier, non fermable. */
|
|
89
|
+
pinnedTabId: string | null;
|
|
90
|
+
/** Pastilles de notification par onglet (ex. ajouts panier). */
|
|
91
|
+
tabBadges: Record<string, number>;
|
|
92
|
+
canGoBack: boolean;
|
|
93
|
+
canGoForward: boolean;
|
|
94
|
+
navigate: (href: string, opts?: NavigateOptions) => void;
|
|
95
|
+
openNewTab: (href?: string) => void;
|
|
96
|
+
activateTab: (tabId: string) => void;
|
|
97
|
+
closeTab: (tabId: string) => void;
|
|
98
|
+
setTabLocked: (tabId: string, locked: boolean) => void;
|
|
99
|
+
moveTab: (tabId: string, toIndex: number) => void;
|
|
100
|
+
goBack: () => void;
|
|
101
|
+
goForward: () => void;
|
|
102
|
+
setTabMeta: (href: string, meta: TabMeta) => void;
|
|
103
|
+
/**
|
|
104
|
+
* Ouvre ou **focus** un chemin workspace (contrat moderne, pas pastille-only).
|
|
105
|
+
*
|
|
106
|
+
* - Onglet déjà actif sur ce pathname → `"active"` (data-changed suffit
|
|
107
|
+
* pour rafraîchir via `useCreezioResource`).
|
|
108
|
+
* - Onglet existant inactif → active l'onglet (`activateTab` / route) →
|
|
109
|
+
* `"focused"`.
|
|
110
|
+
* - Aucun onglet → `navigate(href, { newTab: true })` → `"opened"`.
|
|
111
|
+
*
|
|
112
|
+
* Usage typique : ajout panier / mutation qui doit **montrer** la cible
|
|
113
|
+
* (ex. `openOrNotify("/panier")` depuis un bouton catalogue).
|
|
114
|
+
*/
|
|
115
|
+
openOrNotify: (href: string) => "opened" | "focused" | "active";
|
|
116
|
+
/**
|
|
117
|
+
* Ouvre / réactive un onglet workspace pour un site externe Electron
|
|
118
|
+
* (même barre que Dashboard/Produits). Ne crée pas la WebContentsView —
|
|
119
|
+
* l'appelant (SiteLink / DesktopBridge) a déjà fait openTab IPC.
|
|
120
|
+
*/
|
|
121
|
+
openExternalSite: (opts: OpenExternalSiteOpts) => void;
|
|
122
|
+
/** @deprecated Use openExternalSite. */
|
|
123
|
+
openSupplierSite: (opts: OpenSupplierSiteOpts) => void;
|
|
124
|
+
/** Met à jour le lien electronTabId / titre d'un onglet site. */
|
|
125
|
+
patchExternalSiteTab: (
|
|
126
|
+
siteId: number,
|
|
127
|
+
patch: Partial<{ electronTabId: string; url: string; title: string }>,
|
|
128
|
+
) => void;
|
|
129
|
+
/** @deprecated Use patchExternalSiteTab. */
|
|
130
|
+
patchSupplierTab: (
|
|
131
|
+
fournisseurId: number,
|
|
132
|
+
patch: Partial<{ electronTabId: string; url: string; title: string }>,
|
|
133
|
+
) => void;
|
|
134
|
+
currentHref: string;
|
|
135
|
+
/**
|
|
136
|
+
* Surface que regarde l'utilisateur (CRM React vs site externe).
|
|
137
|
+
* Source de vérité unique pour le cerveau assistant.
|
|
138
|
+
*/
|
|
139
|
+
activeSurface: ActiveSurface;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const TabWorkspaceContext = createContext<TabWorkspaceContextValue | null>(null);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pont module-scope vers le `navigate` du provider monté. Nécessaire pour les
|
|
146
|
+
* surfaces montées HORS du TabWorkspaceProvider (ex. @creezio/interactive-demo
|
|
147
|
+
* dans le chrome marque) : un `router.push` direct sur l'onglet épinglé
|
|
148
|
+
* (Dashboard) est réaligné par le provider — seule la navigation workspace
|
|
149
|
+
* ouvre/active correctement un onglet.
|
|
150
|
+
*/
|
|
151
|
+
let workspaceNavigateBridge:
|
|
152
|
+
| ((href: string, opts?: NavigateOptions) => void)
|
|
153
|
+
| null = null;
|
|
154
|
+
|
|
155
|
+
/** `navigate` workspace courant, ou null hors provider / avant hydratation. */
|
|
156
|
+
export function getWorkspaceTabNavigate():
|
|
157
|
+
| ((href: string, opts?: NavigateOptions) => void)
|
|
158
|
+
| null {
|
|
159
|
+
return workspaceNavigateBridge;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function loadPersisted(): WorkspacePersistedState | null {
|
|
163
|
+
if (typeof window === "undefined") return null;
|
|
164
|
+
try {
|
|
165
|
+
const raw = sessionStorage.getItem(WORKSPACE_STORAGE_KEY);
|
|
166
|
+
if (!raw) return null;
|
|
167
|
+
const parsed = JSON.parse(raw) as WorkspacePersistedState;
|
|
168
|
+
if (!parsed?.tabs?.length || !parsed.activeTabId) return null;
|
|
169
|
+
return parsed;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function persist(state: WorkspacePersistedState) {
|
|
176
|
+
try {
|
|
177
|
+
sessionStorage.setItem(WORKSPACE_STORAGE_KEY, JSON.stringify(state));
|
|
178
|
+
} catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hrefOf(pathname: string, search: string) {
|
|
184
|
+
return normalizeHref(search ? `${pathname}?${search}` : pathname);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function withConfiguredCtx(
|
|
188
|
+
href: string,
|
|
189
|
+
opts: { fromHref?: string | null; preferCatalogue?: boolean },
|
|
190
|
+
): string {
|
|
191
|
+
const adapter = getProductDetailCtxAdapter();
|
|
192
|
+
if (!adapter) return href;
|
|
193
|
+
const path = href.split("?")[0] || "";
|
|
194
|
+
if (!adapter.isDetailPath(path)) return href;
|
|
195
|
+
return opts.preferCatalogue
|
|
196
|
+
? adapter.withCatalogueCtx(href)
|
|
197
|
+
: adapter.withInferredCtx(href, opts.fromHref);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Garantit l'invariant : UN seul onglet Dashboard, TOUJOURS en position 0,
|
|
202
|
+
* href propre `/dashboard` (pas de doublon fermable).
|
|
203
|
+
*/
|
|
204
|
+
function withPinnedDashboard(tabs: WorkspaceTab[]): {
|
|
205
|
+
tabs: WorkspaceTab[];
|
|
206
|
+
pinnedId: string;
|
|
207
|
+
} {
|
|
208
|
+
const dashExisting = tabs.find((t) => isDashboardHref(t.href));
|
|
209
|
+
const others = tabs.filter((t) => !isDashboardHref(t.href));
|
|
210
|
+
const base = dashExisting ?? createTab(DASHBOARD_PATH, { title: "Dashboard" });
|
|
211
|
+
const pinned: WorkspaceTab = {
|
|
212
|
+
...base,
|
|
213
|
+
href: DASHBOARD_PATH,
|
|
214
|
+
title: base.title || "Dashboard",
|
|
215
|
+
history: [DASHBOARD_PATH],
|
|
216
|
+
historyIndex: 0,
|
|
217
|
+
};
|
|
218
|
+
return { tabs: [pinned, ...others], pinnedId: pinned.id };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Pathname normalisé (sans query) pour la déduplication d'onglets. */
|
|
222
|
+
function pathnameOf(href: string): string {
|
|
223
|
+
return normalizeHref(href).split("?")[0] || "/";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Onglet existant qui affiche déjà la même page (pathname).
|
|
228
|
+
* Dashboard → toujours l'épinglé. Sinon préférer l'onglet actif s'il match
|
|
229
|
+
* (filtres / pagination dans le même onglet), sinon le plus ancien.
|
|
230
|
+
*/
|
|
231
|
+
function findExistingTabForHref(
|
|
232
|
+
tabs: WorkspaceTab[],
|
|
233
|
+
href: string,
|
|
234
|
+
opts: { activeTabId: string | null; pinnedTabId: string | null },
|
|
235
|
+
): WorkspaceTab | null {
|
|
236
|
+
if (isDashboardHref(href)) {
|
|
237
|
+
return (
|
|
238
|
+
tabs.find((t) => t.id === opts.pinnedTabId) ||
|
|
239
|
+
tabs.find((t) => isDashboardHref(t.href)) ||
|
|
240
|
+
null
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const path = pathnameOf(href);
|
|
244
|
+
const active = tabs.find((t) => t.id === opts.activeTabId);
|
|
245
|
+
if (active && pathnameOf(active.href) === path) return active;
|
|
246
|
+
return tabs.find((t) => pathnameOf(t.href) === path) || null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function findExternalSiteTab(
|
|
250
|
+
tabs: WorkspaceTab[],
|
|
251
|
+
siteId: number,
|
|
252
|
+
): WorkspaceTab | null {
|
|
253
|
+
const href = externalSiteHref(siteId);
|
|
254
|
+
return (
|
|
255
|
+
tabs.find((t) => t.externalSite?.siteId === siteId) ||
|
|
256
|
+
tabs.find((t) => t.supplier?.siteId === siteId) ||
|
|
257
|
+
tabs.find((t) => t.supplier?.fournisseurId === siteId) ||
|
|
258
|
+
tabs.find((t) => pathnameOf(t.href) === pathnameOf(href)) ||
|
|
259
|
+
null
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function closeElectronTab(tab: WorkspaceTab | undefined): void {
|
|
264
|
+
const electronId =
|
|
265
|
+
tab?.externalSite?.electronTabId ?? tab?.supplier?.electronTabId;
|
|
266
|
+
if (!electronId) return;
|
|
267
|
+
try {
|
|
268
|
+
void getShellDesktopApi()?.closeTab(electronId);
|
|
269
|
+
} catch {
|
|
270
|
+
/* ignore */
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Ferme les onglets qui dupliquent le même pathname (garde `keepId`).
|
|
276
|
+
* Invalide le keep-alive des onglets fermés.
|
|
277
|
+
*/
|
|
278
|
+
function dropDuplicatePathTabs(
|
|
279
|
+
tabs: WorkspaceTab[],
|
|
280
|
+
href: string,
|
|
281
|
+
keepId: string,
|
|
282
|
+
): WorkspaceTab[] {
|
|
283
|
+
const path = pathnameOf(href);
|
|
284
|
+
const next: WorkspaceTab[] = [];
|
|
285
|
+
for (const t of tabs) {
|
|
286
|
+
if (t.id === keepId) {
|
|
287
|
+
next.push(t);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (pathnameOf(t.href) === path || (isDashboardHref(href) && isDashboardHref(t.href))) {
|
|
291
|
+
invalidateKeepAlive(t.href);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
next.push(t);
|
|
295
|
+
}
|
|
296
|
+
return next;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function TabWorkspaceProvider({ children }: { children: ReactNode }) {
|
|
300
|
+
const router = useRouter();
|
|
301
|
+
const pathname = usePathname() || "/";
|
|
302
|
+
const search = useLocationSearch(pathname);
|
|
303
|
+
const currentHref = hrefOf(pathname, search);
|
|
304
|
+
const inWorkspace = isWorkspacePath(pathname);
|
|
305
|
+
|
|
306
|
+
const [ready, setReady] = useState(false);
|
|
307
|
+
// id stable "boot" pour éviter un mismatch d'hydratation (pas de random au 1er paint)
|
|
308
|
+
const [tabs, setTabs] = useState<WorkspaceTab[]>(() => [
|
|
309
|
+
createTab(currentHref, { id: "boot" }),
|
|
310
|
+
]);
|
|
311
|
+
const [activeTabId, setActiveTabId] = useState("boot");
|
|
312
|
+
const [pinnedTabId, setPinnedTabId] = useState<string | null>(null);
|
|
313
|
+
const [tabBadges, setTabBadges] = useState<Record<string, number>>({});
|
|
314
|
+
|
|
315
|
+
const tabsRef = useRef(tabs);
|
|
316
|
+
const activeTabIdRef = useRef(activeTabId);
|
|
317
|
+
const pinnedTabIdRef = useRef(pinnedTabId);
|
|
318
|
+
const currentHrefRef = useRef(currentHref);
|
|
319
|
+
const hydratedRef = useRef(false);
|
|
320
|
+
// Derniers titres publiés par les panes, par URL. Sert à restaurer le vrai
|
|
321
|
+
// titre lors d'un back/forward vers une pane gelée (qui ne re-publie pas :
|
|
322
|
+
// ses deps n'ont pas changé) au lieu du titre générique dérivé de l'URL.
|
|
323
|
+
const metaByHrefRef = useRef(new Map<string, TabMeta>());
|
|
324
|
+
|
|
325
|
+
const metaFor = useCallback((href: string): TabMeta => {
|
|
326
|
+
const known = metaByHrefRef.current.get(normalizeHref(href));
|
|
327
|
+
return {
|
|
328
|
+
title: known?.title || titleFromHref(href),
|
|
329
|
+
subtitle: known?.subtitle,
|
|
330
|
+
kind: known?.kind,
|
|
331
|
+
trail: known?.trail,
|
|
332
|
+
fullscreen: known?.fullscreen ?? isFullscreenHref(href),
|
|
333
|
+
};
|
|
334
|
+
}, []);
|
|
335
|
+
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
tabsRef.current = tabs;
|
|
338
|
+
}, [tabs]);
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
activeTabIdRef.current = activeTabId;
|
|
341
|
+
}, [activeTabId]);
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
pinnedTabIdRef.current = pinnedTabId;
|
|
344
|
+
}, [pinnedTabId]);
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
currentHrefRef.current = currentHref;
|
|
347
|
+
}, [currentHref]);
|
|
348
|
+
|
|
349
|
+
const routeTo = useCallback(
|
|
350
|
+
(href: string, replace = true) => {
|
|
351
|
+
const h = normalizeHref(href);
|
|
352
|
+
if (replace) router.replace(h);
|
|
353
|
+
else router.push(h);
|
|
354
|
+
},
|
|
355
|
+
[router],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// Hydratation session
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (hydratedRef.current) return;
|
|
361
|
+
hydratedRef.current = true;
|
|
362
|
+
|
|
363
|
+
if (!inWorkspace) {
|
|
364
|
+
setReady(true);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const saved = loadPersisted();
|
|
369
|
+
if (saved) {
|
|
370
|
+
let normalized = saved.tabs.map((t) => ensureTabHistory(t));
|
|
371
|
+
let pinned = withPinnedDashboard(normalized);
|
|
372
|
+
// L'URL demandée (lien direct, F5, clic avant hydratation) prime TOUJOURS
|
|
373
|
+
// sur l'onglet actif sauvegardé — plus de router.replace vers un vieux panier.
|
|
374
|
+
let active =
|
|
375
|
+
pinned.tabs.find((t) => normalizeHref(t.href) === currentHref) ||
|
|
376
|
+
pinned.tabs.find((t) => samePathname(t.href, currentHref));
|
|
377
|
+
if (active && normalizeHref(active.href) !== currentHref) {
|
|
378
|
+
const meta = metaFor(currentHref);
|
|
379
|
+
active = applyTabMeta(replaceTabHistory(active, currentHref), meta);
|
|
380
|
+
pinned = {
|
|
381
|
+
...pinned,
|
|
382
|
+
tabs: pinned.tabs.map((t) => (t.id === active!.id ? active! : t)),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (!active) {
|
|
386
|
+
if (isDashboardHref(currentHref)) {
|
|
387
|
+
active = pinned.tabs[0]!;
|
|
388
|
+
} else {
|
|
389
|
+
const meta = metaFor(currentHref);
|
|
390
|
+
const tab = createTab(currentHref, {
|
|
391
|
+
title: meta.title,
|
|
392
|
+
subtitle: meta.subtitle,
|
|
393
|
+
kind: meta.kind,
|
|
394
|
+
trail: meta.trail,
|
|
395
|
+
});
|
|
396
|
+
pinned = { tabs: [...pinned.tabs, tab], pinnedId: pinned.pinnedId };
|
|
397
|
+
active = tab;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
setTabs(pinned.tabs);
|
|
401
|
+
setPinnedTabId(pinned.pinnedId);
|
|
402
|
+
setActiveTabId(active.id);
|
|
403
|
+
// Ne jamais rediriger hors de currentHref. Sync seulement si on a
|
|
404
|
+
// réactivé le dashboard alors que l'URL est déjà le dashboard.
|
|
405
|
+
} else {
|
|
406
|
+
const tab = createTab(currentHref);
|
|
407
|
+
const pinned = withPinnedDashboard([tab]);
|
|
408
|
+
setTabs(pinned.tabs);
|
|
409
|
+
setPinnedTabId(pinned.pinnedId);
|
|
410
|
+
setActiveTabId(tab.id);
|
|
411
|
+
}
|
|
412
|
+
setReady(true);
|
|
413
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
414
|
+
}, []);
|
|
415
|
+
|
|
416
|
+
// Persistance — ordre des onglets + piles d'historique par onglet
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
if (!ready || !inWorkspace || !tabs.length || !activeTabId) return;
|
|
419
|
+
persist({ tabs, activeTabId });
|
|
420
|
+
}, [ready, inWorkspace, tabs, activeTabId]);
|
|
421
|
+
|
|
422
|
+
// Sync URL → onglet actif (après navigation Next)
|
|
423
|
+
// Ne touche PAS à l'historique ni à l'ordre des onglets.
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (!ready || !inWorkspace) return;
|
|
426
|
+
|
|
427
|
+
// Si l'URL courante correspond déjà à un AUTRE onglet → basculer dessus
|
|
428
|
+
// au lieu d'écraser l'onglet actif (anti-doublon Dashboard / Catalogue…).
|
|
429
|
+
const existing = findExistingTabForHref(tabsRef.current, currentHref, {
|
|
430
|
+
activeTabId: activeTabIdRef.current,
|
|
431
|
+
pinnedTabId: pinnedTabIdRef.current,
|
|
432
|
+
});
|
|
433
|
+
if (existing && existing.id !== activeTabIdRef.current) {
|
|
434
|
+
const cleaned = withPinnedDashboard(
|
|
435
|
+
dropDuplicatePathTabs(tabsRef.current, currentHref, existing.id),
|
|
436
|
+
);
|
|
437
|
+
const keep =
|
|
438
|
+
cleaned.tabs.find((t) => t.id === existing.id) ||
|
|
439
|
+
cleaned.tabs.find((t) => t.id === cleaned.pinnedId) ||
|
|
440
|
+
existing;
|
|
441
|
+
const meta = metaFor(currentHref);
|
|
442
|
+
const updated = samePathname(keep.href, currentHref)
|
|
443
|
+
? applyTabMeta(replaceTabHistory(keep, currentHref), meta)
|
|
444
|
+
: applyTabMeta(keep, meta);
|
|
445
|
+
const nextTabs = cleaned.tabs.map((t) =>
|
|
446
|
+
t.id === updated.id ? updated : t,
|
|
447
|
+
);
|
|
448
|
+
tabsRef.current = nextTabs;
|
|
449
|
+
pinnedTabIdRef.current = cleaned.pinnedId;
|
|
450
|
+
activeTabIdRef.current = updated.id;
|
|
451
|
+
setTabs(nextTabs);
|
|
452
|
+
setPinnedTabId(cleaned.pinnedId);
|
|
453
|
+
setActiveTabId(updated.id);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
setTabs((prev) => {
|
|
458
|
+
if (!prev.length) {
|
|
459
|
+
const tab = createTab(currentHref);
|
|
460
|
+
setActiveTabId(tab.id);
|
|
461
|
+
return [tab];
|
|
462
|
+
}
|
|
463
|
+
const active = prev.find((t) => t.id === activeTabIdRef.current);
|
|
464
|
+
if (!active) return prev;
|
|
465
|
+
if (active.href === currentHref) return prev;
|
|
466
|
+
// Onglet protégé (Dashboard ou verrouillage utilisateur) : son contenu
|
|
467
|
+
// ne peut JAMAIS être remplacé. Si l'URL diverge via un router.push
|
|
468
|
+
// direct, on réaligne l'URL ; les navigations workspace légitimes
|
|
469
|
+
// passent par navigate(), qui ouvre un nouvel onglet dans ce cas.
|
|
470
|
+
if (
|
|
471
|
+
isWorkspaceTabLocked(active, pinnedTabIdRef.current) &&
|
|
472
|
+
!samePathname(active.href, currentHref)
|
|
473
|
+
) {
|
|
474
|
+
routeTo(active.href, true);
|
|
475
|
+
return prev;
|
|
476
|
+
}
|
|
477
|
+
const meta = metaFor(currentHref);
|
|
478
|
+
// URL modifiée hors navigate() (router.replace/push des filtres, vues,
|
|
479
|
+
// recherche…) : même pathname → remplace l'entrée d'historique (vue ou
|
|
480
|
+
// filtre ≠ nouvelle page) ; pathname différent → vraie navigation, on
|
|
481
|
+
// empile pour que « précédent » fonctionne.
|
|
482
|
+
const update = samePathname(active.href, currentHref)
|
|
483
|
+
? replaceTabHistory(active, currentHref)
|
|
484
|
+
: pushTabHistory(active, currentHref);
|
|
485
|
+
return prev.map((t) =>
|
|
486
|
+
t.id === active.id ? applyTabMeta(update, meta) : t,
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
}, [currentHref, ready, inWorkspace, metaFor, routeTo]);
|
|
490
|
+
|
|
491
|
+
const navigate = useCallback(
|
|
492
|
+
(href: string, opts?: NavigateOptions) => {
|
|
493
|
+
const activeForCtx = tabsRef.current.find(
|
|
494
|
+
(t) => t.id === activeTabIdRef.current,
|
|
495
|
+
);
|
|
496
|
+
const h = normalizeHref(
|
|
497
|
+
withConfiguredCtx(href, {
|
|
498
|
+
fromHref: activeForCtx?.href,
|
|
499
|
+
preferCatalogue: opts?.preferCatalogue,
|
|
500
|
+
}),
|
|
501
|
+
);
|
|
502
|
+
const pathOnly = h.split("?")[0] || h;
|
|
503
|
+
if (!isWorkspacePath(pathOnly)) {
|
|
504
|
+
routeTo(h, opts?.replace ?? false);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const provisionalTitle = (opts?.title || "").trim() || undefined;
|
|
509
|
+
const resolveMeta = (target: string): TabMeta => {
|
|
510
|
+
const known = metaFor(target);
|
|
511
|
+
return {
|
|
512
|
+
...known,
|
|
513
|
+
title: provisionalTitle || known.title,
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Anti-doublon : si la page est déjà ouverte → basculer vers cet onglet
|
|
518
|
+
// (ex. clic sidebar Dashboard alors que l'épinglé existe déjà).
|
|
519
|
+
const existing = findExistingTabForHref(tabsRef.current, h, {
|
|
520
|
+
activeTabId: activeTabIdRef.current,
|
|
521
|
+
pinnedTabId: pinnedTabIdRef.current,
|
|
522
|
+
});
|
|
523
|
+
if (existing) {
|
|
524
|
+
const targetHref = isDashboardHref(h) ? DASHBOARD_PATH : h;
|
|
525
|
+
if (
|
|
526
|
+
existing.id === activeTabIdRef.current &&
|
|
527
|
+
normalizeHref(existing.href) === targetHref &&
|
|
528
|
+
targetHref === currentHrefRef.current
|
|
529
|
+
) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const cleaned = withPinnedDashboard(
|
|
533
|
+
dropDuplicatePathTabs(tabsRef.current, h, existing.id),
|
|
534
|
+
);
|
|
535
|
+
const target =
|
|
536
|
+
cleaned.tabs.find((t) => t.id === existing.id) ||
|
|
537
|
+
(isDashboardHref(h)
|
|
538
|
+
? cleaned.tabs.find((t) => t.id === cleaned.pinnedId)
|
|
539
|
+
: null) ||
|
|
540
|
+
existing;
|
|
541
|
+
const meta = resolveMeta(targetHref);
|
|
542
|
+
const updated =
|
|
543
|
+
normalizeHref(target.href) === targetHref
|
|
544
|
+
? applyTabMeta(target, meta)
|
|
545
|
+
: applyTabMeta(
|
|
546
|
+
samePathname(target.href, targetHref) || opts?.skipHistory
|
|
547
|
+
? replaceTabHistory(target, targetHref)
|
|
548
|
+
: pushTabHistory(target, targetHref),
|
|
549
|
+
meta,
|
|
550
|
+
);
|
|
551
|
+
const nextTabs = cleaned.tabs.map((t) =>
|
|
552
|
+
t.id === updated.id ? updated : t,
|
|
553
|
+
);
|
|
554
|
+
tabsRef.current = nextTabs;
|
|
555
|
+
pinnedTabIdRef.current = cleaned.pinnedId;
|
|
556
|
+
activeTabIdRef.current = updated.id;
|
|
557
|
+
setTabs(nextTabs);
|
|
558
|
+
setPinnedTabId(cleaned.pinnedId);
|
|
559
|
+
setActiveTabId(updated.id);
|
|
560
|
+
setTabBadges((prev) => {
|
|
561
|
+
if (!prev[updated.id]) return prev;
|
|
562
|
+
const next = { ...prev };
|
|
563
|
+
delete next[updated.id];
|
|
564
|
+
return next;
|
|
565
|
+
});
|
|
566
|
+
if (normalizeHref(targetHref) !== currentHrefRef.current) {
|
|
567
|
+
routeTo(targetHref, true);
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Depuis tout onglet protégé : une autre page s'ouvre dans un nouvel
|
|
573
|
+
// onglet. Une variation de query sur la même page reste une vue/filtre,
|
|
574
|
+
// comme pour le Dashboard dont la query est ignorée.
|
|
575
|
+
const activeForLock = tabsRef.current.find(
|
|
576
|
+
(t) => t.id === activeTabIdRef.current,
|
|
577
|
+
);
|
|
578
|
+
if (
|
|
579
|
+
!opts?.newTab &&
|
|
580
|
+
activeForLock &&
|
|
581
|
+
shouldOpenLockedNavigationInNewTab(
|
|
582
|
+
activeForLock,
|
|
583
|
+
h,
|
|
584
|
+
pinnedTabIdRef.current,
|
|
585
|
+
)
|
|
586
|
+
) {
|
|
587
|
+
opts = { ...opts, newTab: true };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (opts?.newTab) {
|
|
591
|
+
const prev = tabsRef.current;
|
|
592
|
+
let next = prev;
|
|
593
|
+
if (prev.length >= MAX_TABS) {
|
|
594
|
+
const victim =
|
|
595
|
+
prev.find(
|
|
596
|
+
(t) =>
|
|
597
|
+
t.id !== activeTabIdRef.current &&
|
|
598
|
+
!isWorkspaceTabLocked(t, pinnedTabIdRef.current),
|
|
599
|
+
) ||
|
|
600
|
+
prev.find((t) => !isWorkspaceTabLocked(t, pinnedTabIdRef.current));
|
|
601
|
+
if (victim) {
|
|
602
|
+
invalidateKeepAlive(victim.href);
|
|
603
|
+
next = prev.filter((t) => t.id !== victim.id);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const meta = resolveMeta(h);
|
|
607
|
+
const tab = createTab(h, {
|
|
608
|
+
title: meta.title,
|
|
609
|
+
subtitle: meta.subtitle,
|
|
610
|
+
kind: meta.kind,
|
|
611
|
+
trail: meta.trail,
|
|
612
|
+
});
|
|
613
|
+
const pinned = withPinnedDashboard([...next, tab]);
|
|
614
|
+
// Refs synchrones AVANT routeTo : sinon le sync URL→onglet court
|
|
615
|
+
// avec l'ancien activeTabId et écrase l'onglet produit (ex. → Panier).
|
|
616
|
+
tabsRef.current = pinned.tabs;
|
|
617
|
+
pinnedTabIdRef.current = pinned.pinnedId;
|
|
618
|
+
activeTabIdRef.current = tab.id;
|
|
619
|
+
setTabs(pinned.tabs);
|
|
620
|
+
setPinnedTabId(pinned.pinnedId);
|
|
621
|
+
setActiveTabId(tab.id);
|
|
622
|
+
routeTo(h, opts.replace ?? false);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (h === currentHrefRef.current) return;
|
|
627
|
+
|
|
628
|
+
// Page absente des onglets → navigation dans l'onglet actif.
|
|
629
|
+
setTabs((prev) =>
|
|
630
|
+
prev.map((t) => {
|
|
631
|
+
if (t.id !== activeTabIdRef.current) return t;
|
|
632
|
+
const meta = resolveMeta(h);
|
|
633
|
+
// Même page, seuls les query params changent (vue liste/carte,
|
|
634
|
+
// filtres, pagination) → on REMPLACE l'entrée d'historique au lieu
|
|
635
|
+
// d'empiler : « précédent » revient à la page d'avant, pas à
|
|
636
|
+
// l'état de vue/filtre d'avant.
|
|
637
|
+
const sameParamsOnlyChange = samePathname(t.href, h);
|
|
638
|
+
const next =
|
|
639
|
+
opts?.skipHistory || sameParamsOnlyChange
|
|
640
|
+
? replaceTabHistory(t, h)
|
|
641
|
+
: pushTabHistory(t, h);
|
|
642
|
+
return applyTabMeta(next, meta);
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
routeTo(h, opts?.replace ?? true);
|
|
646
|
+
},
|
|
647
|
+
[routeTo, metaFor],
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
const openNewTab = useCallback(
|
|
651
|
+
(href?: string) => {
|
|
652
|
+
navigate(href || getDefaultNewTabHref(), { newTab: true });
|
|
653
|
+
},
|
|
654
|
+
[navigate],
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
/** Switch d'onglet : change l'actif, PAS l'ordre, PAS l'historique. */
|
|
658
|
+
const activateTab = useCallback(
|
|
659
|
+
(tabId: string) => {
|
|
660
|
+
const tab = tabsRef.current.find((t) => t.id === tabId);
|
|
661
|
+
if (!tab) return;
|
|
662
|
+
// Consulter l'onglet efface sa pastille de notification.
|
|
663
|
+
setTabBadges((prev) => {
|
|
664
|
+
if (!prev[tabId]) return prev;
|
|
665
|
+
const next = { ...prev };
|
|
666
|
+
delete next[tabId];
|
|
667
|
+
return next;
|
|
668
|
+
});
|
|
669
|
+
if (tabId === activeTabIdRef.current) return;
|
|
670
|
+
activeTabIdRef.current = tabId;
|
|
671
|
+
setActiveTabId(tabId);
|
|
672
|
+
if (normalizeHref(tab.href) !== currentHrefRef.current) {
|
|
673
|
+
routeTo(tab.href, true);
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
[routeTo],
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
const closeTab = useCallback(
|
|
680
|
+
(tabId: string) => {
|
|
681
|
+
const prev = tabsRef.current;
|
|
682
|
+
const idx = prev.findIndex((t) => t.id === tabId);
|
|
683
|
+
if (idx < 0) return;
|
|
684
|
+
const closing = prev[idx]!;
|
|
685
|
+
// Dashboard et onglets verrouillés par l'utilisateur ne se ferment jamais.
|
|
686
|
+
if (isWorkspaceTabLocked(closing, pinnedTabIdRef.current)) return;
|
|
687
|
+
// Ferme la WebContentsView Electron mais GARDE les cookies (partition).
|
|
688
|
+
closeElectronTab(closing);
|
|
689
|
+
if (isExternalSiteHref(closing.href)) {
|
|
690
|
+
try {
|
|
691
|
+
void getShellDesktopApi()?.showCrm?.();
|
|
692
|
+
} catch {
|
|
693
|
+
/* ignore */
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
invalidateKeepAlive(closing.href);
|
|
697
|
+
setTabBadges((badges) => {
|
|
698
|
+
if (!badges[tabId]) return badges;
|
|
699
|
+
const next = { ...badges };
|
|
700
|
+
delete next[tabId];
|
|
701
|
+
return next;
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
if (prev.length === 1) {
|
|
705
|
+
const pinned = withPinnedDashboard([]);
|
|
706
|
+
setTabs(pinned.tabs);
|
|
707
|
+
setPinnedTabId(pinned.pinnedId);
|
|
708
|
+
setActiveTabId(pinned.pinnedId);
|
|
709
|
+
routeTo(DASHBOARD_PATH, true);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const pinned = withPinnedDashboard(prev.filter((t) => t.id !== tabId));
|
|
714
|
+
setTabs(pinned.tabs);
|
|
715
|
+
setPinnedTabId(pinned.pinnedId);
|
|
716
|
+
if (activeTabIdRef.current === tabId) {
|
|
717
|
+
// Chrome : préférer le voisin de droite, sinon gauche
|
|
718
|
+
const list = pinned.tabs;
|
|
719
|
+
const fallback = list[idx] || list[idx - 1] || list[0]!;
|
|
720
|
+
setActiveTabId(fallback.id);
|
|
721
|
+
routeTo(fallback.href, true);
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
[routeTo],
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const setTabLocked = useCallback((tabId: string, locked: boolean) => {
|
|
728
|
+
// Le Dashboard est un verrou système : il ne peut pas être déverrouillé.
|
|
729
|
+
if (tabId === pinnedTabIdRef.current) return;
|
|
730
|
+
const current = tabsRef.current.find((t) => t.id === tabId);
|
|
731
|
+
if (!current || current.locked === locked) return;
|
|
732
|
+
const nextTabs = tabsRef.current.map((t) =>
|
|
733
|
+
t.id === tabId ? { ...t, locked } : t,
|
|
734
|
+
);
|
|
735
|
+
tabsRef.current = nextTabs;
|
|
736
|
+
setTabs(nextTabs);
|
|
737
|
+
}, []);
|
|
738
|
+
|
|
739
|
+
const patchExternalSiteTab = useCallback(
|
|
740
|
+
(
|
|
741
|
+
siteId: number,
|
|
742
|
+
patch: Partial<{ electronTabId: string; url: string; title: string }>,
|
|
743
|
+
) => {
|
|
744
|
+
setTabs((prev) => {
|
|
745
|
+
let changed = false;
|
|
746
|
+
const next = prev.map((t) => {
|
|
747
|
+
const meta = t.externalSite ?? t.supplier;
|
|
748
|
+
const metaSiteId = meta?.siteId ?? t.supplier?.fournisseurId;
|
|
749
|
+
if (metaSiteId !== siteId && !isExternalSiteHref(t.href)) {
|
|
750
|
+
return t;
|
|
751
|
+
}
|
|
752
|
+
if (
|
|
753
|
+
metaSiteId !== siteId &&
|
|
754
|
+
pathnameOf(t.href) !== pathnameOf(externalSiteHref(siteId))
|
|
755
|
+
) {
|
|
756
|
+
return t;
|
|
757
|
+
}
|
|
758
|
+
const nextTitle =
|
|
759
|
+
patch.title !== undefined ? patch.title.trim() || t.title : t.title;
|
|
760
|
+
const nextUrl =
|
|
761
|
+
patch.url !== undefined ? patch.url : (meta?.url ?? "");
|
|
762
|
+
const nextElectronId =
|
|
763
|
+
patch.electronTabId !== undefined
|
|
764
|
+
? patch.electronTabId
|
|
765
|
+
: meta?.electronTabId;
|
|
766
|
+
if (
|
|
767
|
+
nextTitle === t.title &&
|
|
768
|
+
nextUrl === (meta?.url ?? "") &&
|
|
769
|
+
nextElectronId === meta?.electronTabId &&
|
|
770
|
+
t.externalSite?.siteId === siteId &&
|
|
771
|
+
t.supplier?.fournisseurId === siteId
|
|
772
|
+
) {
|
|
773
|
+
return t;
|
|
774
|
+
}
|
|
775
|
+
changed = true;
|
|
776
|
+
const externalSite = {
|
|
777
|
+
siteId,
|
|
778
|
+
url: nextUrl,
|
|
779
|
+
electronTabId: nextElectronId,
|
|
780
|
+
};
|
|
781
|
+
return {
|
|
782
|
+
...t,
|
|
783
|
+
title: nextTitle,
|
|
784
|
+
externalSite,
|
|
785
|
+
/** @deprecated miroir compat */
|
|
786
|
+
supplier: { ...externalSite, fournisseurId: siteId },
|
|
787
|
+
};
|
|
788
|
+
});
|
|
789
|
+
return changed ? next : prev;
|
|
790
|
+
});
|
|
791
|
+
},
|
|
792
|
+
[],
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const patchSupplierTab = useCallback(
|
|
796
|
+
(
|
|
797
|
+
fournisseurId: number,
|
|
798
|
+
patch: Partial<{ electronTabId: string; url: string; title: string }>,
|
|
799
|
+
) => patchExternalSiteTab(fournisseurId, patch),
|
|
800
|
+
[patchExternalSiteTab],
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const openExternalSite = useCallback(
|
|
804
|
+
(opts: OpenExternalSiteOpts) => {
|
|
805
|
+
const sid = Math.floor(opts.siteId);
|
|
806
|
+
if (!Number.isFinite(sid) || sid < 0) return;
|
|
807
|
+
const href = externalSiteHref(sid);
|
|
808
|
+
const existing = findExternalSiteTab(tabsRef.current, sid);
|
|
809
|
+
|
|
810
|
+
if (existing) {
|
|
811
|
+
// Réactivation (sidebar Hermes/n8n) : garder l'URL live SPA.
|
|
812
|
+
// Navigation explicite (SiteLink / navigateur) : navigateUrl → seed.
|
|
813
|
+
const existingMeta = existing.externalSite ?? existing.supplier;
|
|
814
|
+
const liveUrl = (existingMeta?.url || "").trim();
|
|
815
|
+
const seedUrl = (opts.url || "").trim();
|
|
816
|
+
const nextUrl = opts.navigateUrl
|
|
817
|
+
? seedUrl || liveUrl
|
|
818
|
+
: liveUrl || seedUrl;
|
|
819
|
+
const externalSite = {
|
|
820
|
+
siteId: sid,
|
|
821
|
+
url: nextUrl,
|
|
822
|
+
electronTabId: opts.electronTabId || existingMeta?.electronTabId,
|
|
823
|
+
};
|
|
824
|
+
const updated: WorkspaceTab = {
|
|
825
|
+
...existing,
|
|
826
|
+
title: (opts.title || "").trim() || existing.title,
|
|
827
|
+
href,
|
|
828
|
+
fullscreen: true,
|
|
829
|
+
externalSite,
|
|
830
|
+
/** @deprecated miroir compat */
|
|
831
|
+
supplier: { ...externalSite, fournisseurId: sid },
|
|
832
|
+
};
|
|
833
|
+
const nextTabs = tabsRef.current.map((t) =>
|
|
834
|
+
t.id === existing.id ? updated : t,
|
|
835
|
+
);
|
|
836
|
+
tabsRef.current = nextTabs;
|
|
837
|
+
activeTabIdRef.current = existing.id;
|
|
838
|
+
setTabs(nextTabs);
|
|
839
|
+
setActiveTabId(existing.id);
|
|
840
|
+
setTabBadges((prev) => {
|
|
841
|
+
if (!prev[existing.id]) return prev;
|
|
842
|
+
const next = { ...prev };
|
|
843
|
+
delete next[existing.id];
|
|
844
|
+
return next;
|
|
845
|
+
});
|
|
846
|
+
if (normalizeHref(href) !== currentHrefRef.current) {
|
|
847
|
+
routeTo(href, true);
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const prev = tabsRef.current;
|
|
853
|
+
let next = prev;
|
|
854
|
+
if (prev.length >= MAX_TABS) {
|
|
855
|
+
const victim =
|
|
856
|
+
prev.find(
|
|
857
|
+
(t) =>
|
|
858
|
+
t.id !== activeTabIdRef.current &&
|
|
859
|
+
!isWorkspaceTabLocked(t, pinnedTabIdRef.current),
|
|
860
|
+
) ||
|
|
861
|
+
prev.find((t) => !isWorkspaceTabLocked(t, pinnedTabIdRef.current));
|
|
862
|
+
if (victim) {
|
|
863
|
+
closeElectronTab(victim);
|
|
864
|
+
invalidateKeepAlive(victim.href);
|
|
865
|
+
next = prev.filter((t) => t.id !== victim.id);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const tab = createExternalSiteTab({
|
|
869
|
+
siteId: sid,
|
|
870
|
+
url: opts.url,
|
|
871
|
+
title: opts.title,
|
|
872
|
+
electronTabId: opts.electronTabId,
|
|
873
|
+
});
|
|
874
|
+
const pinned = withPinnedDashboard([...next, tab]);
|
|
875
|
+
tabsRef.current = pinned.tabs;
|
|
876
|
+
pinnedTabIdRef.current = pinned.pinnedId;
|
|
877
|
+
activeTabIdRef.current = tab.id;
|
|
878
|
+
setTabs(pinned.tabs);
|
|
879
|
+
setPinnedTabId(pinned.pinnedId);
|
|
880
|
+
setActiveTabId(tab.id);
|
|
881
|
+
routeTo(href, true);
|
|
882
|
+
},
|
|
883
|
+
[routeTo],
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
const openSupplierSite = useCallback(
|
|
887
|
+
(opts: OpenSupplierSiteOpts) => {
|
|
888
|
+
openExternalSite({
|
|
889
|
+
siteId: opts.fournisseurId,
|
|
890
|
+
url: opts.url,
|
|
891
|
+
title: opts.title,
|
|
892
|
+
electronTabId: opts.electronTabId,
|
|
893
|
+
navigateUrl: opts.navigateUrl,
|
|
894
|
+
});
|
|
895
|
+
},
|
|
896
|
+
[openExternalSite],
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
/** Réordonne un onglet (drag & drop) — ne touche ni l'actif ni les historiques.
|
|
900
|
+
* Le Dashboard épinglé reste en position 0 : ni déplaçable, ni délogeable. */
|
|
901
|
+
const moveTab = useCallback((tabId: string, toIndex: number) => {
|
|
902
|
+
if (tabId === pinnedTabIdRef.current) return;
|
|
903
|
+
setTabs((prev) => {
|
|
904
|
+
const from = prev.findIndex((t) => t.id === tabId);
|
|
905
|
+
if (from < 0) return prev;
|
|
906
|
+
const min = prev[0] && prev[0].id === pinnedTabIdRef.current ? 1 : 0;
|
|
907
|
+
const to = Math.min(Math.max(min, toIndex), prev.length - 1);
|
|
908
|
+
if (from === to) return prev;
|
|
909
|
+
const next = [...prev];
|
|
910
|
+
const [moved] = next.splice(from, 1);
|
|
911
|
+
next.splice(to, 0, moved!);
|
|
912
|
+
return next;
|
|
913
|
+
});
|
|
914
|
+
}, []);
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Focus l'onglet du pathname s'il existe ; sinon l'ouvre en nouvel onglet.
|
|
918
|
+
* Ne pose plus de pastille seule — l'utilisateur doit **voir** la cible.
|
|
919
|
+
*/
|
|
920
|
+
const openOrNotify = useCallback(
|
|
921
|
+
(href: string): "opened" | "focused" | "active" => {
|
|
922
|
+
const h = normalizeHref(href);
|
|
923
|
+
const path = h.split("?")[0] || h;
|
|
924
|
+
// Un seul onglet par pathname — on prend le plus ancien, on ferme les doublons.
|
|
925
|
+
const matches = tabsRef.current.filter(
|
|
926
|
+
(t) => (normalizeHref(t.href).split("?")[0] || "/") === path,
|
|
927
|
+
);
|
|
928
|
+
const existing = matches[0];
|
|
929
|
+
if (matches.length > 1) {
|
|
930
|
+
const keepId = existing!.id;
|
|
931
|
+
const drop = new Set(matches.slice(1).map((t) => t.id));
|
|
932
|
+
for (const t of matches.slice(1)) invalidateKeepAlive(t.href);
|
|
933
|
+
const cleaned = withPinnedDashboard(
|
|
934
|
+
tabsRef.current.filter((t) => !drop.has(t.id)),
|
|
935
|
+
);
|
|
936
|
+
tabsRef.current = cleaned.tabs;
|
|
937
|
+
pinnedTabIdRef.current = cleaned.pinnedId;
|
|
938
|
+
setTabs(cleaned.tabs);
|
|
939
|
+
setPinnedTabId(cleaned.pinnedId);
|
|
940
|
+
setTabBadges((prev) => {
|
|
941
|
+
const next = { ...prev };
|
|
942
|
+
drop.forEach((id) => {
|
|
943
|
+
delete next[id];
|
|
944
|
+
});
|
|
945
|
+
return next;
|
|
946
|
+
});
|
|
947
|
+
if (drop.has(activeTabIdRef.current)) {
|
|
948
|
+
activeTabIdRef.current = keepId;
|
|
949
|
+
setActiveTabId(keepId);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (existing) {
|
|
953
|
+
if (existing.id === activeTabIdRef.current) return "active";
|
|
954
|
+
// Focus (pas pastille) : activateTab efface aussi une éventuelle badge.
|
|
955
|
+
activateTab(existing.id);
|
|
956
|
+
return "focused";
|
|
957
|
+
}
|
|
958
|
+
navigate(h, { newTab: true });
|
|
959
|
+
return "opened";
|
|
960
|
+
},
|
|
961
|
+
[activateTab, navigate],
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
/** ← agit uniquement sur l'historique de l'onglet actif — ne change jamais l'ordre. */
|
|
965
|
+
const goBack = useCallback(() => {
|
|
966
|
+
const active = tabsRef.current.find((t) => t.id === activeTabIdRef.current);
|
|
967
|
+
if (!active || active.historyIndex <= 0) return;
|
|
968
|
+
const nextIdx = active.historyIndex - 1;
|
|
969
|
+
const href = active.history[nextIdx];
|
|
970
|
+
if (!href) return;
|
|
971
|
+
const meta = metaFor(href);
|
|
972
|
+
setTabs((prev) =>
|
|
973
|
+
prev.map((t) =>
|
|
974
|
+
t.id === active.id
|
|
975
|
+
? applyTabMeta(
|
|
976
|
+
{ ...t, historyIndex: nextIdx, href },
|
|
977
|
+
meta,
|
|
978
|
+
)
|
|
979
|
+
: t,
|
|
980
|
+
),
|
|
981
|
+
);
|
|
982
|
+
routeTo(href, true);
|
|
983
|
+
}, [routeTo, metaFor]);
|
|
984
|
+
|
|
985
|
+
/** → agit uniquement sur l'historique de l'onglet actif — ne change jamais l'ordre. */
|
|
986
|
+
const goForward = useCallback(() => {
|
|
987
|
+
const active = tabsRef.current.find((t) => t.id === activeTabIdRef.current);
|
|
988
|
+
if (!active || active.historyIndex >= active.history.length - 1) return;
|
|
989
|
+
const nextIdx = active.historyIndex + 1;
|
|
990
|
+
const href = active.history[nextIdx];
|
|
991
|
+
if (!href) return;
|
|
992
|
+
const meta = metaFor(href);
|
|
993
|
+
setTabs((prev) =>
|
|
994
|
+
prev.map((t) =>
|
|
995
|
+
t.id === active.id
|
|
996
|
+
? applyTabMeta(
|
|
997
|
+
{ ...t, historyIndex: nextIdx, href },
|
|
998
|
+
meta,
|
|
999
|
+
)
|
|
1000
|
+
: t,
|
|
1001
|
+
),
|
|
1002
|
+
);
|
|
1003
|
+
routeTo(href, true);
|
|
1004
|
+
}, [routeTo, metaFor]);
|
|
1005
|
+
|
|
1006
|
+
const setTabMeta = useCallback((href: string, meta: TabMeta) => {
|
|
1007
|
+
const h = normalizeHref(href);
|
|
1008
|
+
if (meta.title) {
|
|
1009
|
+
metaByHrefRef.current.set(h, {
|
|
1010
|
+
title: meta.title,
|
|
1011
|
+
subtitle: meta.subtitle,
|
|
1012
|
+
kind: meta.kind,
|
|
1013
|
+
trail: meta.trail,
|
|
1014
|
+
fullscreen: meta.fullscreen,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
// Cible le/les onglets dont l'URL correspond à la pane émettrice (href),
|
|
1018
|
+
// jamais « l'onglet actif du moment » : au switch optimiste, activeTabId
|
|
1019
|
+
// bascule avant l'URL et l'onglet cliqué recevait brièvement le titre
|
|
1020
|
+
// de l'ancienne page (flash de titre croisé).
|
|
1021
|
+
setTabs((prev) => {
|
|
1022
|
+
let changed = false;
|
|
1023
|
+
const next = prev.map((t) => {
|
|
1024
|
+
if (normalizeHref(t.href) !== h) return t;
|
|
1025
|
+
const updated = applyTabMeta(t, meta);
|
|
1026
|
+
if (updated !== t) changed = true;
|
|
1027
|
+
return updated;
|
|
1028
|
+
});
|
|
1029
|
+
return changed ? next : prev;
|
|
1030
|
+
});
|
|
1031
|
+
}, []);
|
|
1032
|
+
|
|
1033
|
+
const activeTab = useMemo(
|
|
1034
|
+
() => tabs.find((t) => t.id === activeTabId) || tabs[0] || null,
|
|
1035
|
+
[tabs, activeTabId],
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
const activeSurface = useMemo(
|
|
1039
|
+
() => resolveActiveSurface({ activeTab }),
|
|
1040
|
+
[activeTab],
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
const canGoBack = Boolean(activeTab && activeTab.historyIndex > 0);
|
|
1044
|
+
const canGoForward = Boolean(
|
|
1045
|
+
activeTab &&
|
|
1046
|
+
activeTab.historyIndex >= 0 &&
|
|
1047
|
+
activeTab.historyIndex < activeTab.history.length - 1,
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
// Surface native symétrique : quitter site → showCrm ; revenir → activate
|
|
1051
|
+
// (contentRect Electron conservé — rect optionnel). Sans activate ici,
|
|
1052
|
+
// seul le slot paneActive pouvait réafficher : course keep-alive → bug
|
|
1053
|
+
// « contenu de l'onglet précédent ».
|
|
1054
|
+
useEffect(() => {
|
|
1055
|
+
if (!ready) return;
|
|
1056
|
+
const api = getShellDesktopApi();
|
|
1057
|
+
if (!api) return;
|
|
1058
|
+
const externalMeta = activeTab?.externalSite ?? activeTab?.supplier;
|
|
1059
|
+
const cmd =
|
|
1060
|
+
!activeTab || !isExternalSiteHref(activeTab.href)
|
|
1061
|
+
? reduceExternalSiteSurfaceCommand({ type: "leave-external" })
|
|
1062
|
+
: reduceExternalSiteSurfaceCommand({
|
|
1063
|
+
type: "enter-external",
|
|
1064
|
+
electronTabId: externalMeta?.electronTabId,
|
|
1065
|
+
});
|
|
1066
|
+
if (cmd.type === "show-crm") {
|
|
1067
|
+
// Bridge thin (client remote-only) sans surface tabs native : feature-off.
|
|
1068
|
+
void api.showCrm?.();
|
|
1069
|
+
} else if (cmd.type === "activate" && api.activateTab) {
|
|
1070
|
+
void api.activateTab(cmd.tabId);
|
|
1071
|
+
}
|
|
1072
|
+
}, [ready, activeTabId, activeTab]);
|
|
1073
|
+
|
|
1074
|
+
const value = useMemo<TabWorkspaceContextValue>(
|
|
1075
|
+
() => ({
|
|
1076
|
+
ready,
|
|
1077
|
+
tabs,
|
|
1078
|
+
activeTabId,
|
|
1079
|
+
activeTab,
|
|
1080
|
+
pinnedTabId,
|
|
1081
|
+
tabBadges,
|
|
1082
|
+
canGoBack,
|
|
1083
|
+
canGoForward,
|
|
1084
|
+
navigate,
|
|
1085
|
+
openNewTab,
|
|
1086
|
+
activateTab,
|
|
1087
|
+
closeTab,
|
|
1088
|
+
setTabLocked,
|
|
1089
|
+
moveTab,
|
|
1090
|
+
goBack,
|
|
1091
|
+
goForward,
|
|
1092
|
+
setTabMeta,
|
|
1093
|
+
openOrNotify,
|
|
1094
|
+
openExternalSite,
|
|
1095
|
+
openSupplierSite,
|
|
1096
|
+
patchExternalSiteTab,
|
|
1097
|
+
patchSupplierTab,
|
|
1098
|
+
currentHref,
|
|
1099
|
+
activeSurface,
|
|
1100
|
+
}),
|
|
1101
|
+
[
|
|
1102
|
+
ready,
|
|
1103
|
+
tabs,
|
|
1104
|
+
activeTabId,
|
|
1105
|
+
activeTab,
|
|
1106
|
+
pinnedTabId,
|
|
1107
|
+
tabBadges,
|
|
1108
|
+
canGoBack,
|
|
1109
|
+
canGoForward,
|
|
1110
|
+
navigate,
|
|
1111
|
+
openNewTab,
|
|
1112
|
+
activateTab,
|
|
1113
|
+
closeTab,
|
|
1114
|
+
setTabLocked,
|
|
1115
|
+
moveTab,
|
|
1116
|
+
goBack,
|
|
1117
|
+
goForward,
|
|
1118
|
+
setTabMeta,
|
|
1119
|
+
openOrNotify,
|
|
1120
|
+
openExternalSite,
|
|
1121
|
+
openSupplierSite,
|
|
1122
|
+
patchExternalSiteTab,
|
|
1123
|
+
patchSupplierTab,
|
|
1124
|
+
currentHref,
|
|
1125
|
+
activeSurface,
|
|
1126
|
+
],
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
// Publie le navigate courant pour les consommateurs hors provider
|
|
1130
|
+
// (getWorkspaceTabNavigate) — ex. démo interactive montée dans le chrome.
|
|
1131
|
+
useEffect(() => {
|
|
1132
|
+
workspaceNavigateBridge = navigate;
|
|
1133
|
+
return () => {
|
|
1134
|
+
if (workspaceNavigateBridge === navigate) workspaceNavigateBridge = null;
|
|
1135
|
+
};
|
|
1136
|
+
}, [navigate]);
|
|
1137
|
+
|
|
1138
|
+
return (
|
|
1139
|
+
<TabWorkspaceContext.Provider value={value}>
|
|
1140
|
+
{children}
|
|
1141
|
+
</TabWorkspaceContext.Provider>
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export function useTabWorkspace() {
|
|
1146
|
+
const ctx = useContext(TabWorkspaceContext);
|
|
1147
|
+
if (!ctx) {
|
|
1148
|
+
throw new Error("useTabWorkspace must be used within TabWorkspaceProvider");
|
|
1149
|
+
}
|
|
1150
|
+
return ctx;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
export function useTabWorkspaceOptional() {
|
|
1154
|
+
return useContext(TabWorkspaceContext);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
export function useOpenTab() {
|
|
1158
|
+
const workspace = useTabWorkspace();
|
|
1159
|
+
return useCallback(
|
|
1160
|
+
(href: string, opts?: NavigateOptions) => workspace.navigate(href, opts),
|
|
1161
|
+
[workspace],
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
configureTabWorkspaceHost({
|
|
1167
|
+
useTabWorkspace,
|
|
1168
|
+
useOpenTab,
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
/** Branche l'assistant kit sur le même TabWorkspaceProvider (liens chat → navigate). */
|
|
1172
|
+
configureAssistantTabWorkspace(useTabWorkspaceOptional);
|