@matdata/yasgui 5.6.0 → 5.8.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matdata/yasgui",
3
3
  "description": "Yet Another SPARQL GUI",
4
- "version": "5.6.0",
4
+ "version": "5.8.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  "jsuri": "^1.3.1",
34
34
  "lodash-es": "^4.17.15",
35
35
  "sortablejs": "^1.10.2",
36
- "@matdata/yasgui-graph-plugin": "^1.0.0"
36
+ "@matdata/yasgui-graph-plugin": "^1.4.0",
37
+ "@matdata/yasgui-table-plugin": "^1.1.0"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/autosuggest-highlight": "^3.1.0",
@@ -307,6 +307,7 @@ export function parseFromTurtle(turtle: string): Partial<PersistedJson> {
307
307
  basicAuth: undefined,
308
308
  bearerAuth: undefined,
309
309
  apiKeyAuth: undefined,
310
+ oauth2Auth: undefined,
310
311
  },
311
312
  yasr: {
312
313
  settings: {},
@@ -0,0 +1,315 @@
1
+ /**
2
+ * OAuth 2.0 Utility Functions
3
+ * Handles OAuth 2.0 Authorization Code flow with PKCE
4
+ */
5
+
6
+ /**
7
+ * Generate a random string for PKCE code verifier
8
+ */
9
+ function generateRandomString(length: number): string {
10
+ const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
11
+ const values = crypto.getRandomValues(new Uint8Array(length));
12
+ return Array.from(values)
13
+ .map((v) => possible[v % possible.length])
14
+ .join("");
15
+ }
16
+
17
+ /**
18
+ * Generate SHA-256 hash and base64url encode it
19
+ */
20
+ async function sha256(plain: string): Promise<string> {
21
+ const encoder = new TextEncoder();
22
+ const data = encoder.encode(plain);
23
+ const hash = await crypto.subtle.digest("SHA-256", data);
24
+ return base64urlEncode(hash);
25
+ }
26
+
27
+ /**
28
+ * Base64url encode (without padding)
29
+ */
30
+ function base64urlEncode(buffer: ArrayBuffer): string {
31
+ const bytes = new Uint8Array(buffer);
32
+ let binary = "";
33
+ for (let i = 0; i < bytes.length; i++) {
34
+ binary += String.fromCharCode(bytes[i]);
35
+ }
36
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
37
+ }
38
+
39
+ export interface OAuth2Config {
40
+ clientId: string;
41
+ authorizationEndpoint: string;
42
+ tokenEndpoint: string;
43
+ redirectUri: string;
44
+ scope?: string;
45
+ }
46
+
47
+ export interface OAuth2TokenResponse {
48
+ access_token: string;
49
+ id_token?: string; // ID token for authentication (Azure AD, OIDC)
50
+ refresh_token?: string;
51
+ expires_in?: number; // seconds
52
+ token_type: string;
53
+ }
54
+
55
+ /**
56
+ * Check if a token has expired
57
+ */
58
+ export function isTokenExpired(tokenExpiry?: number): boolean {
59
+ if (!tokenExpiry) return true;
60
+ // Add 60 second buffer to refresh before actual expiration
61
+ return Date.now() >= tokenExpiry - 60000;
62
+ }
63
+
64
+ /**
65
+ * Start OAuth 2.0 Authorization Code flow with PKCE
66
+ * Opens a popup window for user authentication
67
+ */
68
+ export async function startOAuth2Flow(config: OAuth2Config): Promise<OAuth2TokenResponse> {
69
+ // Generate PKCE code verifier and challenge
70
+ const codeVerifier = generateRandomString(128);
71
+ const codeChallenge = await sha256(codeVerifier);
72
+
73
+ // Generate state for CSRF protection
74
+ const state = generateRandomString(32);
75
+
76
+ // Generate unique flow ID to prevent collisions across multiple OAuth flows
77
+ const flowId = generateRandomString(16);
78
+
79
+ // Store code verifier and state in sessionStorage for later retrieval with unique keys
80
+ sessionStorage.setItem(`oauth2_code_verifier_${flowId}`, codeVerifier);
81
+ sessionStorage.setItem(`oauth2_state_${flowId}`, state);
82
+
83
+ // Build authorization URL
84
+ const authUrl = new URL(config.authorizationEndpoint);
85
+ authUrl.searchParams.set("client_id", config.clientId);
86
+ authUrl.searchParams.set("response_type", "code");
87
+ authUrl.searchParams.set("redirect_uri", config.redirectUri);
88
+ authUrl.searchParams.set("code_challenge", codeChallenge);
89
+ authUrl.searchParams.set("code_challenge_method", "S256");
90
+ // Include flowId in state to link the callback to this specific flow
91
+ authUrl.searchParams.set("state", `${state}:${flowId}`);
92
+ if (config.scope) {
93
+ authUrl.searchParams.set("scope", config.scope);
94
+ }
95
+
96
+ // Open popup window
97
+ const width = 600;
98
+ const height = 700;
99
+ const left = window.screenX + (window.outerWidth - width) / 2;
100
+ const top = window.screenY + (window.outerHeight - height) / 2;
101
+ const popup = window.open(
102
+ authUrl.toString(),
103
+ "OAuth2 Authorization",
104
+ `width=${width},height=${height},left=${left},top=${top},popup=yes,scrollbars=yes`,
105
+ );
106
+
107
+ if (!popup) {
108
+ throw new Error("Failed to open OAuth 2.0 authorization popup. Please allow popups for this site.");
109
+ }
110
+
111
+ // Wait for the OAuth callback
112
+ return new Promise<OAuth2TokenResponse>((resolve, reject) => {
113
+ // Flag to prevent race condition between polling and messageHandler
114
+ let flowCompleted = false;
115
+
116
+ const cleanup = () => {
117
+ clearInterval(checkInterval);
118
+ window.removeEventListener("message", messageHandler);
119
+ // Clean up sessionStorage
120
+ sessionStorage.removeItem(`oauth2_code_verifier_${flowId}`);
121
+ sessionStorage.removeItem(`oauth2_state_${flowId}`);
122
+ };
123
+
124
+ const checkInterval = setInterval(() => {
125
+ try {
126
+ // Check if popup is closed
127
+ if (popup.closed) {
128
+ if (!flowCompleted) {
129
+ flowCompleted = true;
130
+ cleanup();
131
+ reject(new Error("OAuth 2.0 authorization was cancelled"));
132
+ }
133
+ return;
134
+ }
135
+
136
+ // Try to read popup URL (will throw if cross-origin)
137
+ try {
138
+ const popupUrl = popup.location.href;
139
+ if (popupUrl.startsWith(config.redirectUri) && !flowCompleted) {
140
+ flowCompleted = true;
141
+ cleanup();
142
+ popup.close();
143
+
144
+ // Parse the authorization code from URL
145
+ const url = new URL(popupUrl);
146
+ const code = url.searchParams.get("code");
147
+ const returnedState = url.searchParams.get("state");
148
+ const error = url.searchParams.get("error");
149
+ const errorDescription = url.searchParams.get("error_description");
150
+
151
+ if (error) {
152
+ reject(new Error(`OAuth 2.0 error: ${error}${errorDescription ? " - " + errorDescription : ""}`));
153
+ return;
154
+ }
155
+
156
+ if (!code) {
157
+ reject(new Error("No authorization code received"));
158
+ return;
159
+ }
160
+
161
+ // Validate state includes our flowId
162
+ const expectedState = `${state}:${flowId}`;
163
+ if (returnedState !== expectedState) {
164
+ reject(new Error("State mismatch - possible CSRF attack"));
165
+ return;
166
+ }
167
+
168
+ // Exchange code for tokens
169
+ exchangeCodeForToken(config, code, codeVerifier).then(resolve).catch(reject);
170
+ }
171
+ } catch (e) {
172
+ // Cross-origin error is expected when on OAuth provider's domain
173
+ // Keep waiting
174
+ }
175
+ } catch (e) {
176
+ // Ignore errors while checking popup
177
+ }
178
+ }, 500);
179
+
180
+ // Also listen for postMessage from redirect page (alternative method)
181
+ const messageHandler = (event: MessageEvent) => {
182
+ // Validate origin if needed (should match redirect URI origin)
183
+ if (event.data && event.data.type === "oauth2_callback" && !flowCompleted) {
184
+ flowCompleted = true;
185
+ cleanup();
186
+ if (popup && !popup.closed) {
187
+ popup.close();
188
+ }
189
+
190
+ const { code, state: returnedState, error, error_description } = event.data;
191
+
192
+ if (error) {
193
+ reject(new Error(`OAuth 2.0 error: ${error}${error_description ? " - " + error_description : ""}`));
194
+ return;
195
+ }
196
+
197
+ if (!code) {
198
+ reject(new Error("No authorization code received"));
199
+ return;
200
+ }
201
+
202
+ // Validate state includes our flowId
203
+ const expectedState = `${state}:${flowId}`;
204
+ if (returnedState !== expectedState) {
205
+ reject(new Error("State mismatch - possible CSRF attack"));
206
+ return;
207
+ }
208
+
209
+ // Exchange code for tokens
210
+ exchangeCodeForToken(config, code, codeVerifier).then(resolve).catch(reject);
211
+ }
212
+ };
213
+
214
+ window.addEventListener("message", messageHandler);
215
+
216
+ // Cleanup timeout after 5 minutes
217
+ setTimeout(
218
+ () => {
219
+ if (!flowCompleted) {
220
+ flowCompleted = true;
221
+ cleanup();
222
+ if (popup && !popup.closed) {
223
+ popup.close();
224
+ }
225
+ reject(new Error("OAuth 2.0 authorization timeout"));
226
+ }
227
+ },
228
+ 5 * 60 * 1000,
229
+ );
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Exchange authorization code for access token
235
+ */
236
+ async function exchangeCodeForToken(
237
+ config: OAuth2Config,
238
+ code: string,
239
+ codeVerifier: string,
240
+ ): Promise<OAuth2TokenResponse> {
241
+ const tokenUrl = config.tokenEndpoint;
242
+
243
+ const body = new URLSearchParams();
244
+ body.set("grant_type", "authorization_code");
245
+ body.set("code", code);
246
+ body.set("redirect_uri", config.redirectUri);
247
+ body.set("client_id", config.clientId);
248
+ body.set("code_verifier", codeVerifier);
249
+
250
+ try {
251
+ const response = await fetch(tokenUrl, {
252
+ method: "POST",
253
+ headers: {
254
+ "Content-Type": "application/x-www-form-urlencoded",
255
+ },
256
+ body: body.toString(),
257
+ });
258
+
259
+ if (!response.ok) {
260
+ const errorText = await response.text();
261
+ throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`);
262
+ }
263
+
264
+ const tokenResponse: OAuth2TokenResponse = await response.json();
265
+
266
+ // Note: sessionStorage cleanup is handled by the caller
267
+ return tokenResponse;
268
+ } catch (error) {
269
+ // Note: sessionStorage cleanup is handled by the caller
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Refresh an OAuth 2.0 access token using a refresh token
276
+ */
277
+ export async function refreshOAuth2Token(
278
+ config: Omit<OAuth2Config, "authorizationEndpoint" | "redirectUri">,
279
+ refreshToken: string,
280
+ ): Promise<OAuth2TokenResponse> {
281
+ const tokenUrl = config.tokenEndpoint;
282
+
283
+ const body = new URLSearchParams();
284
+ body.set("grant_type", "refresh_token");
285
+ body.set("refresh_token", refreshToken);
286
+ body.set("client_id", config.clientId);
287
+
288
+ try {
289
+ const response = await fetch(tokenUrl, {
290
+ method: "POST",
291
+ headers: {
292
+ "Content-Type": "application/x-www-form-urlencoded",
293
+ },
294
+ body: body.toString(),
295
+ });
296
+
297
+ if (!response.ok) {
298
+ const errorText = await response.text();
299
+ throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`);
300
+ }
301
+
302
+ const tokenResponse: OAuth2TokenResponse = await response.json();
303
+ return tokenResponse;
304
+ } catch (error) {
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Calculate token expiry timestamp from expires_in value
311
+ */
312
+ export function calculateTokenExpiry(expiresIn?: number): number | undefined {
313
+ if (!expiresIn) return undefined;
314
+ return Date.now() + expiresIn * 1000;
315
+ }
package/src/Tab.ts CHANGED
@@ -9,6 +9,7 @@ import * as shareLink from "./linkUtils";
9
9
  import EndpointSelect from "./endpointSelect";
10
10
  import "./tab.scss";
11
11
  import { getRandomId, default as Yasgui, YasguiRequestConfig } from "./";
12
+ import * as OAuth2Utils from "./OAuth2Utils";
12
13
 
13
14
  // Layout orientation toggle icons
14
15
  const HORIZONTAL_LAYOUT_ICON = `<svg viewBox="0 0 24 24" class="svgImg">
@@ -77,6 +78,8 @@ export class Tab extends EventEmitter {
77
78
  private settingsModal?: TabSettingsModal;
78
79
  private currentOrientation: "vertical" | "horizontal";
79
80
  private orientationToggleButton?: HTMLButtonElement;
81
+ private verticalResizerEl?: HTMLDivElement;
82
+ private editorWrapperEl?: HTMLDivElement;
80
83
 
81
84
  constructor(yasgui: Yasgui, conf: PersistedJson) {
82
85
  super();
@@ -114,6 +117,7 @@ export class Tab extends EventEmitter {
114
117
  // Useful for adding an infos div that goes alongside the editor without needing to rebuild the whole Yasgui class
115
118
  const editorWrapper = document.createElement("div");
116
119
  editorWrapper.className = "editorwrapper";
120
+ this.editorWrapperEl = editorWrapper;
117
121
  const controlbarAndYasqeDiv = document.createElement("div");
118
122
  //controlbar
119
123
  this.controlBarEl = document.createElement("div");
@@ -131,6 +135,10 @@ export class Tab extends EventEmitter {
131
135
 
132
136
  this.initTabSettingsMenu();
133
137
  this.rootEl.appendChild(editorWrapper);
138
+
139
+ // Add vertical resizer for horizontal layout
140
+ this.drawVerticalResizer();
141
+
134
142
  this.rootEl.appendChild(this.yasrWrapperEl);
135
143
  this.initControlbar();
136
144
  this.initYasqe();
@@ -320,6 +328,12 @@ export class Tab extends EventEmitter {
320
328
  tab.updateOrientationToggleIcon();
321
329
  }
322
330
 
331
+ // Reset editor wrapper width when switching orientations
332
+ if (tab.editorWrapperEl) {
333
+ tab.editorWrapperEl.style.width = "";
334
+ tab.editorWrapperEl.style.flex = "";
335
+ }
336
+
323
337
  // Refresh components to adjust to new layout
324
338
  if (tab.yasqe) {
325
339
  tab.yasqe.refresh();
@@ -477,9 +491,15 @@ export class Tab extends EventEmitter {
477
491
  public getName() {
478
492
  return this.persistentJson.name;
479
493
  }
480
-
481
- public query(): Promise<any> {
494
+ public async query(): Promise<any> {
482
495
  if (!this.yasqe) return Promise.reject(new Error("No yasqe editor initialized"));
496
+
497
+ // Check and refresh OAuth 2.0 token if needed
498
+ const tokenValid = await this.ensureOAuth2TokenValid();
499
+ if (!tokenValid) {
500
+ return Promise.reject(new Error("OAuth 2.0 authentication failed"));
501
+ }
502
+
483
503
  return this.yasqe.query();
484
504
  }
485
505
 
@@ -528,11 +548,85 @@ export class Tab extends EventEmitter {
528
548
  apiKey: auth.apiKey,
529
549
  },
530
550
  };
551
+ } else if (auth.type === "oauth2") {
552
+ // For OAuth 2.0, return the current access token and ID token
553
+ // Token refresh is handled separately before query execution
554
+ if (auth.accessToken) {
555
+ return {
556
+ type: "oauth2" as const,
557
+ config: {
558
+ accessToken: auth.accessToken,
559
+ idToken: auth.idToken,
560
+ },
561
+ };
562
+ }
531
563
  }
532
564
 
533
565
  return undefined;
534
566
  }
535
567
 
568
+ /**
569
+ * Check and refresh OAuth 2.0 token if needed
570
+ * Should be called before query execution
571
+ */
572
+ private async ensureOAuth2TokenValid(): Promise<boolean> {
573
+ const endpoint = this.getEndpoint();
574
+ if (!endpoint) return true;
575
+
576
+ const endpointConfig = this.yasgui.persistentConfig.getEndpointConfig(endpoint);
577
+ if (!endpointConfig || !endpointConfig.authentication) return true;
578
+
579
+ const auth = endpointConfig.authentication;
580
+ if (auth.type !== "oauth2") return true;
581
+
582
+ // Check if token is expired
583
+ if (OAuth2Utils.isTokenExpired(auth.tokenExpiry)) {
584
+ // Try to refresh the token if we have a refresh token
585
+ if (auth.refreshToken) {
586
+ try {
587
+ const tokenResponse = await OAuth2Utils.refreshOAuth2Token(
588
+ {
589
+ clientId: auth.clientId,
590
+ tokenEndpoint: auth.tokenEndpoint,
591
+ },
592
+ auth.refreshToken,
593
+ );
594
+
595
+ const tokenExpiry = OAuth2Utils.calculateTokenExpiry(tokenResponse.expires_in);
596
+
597
+ // Update stored authentication with new tokens
598
+ this.yasgui.persistentConfig.addOrUpdateEndpoint(endpoint, {
599
+ authentication: {
600
+ ...auth,
601
+ accessToken: tokenResponse.access_token,
602
+ idToken: tokenResponse.id_token,
603
+ refreshToken: tokenResponse.refresh_token || auth.refreshToken,
604
+ tokenExpiry,
605
+ },
606
+ });
607
+
608
+ return true;
609
+ } catch (error) {
610
+ console.error("Failed to refresh OAuth 2.0 token:", error);
611
+ // Token refresh failed, user needs to re-authenticate
612
+ alert(
613
+ "Your OAuth 2.0 session has expired and could not be refreshed. Please re-authenticate by clicking the Settings button (gear icon) and selecting the SPARQL Endpoints tab.",
614
+ );
615
+ return false;
616
+ }
617
+ } else {
618
+ // No refresh token available, user needs to re-authenticate
619
+ alert(
620
+ "Your OAuth 2.0 session has expired. Please re-authenticate by clicking the Settings button (gear icon) and selecting the SPARQL Endpoints tab.",
621
+ );
622
+ return false;
623
+ }
624
+ }
625
+
626
+ // Token is still valid
627
+ return true;
628
+ }
629
+
536
630
  /**
537
631
  * The Yasgui configuration object may contain a custom request config
538
632
  * This request config object can contain getter functions, or plain json
@@ -606,6 +700,8 @@ export class Tab extends EventEmitter {
606
700
  processedReqConfig.bearerAuth = endpointAuth.config;
607
701
  } else if (endpointAuth.type === "apiKey" && typeof processedReqConfig.apiKeyAuth === "undefined") {
608
702
  processedReqConfig.apiKeyAuth = endpointAuth.config;
703
+ } else if (endpointAuth.type === "oauth2" && typeof processedReqConfig.oauth2Auth === "undefined") {
704
+ processedReqConfig.oauth2Auth = endpointAuth.config;
609
705
  }
610
706
  }
611
707
 
@@ -903,7 +999,94 @@ WHERE {
903
999
  });
904
1000
  }
905
1001
 
1002
+ private drawVerticalResizer() {
1003
+ if (this.verticalResizerEl || !this.rootEl) return;
1004
+ this.verticalResizerEl = document.createElement("div");
1005
+ addClass(this.verticalResizerEl, "verticalResizeWrapper");
1006
+ const chip = document.createElement("div");
1007
+ addClass(chip, "verticalResizeChip");
1008
+ this.verticalResizerEl.appendChild(chip);
1009
+ this.verticalResizerEl.addEventListener("mousedown", this.initVerticalDrag, false);
1010
+ this.verticalResizerEl.addEventListener("dblclick", this.resetVerticalSplit, false);
1011
+ this.rootEl.appendChild(this.verticalResizerEl);
1012
+ }
1013
+
1014
+ private initVerticalDrag = () => {
1015
+ document.documentElement.addEventListener("mousemove", this.doVerticalDrag, false);
1016
+ document.documentElement.addEventListener("mouseup", this.stopVerticalDrag, false);
1017
+ };
1018
+
1019
+ private calculateVerticalDragOffset(event: MouseEvent): number {
1020
+ if (!this.rootEl) return 0;
1021
+
1022
+ let parentOffset = 0;
1023
+ if (this.rootEl.offsetParent) {
1024
+ parentOffset = (this.rootEl.offsetParent as HTMLElement).offsetLeft;
1025
+ }
1026
+
1027
+ let scrollOffset = 0;
1028
+ let parentElement = this.rootEl.parentElement;
1029
+ while (parentElement) {
1030
+ scrollOffset += parentElement.scrollLeft;
1031
+ parentElement = parentElement.parentElement;
1032
+ }
1033
+
1034
+ return event.clientX - parentOffset - this.rootEl.offsetLeft + scrollOffset;
1035
+ }
1036
+
1037
+ private doVerticalDrag = (event: MouseEvent) => {
1038
+ if (!this.editorWrapperEl || !this.rootEl) return;
1039
+
1040
+ const offset = this.calculateVerticalDragOffset(event);
1041
+ const totalWidth = this.rootEl.offsetWidth;
1042
+
1043
+ // Ensure minimum widths (at least 200px for each panel)
1044
+ const minWidth = 200;
1045
+ const maxWidth = totalWidth - minWidth - 10; // 10px for resizer
1046
+
1047
+ const newWidth = Math.max(minWidth, Math.min(maxWidth, offset));
1048
+ this.editorWrapperEl.style.width = newWidth + "px";
1049
+ this.editorWrapperEl.style.flex = "0 0 " + newWidth + "px";
1050
+ };
1051
+
1052
+ private stopVerticalDrag = () => {
1053
+ document.documentElement.removeEventListener("mousemove", this.doVerticalDrag, false);
1054
+ document.documentElement.removeEventListener("mouseup", this.stopVerticalDrag, false);
1055
+
1056
+ // Refresh editors after resizing
1057
+ if (this.yasqe) {
1058
+ this.yasqe.refresh();
1059
+ }
1060
+ if (this.yasr) {
1061
+ this.yasr.refresh();
1062
+ }
1063
+ };
1064
+
1065
+ private resetVerticalSplit = () => {
1066
+ if (!this.editorWrapperEl) return;
1067
+
1068
+ // Reset to 50/50 split
1069
+ this.editorWrapperEl.style.width = "";
1070
+ this.editorWrapperEl.style.flex = "1 1 50%";
1071
+
1072
+ // Refresh editors after resizing
1073
+ if (this.yasqe) {
1074
+ this.yasqe.refresh();
1075
+ }
1076
+ if (this.yasr) {
1077
+ this.yasr.refresh();
1078
+ }
1079
+ };
1080
+
906
1081
  destroy() {
1082
+ // Clean up vertical resizer event listeners
1083
+ if (this.verticalResizerEl) {
1084
+ this.verticalResizerEl.removeEventListener("mousedown", this.initVerticalDrag, false);
1085
+ this.verticalResizerEl.removeEventListener("dblclick", this.resetVerticalSplit, false);
1086
+ }
1087
+ document.documentElement.removeEventListener("mousemove", this.doVerticalDrag, false);
1088
+ document.documentElement.removeEventListener("mouseup", this.stopVerticalDrag, false);
1089
+
907
1090
  this.removeAllListeners();
908
1091
  this.settingsModal?.destroy();
909
1092
  this.endpointSelect?.destroy();
@@ -1,14 +1,19 @@
1
1
  @use "sass:color";
2
+
2
3
  $minTabHeight: 35px;
4
+
3
5
  .yasgui {
4
6
  .tabsList {
7
+ flex-shrink: 0; // Prevent tabs from shrinking
8
+ display: flex;
9
+ flex-wrap: wrap;
10
+
5
11
  .sortable-placeholder {
6
12
  min-width: 100px;
7
13
  min-height: $minTabHeight;
8
14
  border: 2px dotted color.adjust(#555, $lightness: 20%);
9
15
  }
10
- display: flex;
11
- flex-wrap: wrap;
16
+
12
17
  a {
13
18
  cursor: pointer;
14
19
  display: flex;
@@ -18,6 +23,7 @@ $minTabHeight: 35px;
18
23
  border-bottom: 2px solid transparent;
19
24
  box-sizing: border-box;
20
25
  }
26
+
21
27
  .themeToggle {
22
28
  cursor: pointer;
23
29
  height: 100%;
@@ -60,10 +66,12 @@ $minTabHeight: 35px;
60
66
  &:focus-visible {
61
67
  transform: scale(1.1);
62
68
  }
69
+
63
70
  &:focus {
64
71
  color: #faa857;
65
72
  }
66
73
  }
74
+
67
75
  .tab {
68
76
  position: relative;
69
77
  $activeColor: #337ab7;
@@ -82,6 +90,7 @@ $minTabHeight: 35px;
82
90
  animation-timing-function: ease;
83
91
  animation-iteration-count: infinite;
84
92
  }
93
+
85
94
  @keyframes slide {
86
95
  0% {
87
96
  left: 0;
@@ -96,24 +105,30 @@ $minTabHeight: 35px;
96
105
  right: 0;
97
106
  }
98
107
  }
108
+
99
109
  &.active .loader {
100
110
  background-color: $hoverColor;
101
111
  }
112
+
102
113
  &:hover .loader {
103
114
  background-color: $activeColor;
104
115
  }
116
+
105
117
  &.querying .loader {
106
118
  display: block;
107
119
  }
120
+
108
121
  &.active a {
109
122
  border-bottom-color: $activeColor;
110
123
  color: var(--yasgui-text-primary, #555);
111
124
  }
125
+
112
126
  input {
113
127
  display: none;
114
128
  outline: none;
115
129
  border: none;
116
130
  }
131
+
117
132
  &.renaming {
118
133
  span {
119
134
  display: none;
@@ -125,6 +140,7 @@ $minTabHeight: 35px;
125
140
  display: block;
126
141
  }
127
142
  }
143
+
128
144
  a {
129
145
  font-weight: 600;
130
146
  color: var(--yasgui-text-secondary, color.adjust(#555, $lightness: 20%));
@@ -135,14 +151,17 @@ $minTabHeight: 35px;
135
151
  padding: 0px 24px 0px 30px;
136
152
  white-space: nowrap;
137
153
  overflow: hidden;
154
+
138
155
  &:hover {
139
156
  border-bottom-color: $hoverColor;
140
157
  color: var(--yasgui-text-primary, #555);
141
158
  }
159
+
142
160
  &:focus {
143
161
  border-bottom-color: #faa857;
144
162
  color: var(--yasgui-text-primary, #555);
145
163
  }
164
+
146
165
  .closeTab {
147
166
  color: var(--yasgui-text-primary, #000);
148
167
  margin-left: 7px;
@@ -151,6 +170,7 @@ $minTabHeight: 35px;
151
170
  opacity: 0.2;
152
171
  font-weight: 700;
153
172
  padding: 2px;
173
+
154
174
  &:hover {
155
175
  opacity: 0.5;
156
176
  }