From 0d996f3182af369c4322155461105afb52878dfc Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 27 Jan 2026 18:56:05 +0300 Subject: [PATCH 1/2] feat: editor tabs improvements --- e2e/commands.js | 5 + e2e/fixtures/invalid-tabs-export.json | 8 + e2e/fixtures/valid-tabs-export.json | 22 + e2e/tests/console/editor.spec.js | 59 ++ src/components/ReactChromeTabs/chrome-tabs.ts | 490 +++++++--- src/components/ReactChromeTabs/component.tsx | 10 +- src/components/ReactChromeTabs/hooks.tsx | 33 +- src/scenes/Editor/ButtonBar/index.tsx | 4 + src/scenes/Editor/Monaco/importTabs.test.ts | 916 ++++++++++++++++++ src/scenes/Editor/Monaco/importTabs.ts | 219 +++++ src/scenes/Editor/Monaco/index.tsx | 4 + src/scenes/Editor/Monaco/tabs.tsx | 255 +++-- src/scenes/Editor/index.tsx | 3 + src/store/buffers.ts | 6 +- src/styles/lib/_react-chrome-tabs.scss | 407 +++----- 15 files changed, 1926 insertions(+), 515 deletions(-) create mode 100644 e2e/fixtures/invalid-tabs-export.json create mode 100644 e2e/fixtures/valid-tabs-export.json create mode 100644 src/scenes/Editor/Monaco/importTabs.test.ts create mode 100644 src/scenes/Editor/Monaco/importTabs.ts diff --git a/e2e/commands.js b/e2e/commands.js index 9707098ea..d3585fe96 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -44,6 +44,11 @@ before(() => { if (err.message.includes("ResizeObserver loop")) { return false } + // Monaco editor's word highlighter throws "Canceled" errors during rapid tab switching + // when restoreViewState cancels pending async operations - this is harmless + if (err.message.includes("Canceled")) { + return false + } }) indexedDB.deleteDatabase("web-console") diff --git a/e2e/fixtures/invalid-tabs-export.json b/e2e/fixtures/invalid-tabs-export.json new file mode 100644 index 000000000..d7ebdb48b --- /dev/null +++ b/e2e/fixtures/invalid-tabs-export.json @@ -0,0 +1,8 @@ +[ + { + "label": 123, + "value": "SELECT 1;", + "position": 0, + "editorViewState": {} + } +] diff --git a/e2e/fixtures/valid-tabs-export.json b/e2e/fixtures/valid-tabs-export.json new file mode 100644 index 000000000..059a291fc --- /dev/null +++ b/e2e/fixtures/valid-tabs-export.json @@ -0,0 +1,22 @@ +[ + { + "label": "Imported Tab 1", + "value": "SELECT 1;", + "position": 0, + "editorViewState": {} + }, + { + "label": "Imported Tab 2", + "value": "SELECT 2;", + "position": 1, + "editorViewState": {} + }, + { + "label": "Archived Import", + "value": "SELECT 'archived';", + "position": -1, + "editorViewState": {}, + "archived": true, + "archivedAt": 1706400000000 + } +] diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 31e073662..9c6bb4a3b 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -888,6 +888,16 @@ describe("editor tabs", () => { }) cy.getEditorHitbox().click() cy.getEditorTabByTitle("New updated name").should("be.visible") + + cy.getEditorTabByTitle("New updated name").realHover() + cy.getEditorTabByTitle("New updated name").within(() => { + cy.get(".chrome-tab-edit").should("be.visible").click() + cy.get(".chrome-tab-rename") + .should("be.visible") + .clear() + .type("Renamed via button{enter}") + }) + cy.getEditorTabByTitle("Renamed via button").should("be.visible") }) it("should drag tabs", () => { @@ -1377,3 +1387,52 @@ describe("abortion on new query execution", () => { cy.getByDataHook("success-notification").should("contain", "select 2") }) }) + +describe("import/export tabs", () => { + beforeEach(() => { + cy.loadConsoleWithAuth() + cy.getEditorTabs().should("be.visible") + }) + + it("should show error toast when importing invalid file", () => { + cy.getByDataHook("editor-tabs-menu-button").click() + cy.getByDataHook("editor-tabs-menu").should("be.visible") + + cy.getByDataHook("editor-tabs-menu-import").click() + cy.getByDataHook("editor-tabs-import-input").selectFile( + "e2e/fixtures/invalid-tabs-export.json", + { force: true }, + ) + + cy.get(".toast-error-container") + .should("be.visible") + .should("contain", "Invalid file format") + .should("contain", "Item [0]: label must be a string") + }) + + it("should import tabs successfully with active and archived tabs", () => { + cy.getByDataHook("editor-tabs-menu-button").click() + cy.getByDataHook("editor-tabs-menu").should("be.visible") + + cy.getByDataHook("editor-tabs-menu-import").click() + cy.getByDataHook("editor-tabs-import-input").selectFile( + "e2e/fixtures/valid-tabs-export.json", + { force: true }, + ) + + cy.get(".toast-success-container") + .should("be.visible") + .should("contain", "Imported 3 tabs successfully") + .click() + + cy.getEditorTabByTitle("Imported Tab 1").should("be.visible") + cy.getEditorTabByTitle("Imported Tab 2").should("be.visible") + + cy.getByDataHook("editor-tabs-history-button").click() + cy.getByDataHook("editor-tabs-history").should("be.visible") + cy.getByDataHook("editor-tabs-history-item").should( + "contain", + "Archived Import", + ) + }) +}) diff --git a/src/components/ReactChromeTabs/chrome-tabs.ts b/src/components/ReactChromeTabs/chrome-tabs.ts index 91997862d..bde5ea939 100644 --- a/src/components/ReactChromeTabs/chrome-tabs.ts +++ b/src/components/ReactChromeTabs/chrome-tabs.ts @@ -24,30 +24,17 @@ import Draggabilly from "draggabilly" -const TAB_CONTENT_MARGIN = 10 -const TAB_CONTENT_OVERLAP_DISTANCE = 1 - -const TAB_CONTENT_MIN_WIDTH = 24 +const TAB_CONTENT_MIN_WIDTH = 150 const TAB_CONTENT_MAX_WIDTH = 240 - -const TAB_SIZE_SMALL = 84 -const TAB_SIZE_SMALLER = 60 -const TAB_SIZE_MINI = 48 const NEW_TAB_BUTTON_AREA = 90 -const newTabButtonTemplate = ` -
- -
- ` - const closest = (value: number, array: number[]) => { - let closest = Infinity + let closestDist = Infinity let closestIndex = -1 array.forEach((v, i) => { - if (Math.abs(value - v) < closest) { - closest = Math.abs(value - v) + if (Math.abs(value - v) < closestDist) { + closestDist = Math.abs(value - v) closestIndex = i } }) @@ -55,17 +42,22 @@ const closest = (value: number, array: number[]) => { return closestIndex } +const newTabButtonTemplate = ` +
+ +
+ ` + const tabTemplate = `
-
-
- -
+
@@ -116,11 +108,33 @@ class ChromeTabs { draggabillies: Draggabilly[] isDragging: boolean draggabillyDragging: Draggabilly | null + initialScrollDone: boolean + autoScrollInterval: number | null + autoScrollSpeed: number + dragPlaceholder: HTMLElement | null + dragState: { + tabEl: HTMLElement | null + originIndex: number + currentIndex: number + startScrollLeft: number + pointerX: number + } constructor() { this.draggabillies = [] this.isDragging = false this.draggabillyDragging = null + this.initialScrollDone = false + this.autoScrollInterval = null + this.autoScrollSpeed = 0 + this.dragPlaceholder = null + this.dragState = { + tabEl: null, + originIndex: -1, + currentIndex: -1, + startScrollLeft: 0, + pointerX: 0, + } } init(el: HTMLElement, limit?: number) { @@ -131,7 +145,6 @@ class ChromeTabs { this.el.setAttribute("data-chrome-tabs-instance-id", this.instanceId + "") instanceId += 1 - this.setupCustomProperties() this.setupStyleEl() this.setupEvents() this.layoutTabs() @@ -143,10 +156,6 @@ class ChromeTabs { this.el.dispatchEvent(new CustomEvent(eventName, { detail: data })) } - setupCustomProperties() { - this.el.style.setProperty("--tab-content-margin", `${TAB_CONTENT_MARGIN}px`) - } - setupStyleEl() { this.styleEl = document.createElement("style") this.el.appendChild(this.styleEl) @@ -165,9 +174,14 @@ class ChromeTabs { resizeObserver.observe(this.el) + this.tabContentEl.addEventListener("scroll", () => { + this.updateOverflowShadows() + }) + this.el.addEventListener("click", ({ target }) => { if (target instanceof Element) { - if (target.classList.contains("new-tab-button")) { + const newTabButton = target.closest(".new-tab-button") + if (newTabButton) { this.emit("newTab", {}) this.setupNewTabButton() } @@ -176,6 +190,8 @@ class ChromeTabs { this.tabEls.forEach((tabEl) => this.setTabCloseEventListener(tabEl)) + this.tabEls.forEach((tabEl) => this.setTabEditEventListener(tabEl)) + this.tabEls.forEach((tabEl) => this.setTabRenameConfirmEventListener(tabEl)) document.addEventListener("click", ({ target }) => { @@ -213,29 +229,19 @@ class ChromeTabs { return this.el.querySelector(".chrome-tabs-content")! } - get tabContentWidths() { + get tabWidths() { const numberOfTabs = this.tabEls.length - const tabsContentWidth = this.el.clientWidth - NEW_TAB_BUTTON_AREA - const tabsCumulativeOverlappedWidth = - (numberOfTabs - 1) * TAB_CONTENT_OVERLAP_DISTANCE - const targetWidth = - (tabsContentWidth - - 2 * TAB_CONTENT_MARGIN + - tabsCumulativeOverlappedWidth) / - numberOfTabs + const availableWidth = this.el.clientWidth - NEW_TAB_BUTTON_AREA + const targetWidth = availableWidth / numberOfTabs const clampedTargetWidth = Math.max( TAB_CONTENT_MIN_WIDTH, Math.min(TAB_CONTENT_MAX_WIDTH, targetWidth), ) const flooredClampedTargetWidth = Math.floor(clampedTargetWidth) - const totalTabsWidthUsingTarget = - flooredClampedTargetWidth * numberOfTabs + - 2 * TAB_CONTENT_MARGIN - - tabsCumulativeOverlappedWidth + const totalTabsWidthUsingTarget = flooredClampedTargetWidth * numberOfTabs const totalExtraWidthDueToFlooring = - tabsContentWidth - totalTabsWidthUsingTarget + availableWidth - totalTabsWidthUsingTarget - // TODO - Support tabs with different widths / e.g. "pinned" tabs const widths = [] let extraWidthRemaining = totalExtraWidthDueToFlooring for (let i = 0; i < numberOfTabs; i += 1) { @@ -251,46 +257,24 @@ class ChromeTabs { return widths } - get tabContentPositions() { - const positions: number[] = [] - const tabContentWidths = this.tabContentWidths - - let position = TAB_CONTENT_MARGIN - tabContentWidths.forEach((width, i) => { - const offset = i * TAB_CONTENT_OVERLAP_DISTANCE - positions.push(position - offset) - position += width - }) - - return positions - } - get tabPositions() { const positions: number[] = [] + const tabWidths = this.tabWidths - this.tabContentPositions.forEach((contentPosition) => { - positions.push(contentPosition - TAB_CONTENT_MARGIN) + let position = 0 + tabWidths.forEach((width) => { + positions.push(position) + position += width }) return positions } layoutTabs() { - const tabContentWidths = this.tabContentWidths + const tabWidths = this.tabWidths this.tabEls.forEach((tabEl, i) => { - const contentWidth = tabContentWidths[i] - const width = contentWidth + 2 * TAB_CONTENT_MARGIN - - tabEl.style.width = width + "px" - tabEl.removeAttribute("is-small") - tabEl.removeAttribute("is-smaller") - tabEl.removeAttribute("is-mini") - - if (contentWidth < TAB_SIZE_SMALL) tabEl.setAttribute("is-small", "") - if (contentWidth < TAB_SIZE_SMALLER) tabEl.setAttribute("is-smaller", "") - if (contentWidth < TAB_SIZE_MINI) tabEl.setAttribute("is-mini", "") - + tabEl.style.width = tabWidths[i] + "px" const closeEl = tabEl.querySelector(".chrome-tab-close") if (closeEl) { closeEl.style.display = this.tabEls.length > 1 ? "block" : "none" @@ -300,32 +284,40 @@ class ChromeTabs { let styleHTML = "" this.tabPositions.forEach((position, i) => { styleHTML += ` - .chrome-tabs[data-chrome-tabs-instance-id="${ - this.instanceId - }"] .chrome-tab:nth-child(${i + 1}) { - transform: translate3d(${position}px, 0, 0) - } - ` + .chrome-tabs[data-chrome-tabs-instance-id="${ + this.instanceId + }"] .chrome-tab:nth-child(${i + 1}) { + transform: translate3d(${position}px, 0, 0) + } + ` }) this.styleEl.innerHTML = styleHTML - const tabsLen = this.tabEls.length - if ( - this.el.offsetWidth - this.tabContentEl.offsetWidth > - NEW_TAB_BUTTON_AREA + TAB_CONTENT_MARGIN / 2 || - tabsLen < 5 - ) { - this.tabContentEl.style.width = `${ - (this.tabEls[0] ? this.tabEls[0].offsetWidth * tabsLen : 0) - - (tabsLen > 0 - ? tabsLen * TAB_CONTENT_MARGIN * 2 - - TAB_CONTENT_MIN_WIDTH + - TAB_CONTENT_MARGIN - : 0) - }px` - this.tabContentEl.nextElementSibling!.classList.remove("overflow-shadow") - } else - this.tabContentEl.nextElementSibling!.classList.add("overflow-shadow") + const totalTabsWidth = tabWidths.reduce((sum, w) => sum + w, 0) + this.tabContentEl.style.width = `${totalTabsWidth}px` + + this.updateOverflowShadows() + } + + updateOverflowShadows() { + const container = this.tabContentEl + const scrollLeft = container.scrollLeft + const scrollWidth = container.scrollWidth + const clientWidth = container.clientWidth + + const hasOverflowLeft = scrollLeft > 0 + const hasOverflowRight = scrollLeft + clientWidth < scrollWidth - 1 // -1 for rounding tolerance + + const parentRect = this.el.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const rightOffset = Math.max(0, parentRect.right - containerRect.right) + this.el.style.setProperty( + "--overflow-shadow-right-offset", + `${rightOffset}px`, + ) + + this.el.setAttribute("data-overflow-left", hasOverflowLeft.toString()) + this.el.setAttribute("data-overflow-right", hasOverflowRight.toString()) } createNewTabEl() { @@ -342,7 +334,7 @@ class ChromeTabs { tabEl.oncontextmenu = (event) => { this.emit("contextmenu", { tabEl, event }) } - if (animate) { + if (animate && this.initialScrollDone) { tabEl.classList.add("chrome-tab-was-just-added") setTimeout(() => tabEl.classList.remove("chrome-tab-was-just-added"), 500) } @@ -350,6 +342,7 @@ class ChromeTabs { tabProperties = Object.assign({}, defaultTapProperties, tabProperties) this.tabContentEl.appendChild(tabEl) this.setTabCloseEventListener(tabEl) + this.setTabEditEventListener(tabEl) this.setTabRenameConfirmEventListener(tabEl) this.updateTab(tabEl, tabProperties) this.emit("tabAdd", { tabEl }) @@ -371,6 +364,16 @@ class ChromeTabs { .addEventListener("click", closeTabEvent) } + setTabEditEventListener(tabEl: HTMLElement) { + const editTabEvent = (_: Event) => { + _.stopImmediatePropagation() + this.showRenameTab(tabEl) + } + tabEl + .querySelector(".chrome-tab-edit")! + .addEventListener("click", editTabEvent) + } + setTabRenameConfirmEventListener(tabEl: HTMLElement) { const input = tabEl.querySelector(".chrome-tab-rename") as HTMLInputElement input.addEventListener("keydown", (e: KeyboardEvent) => { @@ -398,6 +401,46 @@ class ChromeTabs { if (activeTabEl) activeTabEl.removeAttribute("active") tabEl.setAttribute("active", "") this.emit("activeTabChange", { tabEl }) + if (this.initialScrollDone) { + setTimeout(() => this.scrollTabIntoView(tabEl)) + } + } + + scrollTabIntoView(tabEl: HTMLElement) { + const container = this.tabContentEl + const tabIndex = this.tabEls.indexOf(tabEl) + if (tabIndex === -1) return + + const tabRect = tabEl.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + + const tabLeft = tabRect.left - containerRect.left + container.scrollLeft + const tabRight = tabLeft + tabRect.width + + const containerScrollLeft = container.scrollLeft + const containerVisibleWidth = containerRect.width + const containerVisibleRight = containerScrollLeft + containerVisibleWidth + + if (tabLeft < containerScrollLeft) { + container.scrollTo({ + left: tabLeft, + }) + } else if (tabRight > containerVisibleRight) { + container.scrollTo({ + left: tabRight - containerVisibleWidth, + }) + } + } + + completeInitialSetup() { + if (this.initialScrollDone) return + + this.initialScrollDone = true + const activeTab = this.activeTabEl as HTMLElement | null + if (activeTab) { + this.scrollTabIntoView(activeTab) + } + this.el.classList.add("chrome-tabs-ready") } removeTab(tabEl: HTMLElement) { @@ -458,7 +501,17 @@ class ChromeTabs { } } + isTabRenameable(tabEl: HTMLElement) { + return ( + !tabEl.classList.contains("temporary-tab") && + !tabEl.classList.contains("preview-tab") + ) + } + showRenameTab(tabEl: HTMLElement) { + if (!this.isTabRenameable(tabEl)) { + return + } tabEl.setAttribute("is-renaming", "") tabEl.setAttribute("data-tab-title", tabEl.textContent?.trim() || "") const titleEl = tabEl.querySelector(".chrome-tab-title") as HTMLDivElement @@ -466,9 +519,11 @@ class ChromeTabs { ".chrome-tab-rename", ) as HTMLInputElement const closeEl = tabEl.querySelector(".chrome-tab-close") as HTMLDivElement + const editEl = tabEl.querySelector(".chrome-tab-edit") as HTMLDivElement titleEl.style.display = "none" inputEl.style.display = "block" closeEl.style.display = "none" + editEl.style.display = "none" inputEl.focus() inputEl.select() } @@ -484,9 +539,11 @@ class ChromeTabs { titleEl.textContent = tabEl.getAttribute("data-tab-title") || "" } const closeEl = tabEl.querySelector(".chrome-tab-close") as HTMLDivElement + const editEl = tabEl.querySelector(".chrome-tab-edit") as HTMLDivElement titleEl.style.display = "block" inputEl.style.display = "none" closeEl.style.display = this.tabEls.length > 1 ? "block" : "none" + editEl.style.display = "" } toggleRenameTab(tabEl: HTMLElement) { @@ -504,12 +561,101 @@ class ChromeTabs { ) } + createDragPlaceholder(tabWidth: number) { + if (this.dragPlaceholder) return + + this.dragPlaceholder = document.createElement("div") + this.dragPlaceholder.style.width = `${tabWidth}px` + this.dragPlaceholder.style.height = "1px" + this.dragPlaceholder.style.visibility = "hidden" + this.dragPlaceholder.style.pointerEvents = "none" + this.dragPlaceholder.style.position = "absolute" + // Position it at the end of the tab content to maintain scrollWidth + const totalWidth = + this.tabPositions[this.tabPositions.length - 1] + tabWidth + this.dragPlaceholder.style.left = `${totalWidth - tabWidth}px` + this.dragPlaceholder.classList.add("chrome-tab-drag-placeholder") + + this.tabContentEl.appendChild(this.dragPlaceholder) + } + + removeDragPlaceholder() { + if (this.dragPlaceholder) { + this.dragPlaceholder.remove() + this.dragPlaceholder = null + } + } + + startAutoScroll() { + if (this.autoScrollInterval) return + + this.autoScrollInterval = window.setInterval(() => { + if (this.autoScrollSpeed === 0 || !this.isDragging) return + + const container = this.tabContentEl + const newScrollLeft = container.scrollLeft + this.autoScrollSpeed + const maxScroll = container.scrollWidth - container.clientWidth + + container.scrollLeft = Math.max(0, Math.min(maxScroll, newScrollLeft)) + this.updateOverflowShadows() + + if (this.dragState.tabEl) { + this.updateDraggedTabPosition() + } + }, 16) // ~60fps + } + + stopAutoScroll() { + if (this.autoScrollInterval) { + clearInterval(this.autoScrollInterval) + this.autoScrollInterval = null + } + this.autoScrollSpeed = 0 + } + + updateDraggedTabPosition() { + const { tabEl, pointerX } = this.dragState + if (!tabEl) return + + const container = this.tabContentEl + const containerRect = container.getBoundingClientRect() + const tabWidth = tabEl.offsetWidth + + // Calculate absolute position in the scrollable content based on pointer position + const relativePointerX = pointerX - containerRect.left + const absoluteX = relativePointerX + container.scrollLeft - tabWidth / 2 + + // Clamp to valid range + const maxPosition = container.scrollWidth - tabWidth + const clampedPosition = Math.max(0, Math.min(maxPosition, absoluteX)) + + // For fixed positioning, we need screen coordinates + // Visual X in container = clampedPosition - scrollLeft + // Screen X = containerRect.left + visualX + const visualX = clampedPosition - container.scrollLeft + const screenX = containerRect.left + visualX + + // Update tab position using fixed positioning (left/top instead of transform) + tabEl.style.left = `${screenX}px` + tabEl.style.top = `${containerRect.top}px` + tabEl.style.transform = "none" + + // Check for reorder + const destIndex = closest(clampedPosition, this.tabPositions) + + if (destIndex !== this.dragState.currentIndex && destIndex !== -1) { + this.animateTabMove(tabEl, this.dragState.currentIndex, destIndex) + this.dragState.currentIndex = destIndex + } + } + setupDraggabilly() { const tabEls = this.tabEls if (this.isDragging && this.draggabillyDragging) { this.isDragging = false this.el.classList.remove("chrome-tabs-is-sorting") + this.removeDragPlaceholder() const draggabilly = this.draggabillyDragging as unknown as { element: HTMLElement dragEnd: () => void @@ -527,6 +673,8 @@ class ChromeTabs { } this.draggabillies.forEach((d) => d.destroy()) + this.draggabillies = [] + if (tabEls.find((el) => el.classList.contains("temporary-tab"))) { return } @@ -535,10 +683,9 @@ class ChromeTabs { const draggabilly = new Draggabilly(tabEl, { axis: "x", handle: ".chrome-tab-drag-handle", - containment: this.tabContentEl, + containment: false, }) - let dragStartTabPositionX: number = 0 let lastClickX: number let lastClickY: number let lastTimeStamp: number = 0 @@ -567,63 +714,137 @@ class ChromeTabs { lastTimeStamp = timeStamp } this.emit("tabClick", { tabEl }) - // this.setCurrentTab(tabEl); }) - draggabilly.on("dragStart", () => { + draggabilly.on("dragStart", (_event, pointer) => { this.isDragging = true this.draggabillyDragging = draggabilly + + const originIndex = this.tabEls.indexOf(tabEl) + const tabWidth = tabEl.offsetWidth + + this.createDragPlaceholder(tabWidth) + tabEl.classList.add("chrome-tab-is-dragging") this.el.classList.add("chrome-tabs-is-sorting") - const currentTabIndex = this.tabEls.indexOf(tabEl) - dragStartTabPositionX = this.tabPositions[currentTabIndex] ?? 0 + + this.dragState = { + tabEl, + originIndex, + currentIndex: originIndex, + startScrollLeft: this.tabContentEl.scrollLeft, + pointerX: pointer.clientX, + } + + // Disable Draggabilly's positioning - we'll handle it ourselves + // @ts-expect-error - accessing internal property + draggabilly.positionDrag = () => {} + + // Set initial position immediately + this.updateDraggedTabPosition() + this.emit("dragStart", {}) }) draggabilly.on("dragEnd", () => { this.isDragging = false - const finalTranslateX = parseFloat(tabEl.style.left) - tabEl.style.transform = `translate3d(0, 0, 0)` - this.emit("dragEnd", {}) + this.stopAutoScroll() - // Animate dragged tab back into its place - requestAnimationFrame(() => { - tabEl.style.left = "0" - tabEl.style.transform = `translate3d(${finalTranslateX}px, 0, 0)` + const { originIndex } = this.dragState - requestAnimationFrame(() => { - tabEl.classList.remove("chrome-tab-is-dragging") - this.el.classList.remove("chrome-tabs-is-sorting") + this.dragState = { + tabEl: null, + originIndex: -1, + currentIndex: -1, + startScrollLeft: 0, + pointerX: 0, + } + + tabEl.style.position = "" + tabEl.style.left = "" + tabEl.style.top = "" + + const finalIndex = this.tabEls.indexOf(tabEl) + const finalPosition = this.tabPositions[finalIndex] + + tabEl.style.transform = `translate3d(${finalPosition}px, 0, 0)` + + // Emit reorder BEFORE dragEnd so React component can process it first + // Use finalIndex (actual DOM position) rather than currentIndex for accuracy + if (originIndex !== finalIndex) { + this.emit("tabReorder", { + tabEl, + originIndex, + destinationIndex: finalIndex, + }) + } + + this.emit("dragEnd", {}) - tabEl.classList.add("chrome-tab-was-just-dragged") + requestAnimationFrame(() => { + tabEl.classList.remove("chrome-tab-is-dragging") + this.el.classList.remove("chrome-tabs-is-sorting") + tabEl.classList.add("chrome-tab-was-just-dragged") - requestAnimationFrame(() => { - tabEl.style.transform = "" + this.removeDragPlaceholder() - this.layoutTabs() - this.setupDraggabilly() - }) + requestAnimationFrame(() => { + tabEl.style.transform = "" + this.layoutTabs() + this.setupDraggabilly() + this.scrollTabIntoView(tabEl) }) }) }) - draggabilly.on("dragMove", (event, pointer, moveVector) => { - // Current index be computed within the event since it can change during the dragMove - const tabEls = this.tabEls - const currentIndex = tabEls.indexOf(tabEl) - - const currentTabPositionX = dragStartTabPositionX + moveVector.x - const destinationIndexTarget = closest( - currentTabPositionX, - this.tabPositions, - ) - const destinationIndex = Math.max( - 0, - Math.min(tabEls.length, destinationIndexTarget), - ) - - if (currentIndex !== destinationIndex) { - this.animateTabMove(tabEl, currentIndex, destinationIndex) + draggabilly.on("dragMove", (_event, pointer) => { + const container = this.tabContentEl + const containerRect = container.getBoundingClientRect() + const tabWidth = tabEl.offsetWidth + + // Store pointer position for auto-scroll updates + this.dragState.pointerX = pointer.clientX + + // Calculate absolute position in the scrollable content + const relativePointerX = pointer.clientX - containerRect.left + const absoluteX = relativePointerX + container.scrollLeft - tabWidth / 2 + + const maxPosition = container.scrollWidth - tabWidth + const clampedPosition = Math.max(0, Math.min(maxPosition, absoluteX)) + + const visualX = clampedPosition - container.scrollLeft + const screenX = containerRect.left + visualX + + tabEl.style.left = `${screenX}px` + tabEl.style.top = `${containerRect.top}px` + tabEl.style.transform = "none" + + const destIndex = closest(clampedPosition, this.tabPositions) + + if (destIndex !== this.dragState.currentIndex && destIndex !== -1) { + this.animateTabMove(tabEl, this.dragState.currentIndex, destIndex) + this.dragState.currentIndex = destIndex + } + + const edgeThreshold = 50 + const maxScrollSpeed = 10 + + const distanceFromLeft = pointer.clientX - containerRect.left + const distanceFromRight = containerRect.right - pointer.clientX + + if (distanceFromLeft < edgeThreshold && container.scrollLeft > 0) { + const intensity = 1 - distanceFromLeft / edgeThreshold + this.autoScrollSpeed = -maxScrollSpeed * intensity + this.startAutoScroll() + } else if ( + distanceFromRight < edgeThreshold && + container.scrollLeft < container.scrollWidth - container.clientWidth + ) { + const intensity = 1 - distanceFromRight / edgeThreshold + this.autoScrollSpeed = maxScrollSpeed * intensity + this.startAutoScroll() + } else { + this.autoScrollSpeed = 0 } }) }) @@ -639,7 +860,6 @@ class ChromeTabs { } else { tabEl.parentNode!.insertBefore(tabEl, this.tabEls[destinationIndex + 1]) } - this.emit("tabReorder", { tabEl, originIndex, destinationIndex }) this.layoutTabs() } diff --git a/src/components/ReactChromeTabs/component.tsx b/src/components/ReactChromeTabs/component.tsx index f6c2c47a2..dd83fefd1 100644 --- a/src/components/ReactChromeTabs/component.tsx +++ b/src/components/ReactChromeTabs/component.tsx @@ -74,7 +74,14 @@ export function Tabs({ } }, [onTabReorder]) - const { ChromeTabs, addTab, activeTab, removeTab, updateTab } = useChromeTabs( + const { + ChromeTabs, + addTab, + activeTab, + removeTab, + updateTab, + completeInitialSetup, + } = useChromeTabs( { onTabClose, onTabActive, @@ -108,6 +115,7 @@ export function Tabs({ activeTab(tab.id) } }) + setTimeout(() => completeInitialSetup()) } tabsRef.current = tabs }, [tabs]) diff --git a/src/components/ReactChromeTabs/hooks.tsx b/src/components/ReactChromeTabs/hooks.tsx index 1bddfd076..965654da7 100644 --- a/src/components/ReactChromeTabs/hooks.tsx +++ b/src/components/ReactChromeTabs/hooks.tsx @@ -22,13 +22,7 @@ * ******************************************************************************/ -import React, { - CSSProperties, - forwardRef, - useCallback, - useEffect, - useRef, -} from "react" +import React, { forwardRef, useCallback, useEffect, useRef } from "react" import ChromeTabsClz, { TabProperties, TabEventDetail, @@ -42,7 +36,6 @@ export type Listeners = { onTabClose?: (tabId: string) => void onTabReorder?: (tabId: string, fromIdex: number, toIndex: number) => void onTabRename?: (tabId: string, title: string) => void - onDragBegin?: () => void onDragEnd?: () => void onContextMenu?: (tabId: string, event: MouseEvent) => void onNewTab?: () => void @@ -60,13 +53,7 @@ const ChromeTabsWrapper = forwardRef< classList.push(props.className) } return ( -
+
) @@ -141,17 +128,6 @@ export function useChromeTabs(listeners: Listeners, limit?: number) { } }, [listeners.onTabClose]) - useEffect(() => { - const listener = () => { - listeners.onDragBegin?.() - } - const ele = chromeTabsRef.current?.el - ele?.addEventListener("dragBegin", listener) - return () => { - ele?.removeEventListener("dragBegin", listener) - } - }, [listeners.onDragBegin]) - useEffect(() => { const ele = chromeTabsRef.current?.el const listener = (event: Event) => { @@ -226,6 +202,10 @@ export function useChromeTabs(listeners: Listeners, limit?: number) { } }, []) + const completeInitialSetup = useCallback(() => { + chromeTabsRef.current?.completeInitialSetup() + }, []) + const ChromeTabs = useCallback(function ChromeTabs(props: { className?: string darkMode?: boolean @@ -239,5 +219,6 @@ export function useChromeTabs(listeners: Listeners, limit?: number) { updateTab, removeTab, activeTab, + completeInitialSetup, } } diff --git a/src/scenes/Editor/ButtonBar/index.tsx b/src/scenes/Editor/ButtonBar/index.tsx index a0d3fb181..c3d6ac393 100644 --- a/src/scenes/Editor/ButtonBar/index.tsx +++ b/src/scenes/Editor/ButtonBar/index.tsx @@ -31,6 +31,10 @@ const ButtonBarWrapper = styled.div<{ gap: 1rem; align-items: center; `} + + @media (max-width: 768px) { + display: none; + } ` const ButtonGroup = styled.div` diff --git a/src/scenes/Editor/Monaco/importTabs.test.ts b/src/scenes/Editor/Monaco/importTabs.test.ts new file mode 100644 index 000000000..ef0cea729 --- /dev/null +++ b/src/scenes/Editor/Monaco/importTabs.test.ts @@ -0,0 +1,916 @@ +import { describe, it, expect, vi } from "vitest" +import { + MetricType, + MetricViewMode, + SampleBy, + RefreshRate, +} from "../Metrics/utils" + +// Mock the buffers module to avoid React dependencies +vi.mock("../../../store/buffers", () => ({ + defaultEditorViewState: { + cursorState: [ + { inSelectionMode: false, position: { lineNumber: 1, column: 1 } }, + ], + }, +})) + +// Mock the Monaco index to avoid editor dependencies +vi.mock("./index", () => ({ + LINE_NUMBER_HARD_LIMIT: 99999, +})) + +// Import after mocks +import { + validateBufferSchema, + sanitizeBuffer, + DEFAULT_METRIC_COLOR, +} from "./importTabs" + +describe("validateBufferSchema", () => { + describe("array validation", () => { + it("should reject non-array data", () => { + expect(validateBufferSchema(null)).toBe("Data must be an array") + expect(validateBufferSchema(undefined)).toBe("Data must be an array") + expect(validateBufferSchema({})).toBe("Data must be an array") + expect(validateBufferSchema("string")).toBe("Data must be an array") + expect(validateBufferSchema(123)).toBe("Data must be an array") + }) + + it("should reject empty array", () => { + expect(validateBufferSchema([])).toBe("File contains no tabs") + }) + }) + + describe("buffer item validation", () => { + it("should reject non-object items", () => { + expect(validateBufferSchema([null])).toBe("Item [0]: must be an object") + expect(validateBufferSchema(["string"])).toBe( + "Item [0]: must be an object", + ) + expect(validateBufferSchema([123])).toBe("Item [0]: must be an object") + }) + + it("should reject missing label", () => { + expect( + validateBufferSchema([ + { value: "SELECT 1", position: 0, editorViewState: {} }, + ]), + ).toBe("Item [0]: label must be a string") + }) + + it("should reject non-string label", () => { + expect( + validateBufferSchema([ + { label: 123, value: "SELECT 1", position: 0, editorViewState: {} }, + ]), + ).toBe("Item [0]: label must be a string") + }) + + it("should reject missing value", () => { + expect( + validateBufferSchema([ + { label: "Tab 1", position: 0, editorViewState: {} }, + ]), + ).toBe("Item [0]: value must be a string") + }) + + it("should reject non-string value", () => { + expect( + validateBufferSchema([ + { label: "Tab 1", value: 123, position: 0, editorViewState: {} }, + ]), + ).toBe("Item [0]: value must be a string") + }) + + it("should reject missing position", () => { + expect( + validateBufferSchema([ + { label: "Tab 1", value: "SELECT 1", editorViewState: {} }, + ]), + ).toBe("Item [0]: position must be a number") + }) + + it("should reject non-number position", () => { + expect( + validateBufferSchema([ + { + label: "Tab 1", + value: "SELECT 1", + position: "0", + editorViewState: {}, + }, + ]), + ).toBe("Item [0]: position must be a number") + }) + + it("should reject tabs without editorViewState or metricsViewState", () => { + expect( + validateBufferSchema([ + { label: "Tab 1", value: "SELECT 1", position: 0 }, + ]), + ).toBe("Item [0]: must have editorViewState or metricsViewState") + }) + + it("should accept tab with editorViewState", () => { + expect( + validateBufferSchema([ + { + label: "Tab 1", + value: "SELECT 1", + position: 0, + editorViewState: {}, + }, + ]), + ).toBe(true) + }) + + it("should accept tab with metricsViewState", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: {}, + }, + ]), + ).toBe(true) + }) + }) + + describe("line count limit", () => { + it("should reject value exceeding line limit", () => { + const hugeValue = Array(100001).fill("line").join("\n") + const result = validateBufferSchema([ + { label: "Tab 1", value: hugeValue, position: 0, editorViewState: {} }, + ]) + expect(result).toContain("exceeds line limit") + }) + + it("should accept value within line limit", () => { + const largeValue = Array(1000).fill("line").join("\n") + expect( + validateBufferSchema([ + { + label: "Tab 1", + value: largeValue, + position: 0, + editorViewState: {}, + }, + ]), + ).toBe(true) + }) + }) + + describe("prototype pollution protection", () => { + it("should reject __proto__ key", () => { + const maliciousObj = Object.create(null) as Record + maliciousObj.label = "Tab 1" + maliciousObj.value = "SELECT 1" + maliciousObj.position = 0 + maliciousObj.editorViewState = {} + maliciousObj.__proto__ = { malicious: true } + + expect(validateBufferSchema([maliciousObj])).toBe( + 'Item [0]: contains forbidden key "__proto__"', + ) + }) + + it("should reject constructor key", () => { + const maliciousObj = Object.create(null) as Record + maliciousObj.label = "Tab 1" + maliciousObj.value = "SELECT 1" + maliciousObj.position = 0 + maliciousObj.editorViewState = {} + // @ts-expect-error - we want to test the constructor key + maliciousObj.constructor = { malicious: true } + + expect(validateBufferSchema([maliciousObj])).toBe( + 'Item [0]: contains forbidden key "constructor"', + ) + }) + + it("should reject prototype key", () => { + const maliciousObj = Object.create(null) as Record + maliciousObj.label = "Tab 1" + maliciousObj.value = "SELECT 1" + maliciousObj.position = 0 + maliciousObj.editorViewState = {} + maliciousObj.prototype = { malicious: true } + + expect(validateBufferSchema([maliciousObj])).toBe( + 'Item [0]: contains forbidden key "prototype"', + ) + }) + }) + + describe("metricsViewState validation", () => { + it("should reject non-object metricsViewState", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: "invalid", + }, + ]), + ).toBe("Item [0]: metricsViewState: must be an object") + }) + + it("should reject non-string dateFrom", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { dateFrom: 123 }, + }, + ]), + ).toBe("Item [0]: metricsViewState.dateFrom: must be a string") + }) + + it("should reject non-string dateTo", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { dateTo: 123 }, + }, + ]), + ).toBe("Item [0]: metricsViewState.dateTo: must be a string") + }) + + it("should reject invalid refreshRate", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { refreshRate: "invalid" }, + }, + ]), + ).toBe('Item [0]: metricsViewState.refreshRate: invalid value "invalid"') + }) + + it("should accept valid refreshRate values", () => { + Object.values(RefreshRate).forEach((rate) => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { refreshRate: rate }, + }, + ]), + ).toBe(true) + }) + }) + + it("should reject invalid sampleBy", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { sampleBy: "2h" }, + }, + ]), + ).toBe('Item [0]: metricsViewState.sampleBy: invalid value "2h"') + }) + + it("should accept valid sampleBy values", () => { + Object.values(SampleBy).forEach((sample) => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { sampleBy: sample }, + }, + ]), + ).toBe(true) + }) + }) + + it("should reject invalid viewMode", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { viewMode: "Table" }, + }, + ]), + ).toBe('Item [0]: metricsViewState.viewMode: invalid value "Table"') + }) + + it("should accept valid viewMode values", () => { + Object.values(MetricViewMode).forEach((mode) => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { viewMode: mode }, + }, + ]), + ).toBe(true) + }) + }) + + it("should reject non-array metrics", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { metrics: "not-array" }, + }, + ]), + ).toBe("Item [0]: metricsViewState.metrics: must be an array") + }) + }) + + describe("metric validation", () => { + const validMetric = { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: DEFAULT_METRIC_COLOR, + removed: false, + } + + it("should reject non-object metric", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { metrics: ["invalid"] }, + }, + ]), + ).toBe("Item [0]: metricsViewState.metrics[0]: must be an object") + }) + + it("should reject non-number tableId", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [{ ...validMetric, tableId: "not-number" }], + }, + }, + ]), + ).toBe("Item [0]: metricsViewState.metrics[0].tableId: must be a number") + }) + + it("should accept metric without tableId", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { metrics: [validMetric] }, + }, + ]), + ).toBe(true) + }) + + it("should accept metric with valid tableId", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { metrics: [{ ...validMetric, tableId: 123 }] }, + }, + ]), + ).toBe(true) + }) + + it("should reject invalid metricType", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [{ ...validMetric, metricType: "INVALID_TYPE" }], + }, + }, + ]), + ).toBe( + 'Item [0]: metricsViewState.metrics[0].metricType: invalid value "INVALID_TYPE"', + ) + }) + + it("should accept all valid metricType values", () => { + Object.values(MetricType).forEach((type) => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [{ ...validMetric, metricType: type }], + }, + }, + ]), + ).toBe(true) + }) + }) + + it("should reject non-number position in metric", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [{ ...validMetric, position: "0" }], + }, + }, + ]), + ).toBe("Item [0]: metricsViewState.metrics[0].position: must be a number") + }) + + it("should reject non-string color", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [{ ...validMetric, color: 123 }], + }, + }, + ]), + ).toBe("Item [0]: metricsViewState.metrics[0].color: must be a string") + }) + + it("should reject non-boolean removed when present", () => { + expect( + validateBufferSchema([ + { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [{ ...validMetric, removed: "false" }], + }, + }, + ]), + ).toBe("Item [0]: metricsViewState.metrics[0].removed: must be a boolean") + }) + }) + + describe("multiple tabs validation", () => { + it("should validate all tabs and report first error", () => { + expect( + validateBufferSchema([ + { + label: "Tab 1", + value: "SELECT 1", + position: 0, + editorViewState: {}, + }, + { label: "Tab 2", value: 123, position: 1, editorViewState: {} }, + ]), + ).toBe("Item [1]: value must be a string") + }) + + it("should accept multiple valid tabs", () => { + expect( + validateBufferSchema([ + { + label: "Tab 1", + value: "SELECT 1", + position: 0, + editorViewState: {}, + }, + { + label: "Tab 2", + value: "SELECT 2", + position: 1, + editorViewState: {}, + }, + { + label: "Metrics", + value: "", + position: 2, + metricsViewState: { viewMode: MetricViewMode.GRID }, + }, + ]), + ).toBe(true) + }) + }) +}) + +describe("sanitizeBuffer", () => { + describe("basic field sanitization", () => { + it("should copy label, value, and position", () => { + const input = { + label: "Test Tab", + value: "SELECT 1", + position: 5, + editorViewState: {}, + } + const result = sanitizeBuffer(input) + expect(result.label).toBe("Test Tab") + expect(result.value).toBe("SELECT 1") + expect(result.position).toBe(5) + }) + + it("should use defaultEditorViewState for SQL tabs", () => { + const input = { + label: "Test Tab", + value: "SELECT 1", + position: 0, + editorViewState: { malicious: "data" }, + } + const result = sanitizeBuffer(input) + expect(result.editorViewState).toBeDefined() + expect( + (result.editorViewState as unknown as Record) + .malicious, + ).toBeUndefined() + }) + }) + + describe("optional field handling", () => { + it("should copy archived when true", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + archived: true, + } + const result = sanitizeBuffer(input) + expect(result.archived).toBe(true) + }) + + it("should not copy archived when false or missing", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + archived: false, + } + const result = sanitizeBuffer(input) + expect(result.archived).toBeUndefined() + }) + + it("should copy archivedAt when number", () => { + const timestamp = Date.now() + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + archivedAt: timestamp, + } + const result = sanitizeBuffer(input) + expect(result.archivedAt).toBe(timestamp) + }) + + it("should not copy archivedAt when not a number", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + archivedAt: "2024-01-01", + } + const result = sanitizeBuffer(input) + expect(result.archivedAt).toBeUndefined() + }) + }) + + describe("internal state fields exclusion", () => { + it("should NOT copy isTemporary", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + isTemporary: true, + } + const result = sanitizeBuffer(input) + expect(result.isTemporary).toBeUndefined() + }) + + it("should NOT copy isPreviewBuffer", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + isPreviewBuffer: true, + } + const result = sanitizeBuffer(input) + expect(result.isPreviewBuffer).toBeUndefined() + }) + + it("should NOT copy previewContent", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + previewContent: { type: "diff", original: "", modified: "" }, + } + const result = sanitizeBuffer(input) + expect(result.previewContent).toBeUndefined() + }) + }) + + describe("unexpected field exclusion", () => { + it("should NOT copy arbitrary extra fields", () => { + const input = { + label: "Test", + value: "", + position: 0, + editorViewState: {}, + maliciousField: "evil", + anotherField: { nested: "data" }, + } + const result = sanitizeBuffer(input) as Record + expect(result.maliciousField).toBeUndefined() + expect(result.anotherField).toBeUndefined() + }) + }) + + describe("metricsViewState sanitization", () => { + it("should sanitize metricsViewState fields", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + dateFrom: "now-1h", + dateTo: "now", + refreshRate: RefreshRate.FIVE_SECONDS, + sampleBy: SampleBy.ONE_MINUTE, + viewMode: MetricViewMode.GRID, + extraField: "should not be copied", + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState).toBeDefined() + expect(result.metricsViewState?.dateFrom).toBe("now-1h") + expect(result.metricsViewState?.dateTo).toBe("now") + expect(result.metricsViewState?.refreshRate).toBe( + RefreshRate.FIVE_SECONDS, + ) + expect(result.metricsViewState?.sampleBy).toBe(SampleBy.ONE_MINUTE) + expect(result.metricsViewState?.viewMode).toBe(MetricViewMode.GRID) + // Extra fields should NOT be copied due to sanitization + expect( + (result.metricsViewState as Record).extraField, + ).toBeUndefined() + }) + }) + + describe("metric color sanitization (CSS injection prevention)", () => { + it("should accept valid hex colors", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: DEFAULT_METRIC_COLOR, + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe( + DEFAULT_METRIC_COLOR, + ) + }) + + it("should accept lowercase hex colors", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: "#aabbcc", + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe("#aabbcc") + }) + + it("should replace invalid color with default", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: "red", + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe( + DEFAULT_METRIC_COLOR, + ) + }) + + it("should replace CSS injection attempt with default", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: "red; background: url(evil.com)", + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe( + DEFAULT_METRIC_COLOR, + ) + }) + + it("should replace rgb() color with default", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: "rgb(255, 0, 0)", + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe( + DEFAULT_METRIC_COLOR, + ) + }) + + it("should replace short hex color with default", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: "#F00", + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe( + DEFAULT_METRIC_COLOR, + ) + }) + + it("should replace hex color with alpha with default", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: "#FF6B6BFF", + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].color).toBe( + DEFAULT_METRIC_COLOR, + ) + }) + }) + + describe("metric field sanitization", () => { + it("should copy tableId when present", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + tableId: 123, + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: DEFAULT_METRIC_COLOR, + removed: false, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].tableId).toBe(123) + }) + + it("should default removed to false when undefined", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: DEFAULT_METRIC_COLOR, + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect(result.metricsViewState?.metrics?.[0].removed).toBe(false) + }) + + it("should NOT copy extra fields from metric objects", () => { + const input = { + label: "Metrics", + value: "", + position: 0, + metricsViewState: { + metrics: [ + { + metricType: MetricType.WAL_ROW_THROUGHPUT, + position: 0, + color: DEFAULT_METRIC_COLOR, + removed: false, + extraField: "malicious", + }, + ], + }, + } + const result = sanitizeBuffer(input) + expect( + (result.metricsViewState?.metrics?.[0] as Record) + .extraField, + ).toBeUndefined() + }) + }) +}) diff --git a/src/scenes/Editor/Monaco/importTabs.ts b/src/scenes/Editor/Monaco/importTabs.ts new file mode 100644 index 000000000..20b7eff15 --- /dev/null +++ b/src/scenes/Editor/Monaco/importTabs.ts @@ -0,0 +1,219 @@ +import { + Buffer, + defaultEditorViewState, + Metric, + MetricsViewState, +} from "../../../store/buffers" +import { + MetricType, + MetricViewMode, + SampleBy, + RefreshRate, +} from "../Metrics/utils" +import { LINE_NUMBER_HARD_LIMIT } from "./index" + +type ValidationResult = true | string + +const METRIC_TYPES = Object.values(MetricType) +const METRIC_VIEW_MODES = Object.values(MetricViewMode) +const SAMPLE_BY_VALUES = Object.values(SampleBy) +const REFRESH_RATE_VALUES = Object.values(RefreshRate) + +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]) + +const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/ +export const DEFAULT_METRIC_COLOR = "#FF6B6B" + +const validateMetric = (item: unknown, index: number): ValidationResult => { + if (typeof item !== "object" || item === null) + return `metrics[${index}]: must be an object` + const obj = item as Record + + if (obj.tableId !== undefined && typeof obj.tableId !== "number") + return `metrics[${index}].tableId: must be a number` + if (!METRIC_TYPES.includes(obj.metricType as MetricType)) + return `metrics[${index}].metricType: invalid value "${obj.metricType}"` + if (typeof obj.position !== "number") + return `metrics[${index}].position: must be a number` + if (typeof obj.color !== "string") + return `metrics[${index}].color: must be a string` + if (obj.removed !== undefined && typeof obj.removed !== "boolean") + return `metrics[${index}].removed: must be a boolean` + + return true +} + +const validateMetricsViewState = (item: unknown): ValidationResult => { + if (typeof item !== "object" || item === null) + return "metricsViewState: must be an object" + const obj = item as Record + + if (obj.dateFrom !== undefined && typeof obj.dateFrom !== "string") + return "metricsViewState.dateFrom: must be a string" + if (obj.dateTo !== undefined && typeof obj.dateTo !== "string") + return "metricsViewState.dateTo: must be a string" + if ( + obj.refreshRate !== undefined && + !REFRESH_RATE_VALUES.includes(obj.refreshRate as RefreshRate) + ) + return `metricsViewState.refreshRate: invalid value ${JSON.stringify(obj.refreshRate)}` + if ( + obj.sampleBy !== undefined && + !SAMPLE_BY_VALUES.includes(obj.sampleBy as SampleBy) + ) + return `metricsViewState.sampleBy: invalid value ${JSON.stringify(obj.sampleBy)}` + if ( + obj.viewMode !== undefined && + !METRIC_VIEW_MODES.includes(obj.viewMode as MetricViewMode) + ) + return `metricsViewState.viewMode: invalid value ${JSON.stringify(obj.viewMode)}` + if (obj.metrics !== undefined) { + if (!Array.isArray(obj.metrics)) + return "metricsViewState.metrics: must be an array" + for (let i = 0; i < obj.metrics.length; i++) { + const result = validateMetric(obj.metrics[i], i) + if (result !== true) return `metricsViewState.${result}` + } + } + + return true +} + +const validateBufferItem = (item: unknown, index: number): ValidationResult => { + if (typeof item !== "object" || item === null) + return `Item [${index}]: must be an object` + const obj = item as Record + + // Check for prototype pollution attempts + for (const key of Object.keys(obj)) { + if (DANGEROUS_KEYS.has(key)) { + return `Item [${index}]: contains forbidden key "${key}"` + } + } + + if (typeof obj.label !== "string") + return `Item [${index}]: label must be a string` + if (typeof obj.value !== "string") + return `Item [${index}]: value must be a string` + const lineCount = obj.value.split("\n").length + if (lineCount > LINE_NUMBER_HARD_LIMIT) + return `Item [${index}]: exceeds line limit (line count > ${LINE_NUMBER_HARD_LIMIT})` + if (typeof obj.position !== "number") + return `Item [${index}]: position must be a number` + + const hasEditorViewState = obj.editorViewState !== undefined + const hasMetricsViewState = obj.metricsViewState !== undefined + if (!hasEditorViewState && !hasMetricsViewState) + return `Item [${index}]: must have editorViewState or metricsViewState` + + if (hasMetricsViewState) { + const result = validateMetricsViewState(obj.metricsViewState) + if (result !== true) return `Item [${index}]: ${result}` + } + + return true +} + +/** + * Sanitize a single metric object - only copy validated fields + */ +const sanitizeMetric = (item: Record): Metric => { + const color = + typeof item.color === "string" && HEX_COLOR_REGEX.test(item.color) + ? item.color + : DEFAULT_METRIC_COLOR + + const metric: Metric = { + metricType: item.metricType as MetricType, + position: item.position as number, + color, + removed: (item.removed as boolean) ?? false, + } + if (item.tableId !== undefined) { + metric.tableId = item.tableId as number + } + return metric +} + +/** + * Sanitize metricsViewState - only copy validated fields + */ +const sanitizeMetricsViewState = ( + item: Record, +): MetricsViewState => { + const state: MetricsViewState = {} + + if (item.dateFrom !== undefined) { + state.dateFrom = item.dateFrom as string + } + if (item.dateTo !== undefined) { + state.dateTo = item.dateTo as string + } + if (item.refreshRate !== undefined) { + state.refreshRate = item.refreshRate as RefreshRate + } + if (item.sampleBy !== undefined) { + state.sampleBy = item.sampleBy as SampleBy + } + if (item.viewMode !== undefined) { + state.viewMode = item.viewMode as MetricViewMode + } + if (item.metrics !== undefined && Array.isArray(item.metrics)) { + state.metrics = item.metrics.map((m) => + sanitizeMetric(m as Record), + ) + } + + return state +} + +/** + * Sanitize a buffer item - only copy validated fields to prevent injection + * of unexpected properties. This is a security measure. + */ +export const sanitizeBuffer = ( + item: Record, +): Omit => { + const hasMetricsViewState = item.metricsViewState !== undefined + + const sanitized: Omit = { + label: item.label as string, + value: item.value as string, + position: item.position as number, + } + + if (hasMetricsViewState) { + // Sanitize metricsViewState - only copy validated fields + sanitized.metricsViewState = sanitizeMetricsViewState( + item.metricsViewState as Record, + ) + } else { + // Always use default editorViewState for security + sanitized.editorViewState = defaultEditorViewState + } + + // Only copy specific optional fields with type checking + if (item.archived === true) { + sanitized.archived = true + } + if (typeof item.archivedAt === "number") { + sanitized.archivedAt = item.archivedAt + } + // Note: isTemporary, isPreviewBuffer, and previewContent are intentionally + // NOT imported as they are internal state fields that should not come from + // external sources + + return sanitized +} + +export const validateBufferSchema = (data: unknown): ValidationResult => { + if (!Array.isArray(data)) return "Data must be an array" + if (data.length === 0) return "File contains no tabs" + + for (let i = 0; i < data.length; i++) { + const result = validateBufferItem(data[i], i) + if (result !== true) return result + } + + return true +} diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index ada3b64e3..91fa96b61 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -247,6 +247,7 @@ const EditorWrapper = styled.div` flex: 1; overflow: hidden; position: relative; + padding: 8px 0 0 0; ` const getDefaultLineNumbersMinChars = (canUseAI: boolean) => { @@ -2068,6 +2069,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { stickyScroll: { enabled: false, }, + scrollbar: { + useShadows: false, + }, selectOnLineNumbers: false, scrollBeyondLastLine: false, tabSize: 2, diff --git a/src/scenes/Editor/Monaco/tabs.tsx b/src/scenes/Editor/Monaco/tabs.tsx index 2a61ae4c4..7b717c456 100644 --- a/src/scenes/Editor/Monaco/tabs.tsx +++ b/src/scenes/Editor/Monaco/tabs.tsx @@ -3,6 +3,14 @@ import styled, { css } from "styled-components" import { Tabs as ReactChromeTabs } from "../../../components/ReactChromeTabs" import { useEditor } from "../../../providers" import { File, History, LineChart, Trash } from "@styled-icons/boxicons-regular" +import { + DotsThreeVerticalIcon, + DownloadSimpleIcon, + UploadSimpleIcon, +} from "@phosphor-icons/react" +import { toast } from "../../../components/Toast" +import { db } from "../../../store/db" +import { validateBufferSchema, sanitizeBuffer } from "./importTabs" import { Box, Button, @@ -50,6 +58,11 @@ const DropdownMenuContent = styled(DropdownMenu.Content)` background: ${({ theme }) => theme.color.backgroundDarker}; ` +const ArchivedBuffersList = styled.div` + max-height: 70vh; + overflow-y: auto; +` + const mapTabIconToType = (buffer: Buffer) => { if (buffer.metricsViewState) { return "assets/icon-chart.svg" @@ -75,6 +88,83 @@ export const Tabs = () => { const [tabsVisible, setTabsVisible] = useState(false) const userLocale = useMemo(fetchUserLocale, []) const [historyOpen, setHistoryOpen] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + + const handleExportTabs = async () => { + const allBuffers = await db.buffers.toArray() + const exportData = allBuffers + .filter((b) => !b.isTemporary && !b.isPreviewBuffer) + .map(({ id: _id, ...rest }) => rest) + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `questdb-tabs-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + } + + const handleImportTabs = () => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".json" + input.style.display = "none" + input.dataset.hook = "editor-tabs-import-input" + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file) return + + try { + const text = await file.text() + const data: unknown = JSON.parse(text) + + const validationResult = validateBufferSchema(data) + if (validationResult !== true) { + toast.error(`Invalid file format: ${validationResult}`) + return + } + + const sanitizedData = (data as Record[]).map( + sanitizeBuffer, + ) + + await db.transaction("rw", db.buffers, async () => { + const maxPosition = Math.max(...buffers.map((b) => b.position), 0) + let activeTabCount = 0 + for (const tab of sanitizedData) { + const isArchived = tab.archived === true + await db.buffers.add({ + ...tab, + position: isArchived ? -1 : maxPosition + activeTabCount + 1, + }) + if (!isArchived) { + activeTabCount++ + } + } + }) + + toast.success( + `Imported ${sanitizedData.length} tab${sanitizedData.length === 1 ? "" : "s"} successfully.`, + ) + } catch (err) { + console.error("Import error:", err) + if (err instanceof SyntaxError) { + toast.error("Failed to parse JSON file.") + } else if (err instanceof Error && err.name === "QuotaExceededError") { + toast.error("Storage quota exceeded. Please free up space.") + } else { + toast.error("Failed to import tabs.") + } + } finally { + input.remove() + } + } + document.body.appendChild(input) + input.click() + } const archivedBuffers = buffers .filter( @@ -144,13 +234,6 @@ export const Tabs = () => { await deleteBuffer(parseInt(id)) } await repositionActiveBuffers(id) - if (archivedBuffers.length >= 10) { - await Promise.all( - archivedBuffers - .slice(9) - .map((buffer) => deleteBuffer(buffer.id as number)), - ) - } } const reorder = async ( @@ -209,7 +292,6 @@ export const Tabs = () => { data-hook={`editor-tabs${tabsDisabled ? "-disabled" : ""}`} > { } {...(historyOpen ? { className: "active" } : {})} > - History + - {archivedBuffers.length === 0 && ( + {archivedBuffers.length === 0 ? (
History is empty
- )} - {archivedBuffers.map((buffer) => ( - { - await updateBuffer(buffer.id as number, { - archived: false, - archivedAt: undefined, - position: buffers.filter( - (b) => !b.archived || b.isTemporary, - ).length, - }) - await setActiveBuffer(buffer) - }} - > - - {buffer.metricsViewState ? ( - - ) : ( - - )} - + {archivedBuffers.map((buffer) => ( + { + await updateBuffer(buffer.id as number, { + archived: false, + archivedAt: undefined, + position: buffers.filter( + (b) => !b.archived || b.isTemporary, + ).length, + }) + await setActiveBuffer(buffer) + }} > - - {buffer.label.substring(0, 30)} - {buffer.label.length > 30 ? "..." : ""} - - {buffer.archivedAt && ( - - {formatDistance( - buffer.archivedAt, - new Date().getTime(), - { - locale: getLocaleFromLanguage(userLocale), - }, + + {buffer.metricsViewState ? ( + + ) : ( + + )} + + + {buffer.label.substring(0, 30)} + {buffer.label.length > 30 ? "..." : ""} + + {buffer.archivedAt && ( + + {formatDistance( + buffer.archivedAt, + new Date().getTime(), + { + locale: getLocaleFromLanguage(userLocale), + }, + )} + {" ago"} + )} - {" ago"} - - )} - - - - ))} + + + + ))} + + )} {archivedBuffers.length > 0 && ( <> @@ -344,6 +432,37 @@ export const Tabs = () => {
+ + + + + + + + + + + + + Import tabs + + + + Export tabs + + + + ) } diff --git a/src/scenes/Editor/index.tsx b/src/scenes/Editor/index.tsx index 8ebf1db80..bf71542cb 100644 --- a/src/scenes/Editor/index.tsx +++ b/src/scenes/Editor/index.tsx @@ -361,6 +361,9 @@ const Editor = ({ lineHeight: 24, folding: false, wordWrap: "on", + stickyScroll: { + enabled: false, + }, }} /> diff --git a/src/store/buffers.ts b/src/store/buffers.ts index ea6155368..0e686d3e6 100644 --- a/src/store/buffers.ts +++ b/src/store/buffers.ts @@ -67,6 +67,10 @@ export type PreviewContentCode = { export type PreviewContent = PreviewContentDiff | PreviewContentCode +/** + * Buffer schema - used for tab persistence. + * Import validation: See validateBufferSchema() + */ export type Buffer = { /** auto incremented number by Dexie */ id?: number @@ -82,7 +86,7 @@ export type Buffer = { previewContent?: PreviewContent } -const defaultEditorViewState: editor.ICodeEditorViewState = { +export const defaultEditorViewState: editor.ICodeEditorViewState = { cursorState: [ { inSelectionMode: false, diff --git a/src/styles/lib/_react-chrome-tabs.scss b/src/styles/lib/_react-chrome-tabs.scss index 16e815678..f9f3606c7 100644 --- a/src/styles/lib/_react-chrome-tabs.scss +++ b/src/styles/lib/_react-chrome-tabs.scss @@ -2,8 +2,8 @@ box-sizing: border-box; position: relative; height: 46px; - padding: 8px 3px 4px 3px; - background: #dee1e6; + padding: 0 3px 0 3px; + background: #282a36; border-radius: 5px 5px 0 0; overflow: hidden; display: flex; @@ -21,16 +21,11 @@ background: inherit; pointer-events: auto; height: 100%; - width: 80px; + flex-shrink: 0; position: relative; z-index: 5; border-radius: 17px; - pointer-events: auto; - transition: padding 0.35s; -} - -.chrome-tabs .new-tab-button-wrapper.overflow-shadow { - padding-left: 4px; + margin-left: 10px; } .chrome-tabs .new-tab-button-wrapper .new-tab-button { @@ -43,30 +38,69 @@ padding: 0; border: none; background: none; - color: #555; + color: #bdbdbd; box-shadow: none; - transition: background 0.35s; cursor: default; } .chrome-tabs .new-tab-button-wrapper .new-tab-button:hover { - background: rgba(150, 150, 150, 0.25); + background: rgba(154, 160, 166, 0.25); } .chrome-tabs .chrome-tabs-content { position: relative; width: auto; height: 100%; - transition: width 0.1s; margin-right: 5px; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.chrome-tabs .chrome-tabs-content::-webkit-scrollbar { + display: none; +} + +/* Overflow shadow indicators */ +.chrome-tabs::before, +.chrome-tabs::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: 10px; + pointer-events: none; + opacity: 0; + z-index: 6; +} + +.chrome-tabs::before { + left: 0; + background: linear-gradient(to right, rgba(20, 20, 20, 0.3), transparent); +} + +.chrome-tabs::after { + right: var(--overflow-shadow-right-offset, 90px); + background: linear-gradient(to left, rgba(20, 20, 20, 0.3), transparent); +} + +.chrome-tabs[data-overflow-left="true"]::before { + opacity: 1; +} + +.chrome-tabs[data-overflow-right="true"]::after { + opacity: 1; } .chrome-tabs .chrome-tab { position: absolute; left: 0; - height: 36px; + height: 46px; width: 240px; border: 0; + bottom: 0; margin: 0; z-index: 1; pointer-events: none; @@ -79,11 +113,11 @@ Helvetica, Roboto, sans-serif; - display: none; position: relative; z-index: 1; - border: none; + border: 1px solid #8be9fd; + padding: 0.2rem 0.4rem; line-height: 20px; height: 20px; outline: none; @@ -96,11 +130,6 @@ box-sizing: content-box; } -.chrome-tabs .chrome-tab-rename { - border: 1px solid #8be9fd; - padding: 0.2rem 0.4rem; -} - .chrome-tabs .chrome-tab-rename:focus { outline: none; } @@ -119,90 +148,20 @@ cursor: default; } -.chrome-tabs .chrome-tab .chrome-tab-dividers { - position: absolute; - top: 7px; - bottom: 7px; - left: var(--tab-content-margin); - right: var(--tab-content-margin); -} - -.chrome-tabs .chrome-tab .chrome-tab-dividers, -.chrome-tabs .chrome-tab .chrome-tab-dividers::before, -.chrome-tabs .chrome-tab .chrome-tab-dividers::after { - pointer-events: none; -} - -.chrome-tabs .chrome-tab .chrome-tab-dividers::before, -.chrome-tabs .chrome-tab .chrome-tab-dividers::after { - content: ""; - display: block; - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background: #a9adb0; - opacity: 1; - transition: opacity 0.2s ease; -} - -.chrome-tabs .chrome-tab .chrome-tab-dividers::before { - left: 0; -} - -.chrome-tabs .chrome-tab .chrome-tab-dividers::after { - right: 0; -} - -.chrome-tabs .chrome-tab:first-child .chrome-tab-dividers::before, -.chrome-tabs .chrome-tab:last-child .chrome-tab-dividers::after { - opacity: 0; -} - -.chrome-tabs .chrome-tab .chrome-tab-background { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; - pointer-events: none; -} - -.chrome-tabs .chrome-tab .chrome-tab-background>svg { - width: 100%; - height: 100%; -} - -.chrome-tabs .chrome-tab .chrome-tab-background>svg .chrome-tab-geometry { - fill: #f4f5f6; -} - .chrome-tabs .chrome-tab[active] { z-index: 5; } -.chrome-tabs .chrome-tab[active] .chrome-tab-background>svg .chrome-tab-geometry { - fill: #fff; -} - -.chrome-tabs .chrome-tab:not([active]) .chrome-tab-background { - transition: opacity 0.2s ease; - opacity: 0; - } @media (hover: hover) { .chrome-tabs .chrome-tab:not([active]):hover { z-index: 2; } - - .chrome-tabs .chrome-tab:not([active]):hover .chrome-tab-background { - opacity: 1; - } } -.chrome-tabs .chrome-tab.chrome-tab-was-just-added { +/* Only animate new tabs after initial setup to prevent flicker on load */ +.chrome-tabs.chrome-tabs-ready .chrome-tab.chrome-tab-was-just-added { top: 10px; - animation: chrome-tab-was-just-added 120ms forwards ease-in-out; + animation: chrome-tab-was-just-added 200ms forwards ease-out; } .chrome-tabs .chrome-tab .chrome-tab-content { @@ -211,18 +170,29 @@ align-items: center; top: 0; bottom: 0; - left: var(--tab-content-margin); - right: var(--tab-content-margin); + width: 100%; padding: 9px 8px; - border-top-left-radius: 8px; - border-top-right-radius: 8px; overflow: hidden; pointer-events: all; + background: transparent; +} + +/* Only enable background transition after initial setup to prevent flicker on load */ +.chrome-tabs.chrome-tabs-ready .chrome-tab .chrome-tab-content { + transition: background 0.2s ease; } -.chrome-tabs .chrome-tab[is-mini] .chrome-tab-content { - padding-left: 2px; - padding-right: 2px; +.chrome-tabs .chrome-tab[active] .chrome-tab-content { + background: #44475a; + box-shadow: + inset 0 -2px 0 0 #00aa3b; +} + +@media (hover: hover) { + .chrome-tabs .chrome-tab:not([active]):hover .chrome-tab-content { + background: #333544; + box-shadow: inset 0 -2px 0 0 #44475a; + } } .chrome-tabs .chrome-tab .chrome-tab-favicon { @@ -239,19 +209,6 @@ margin-left: 4px; } -.chrome-tabs .chrome-tab[is-small] .chrome-tab-favicon { - margin-left: 0; -} - -.chrome-tabs .chrome-tab[is-mini]:not([active]) .chrome-tab-favicon { - margin-left: auto; - margin-right: auto; -} - -.chrome-tabs .chrome-tab[is-mini][active] .chrome-tab-favicon { - display: none; -} - .chrome-tabs .chrome-tab .chrome-tab-title { flex: 1; align-self: center; @@ -260,33 +217,23 @@ margin-left: 4px; height: 20px; line-height: 20px; - color: #5f6368; + color: #9ca1a7; -webkit-mask-image: linear-gradient(90deg, - #000 0%, - #000 calc(100% - 24px), - transparent); + #000 0%, + #000 calc(100% - 24px), + transparent); mask-image: linear-gradient(90deg, - #000 0%, - #000 calc(100% - 24px), - transparent); + #000 0%, + #000 calc(100% - 24px), + transparent); } -.chrome-tabs .chrome-tab[is-small] .chrome-tab-title { - margin-left: 0; -} - -.chrome-tabs .chrome-tab .chrome-tab-favicon+.chrome-tab-title, -.chrome-tabs .chrome-tab[is-small] .chrome-tab-favicon+.chrome-tab-title { +.chrome-tabs .chrome-tab .chrome-tab-favicon+.chrome-tab-title { margin-left: 8px; } -.chrome-tabs .chrome-tab[is-smaller] .chrome-tab-favicon+.chrome-tab-title, -.chrome-tabs .chrome-tab[is-mini] .chrome-tab-title { - display: none; -} - .chrome-tabs .chrome-tab[active] .chrome-tab-title { - color: #45474a; + color: #f1f3f4; } .chrome-tabs .chrome-tab .chrome-tab-drag-handle { @@ -304,65 +251,56 @@ flex-grow: 0; flex-shrink: 0; position: relative; - width: 16px; - height: 16px; + width: 20px; + height: 20px; border-radius: 50%; - background-image: url("data:image/svg+xml;utf8,"); + background-image: url("data:image/svg+xml;utf8,"); background-position: center center; background-repeat: no-repeat; - background-size: 8px 8px; + background-size: 12px 12px; } @media (hover: hover) { .chrome-tabs .chrome-tab .chrome-tab-close:hover { - background-color: #e8eaed; + background-color: #5f6368; + background-image: url("data:image/svg+xml;utf8,"); } .chrome-tabs .chrome-tab .chrome-tab-close:hover:active { - background-color: #dadce0; - } -} - -@media not all and (hover: hover) { - .chrome-tabs .chrome-tab .chrome-tab-close:active { - background-color: #dadce0; + background-color: #80868b; + background-image: url("data:image/svg+xml;utf8,"); } } -@media (hover: hover) { - .chrome-tabs .chrome-tab:not([active]) .chrome-tab-close:not(:hover):not(:active) { - opacity: 0.8; - } -} - -.chrome-tabs .chrome-tab[is-smaller] .chrome-tab-close { - margin-left: auto; -} - -.chrome-tabs .chrome-tab[is-mini]:not([active]) .chrome-tab-close { +.chrome-tabs .chrome-tab .chrome-tab-edit { + align-self: center; + flex-grow: 0; + flex-shrink: 0; + position: relative; + width: 20px; + height: 20px; + border-radius: 50%; + background-image: url("data:image/svg+xml;utf8,"); + background-position: center center; + background-repeat: no-repeat; + background-size: 12px 12px; display: none; + margin-right: 0; } -.chrome-tabs .chrome-tab[is-mini][active] .chrome-tab-close { - margin-left: auto; - margin-right: auto; -} - -@-moz-keyframes chrome-tab-was-just-added { - to { - top: 0; +@media (hover: hover) { + .chrome-tabs .chrome-tab:hover .chrome-tab-edit { + display: block; } -} -@-webkit-keyframes chrome-tab-was-just-added { - to { - top: 0; + .chrome-tabs .chrome-tab .chrome-tab-edit:hover { + background-color: #5f6368; + background-image: url("data:image/svg+xml;utf8,"); } -} -@-o-keyframes chrome-tab-was-just-added { - to { - top: 0; + .chrome-tabs .chrome-tab .chrome-tab-edit:hover:active { + background-color: #80868b; + background-image: url("data:image/svg+xml;utf8,"); } } @@ -377,135 +315,36 @@ transition: transform 120ms ease-in-out; } +.chrome-tabs .chrome-tab.chrome-tab-is-dragging { + z-index: 100; + opacity: 1; + position: fixed !important; +} + .chrome-tabs .chrome-tabs-bottom-bar { position: absolute; bottom: 0; height: 4px; left: 0; width: 100%; - background: #fff; - z-index: 10; -} - -.chrome-tabs-optional-shadow-below-bottom-bar { - position: relative; - height: 1px; - width: 100%; - background-image: url("data:image/svg+xml;utf8,"); - background-size: 1px 1px; - background-repeat: repeat-x; - background-position: 0% 0%; -} - -.chrome-tabs.chrome-tabs-dark-theme { - background: #282a36; -} - -.chrome-tabs.chrome-tabs-dark-theme .new-tab-button-wrapper.overflow-shadow { - padding-left: 4px; -} - -.chrome-tabs.chrome-tabs-dark-theme .new-tab-button-wrapper .new-tab-button { - color: rgba(200, 200, 200, 0.5); -} - -.chrome-tabs.chrome-tabs-dark-theme .new-tab-button-wrapper .new-tab-button:hover { - background: rgba(154, 160, 166, 0.25); -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-dividers::before, -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-dividers::after { - background: #4a4d51; -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-background>svg .chrome-tab-geometry { - fill: #292b2e; -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab[active] .chrome-tab-background>svg .chrome-tab-geometry { - fill: #44475a; -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-title { - color: #9ca1a7; -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab[active] .chrome-tab-title { - color: #f1f3f4; -} - -// Metrics tabs styling -.chrome-tabs.chrome-tabs-dark-theme .metrics-tab.chrome-tab .chrome-tab-background { - opacity: 1; -} -.chrome-tabs.chrome-tabs-dark-theme .metrics-tab.chrome-tab .chrome-tab-background>svg .chrome-tab-geometry { - fill: #226A7B; -} -.chrome-tabs.chrome-tabs-dark-theme .metrics-tab.chrome-tab[active] .chrome-tab-background>svg .chrome-tab-geometry { - fill: #60c3da; -} - -.chrome-tabs.chrome-tabs-dark-theme .metrics-tab.chrome-tab .chrome-tab-title { - color: #8be9fd; -} - -.chrome-tabs.chrome-tabs-dark-theme .metrics-tab.chrome-tab[active] .chrome-tab-title { - color: #24252f; -} - -.chrome-tabs .metrics-tab.chrome-tab[active] .chrome-tab-favicon { - filter: invert(1) saturate(0); -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-close { - background-image: url("data:image/svg+xml;utf8,"); -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-close:hover { - background-color: #5f6368; - background-image: url("data:image/svg+xml;utf8,"); -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tab .chrome-tab-close:hover:active { - background-color: #80868b; - background-image: url("data:image/svg+xml;utf8,"); -} - -.chrome-tabs.chrome-tabs-dark-theme .metrics-tab.chrome-tab[active] .chrome-tab-close { - filter: invert(1) saturate(0); -} - -.chrome-tabs.chrome-tabs-dark-theme .chrome-tabs-bottom-bar { background: #323639; + z-index: 10; } .chrome-tabs .temporary-tab.chrome-tab .chrome-tab-title { - color: #9ca1a7; - font-style: italic; - opacity: 0.7; -} - -.chrome-tabs.chrome-tabs-dark-theme .temporary-tab.chrome-tab .chrome-tab-title { - color: #9ca1a7; font-style: italic; opacity: 0.7; } -.chrome-tabs .temporary-tab.chrome-tab .chrome-tab-background { +.chrome-tabs .temporary-tab.chrome-tab .chrome-tab-content { opacity: 0.7; } -.chrome-tabs.chrome-tabs-dark-theme .temporary-tab.chrome-tab .chrome-tab-favicon { +.chrome-tabs .temporary-tab.chrome-tab .chrome-tab-favicon { opacity: 0.7; } -@media only screen and (-webkit-min-device-pixel-ratio: 2), -only screen and (min--moz-device-pixel-ratio: 2), -only screen and (-o-min-device-pixel-ratio: 2/1), -only screen and (min-device-pixel-ratio: 2), -only screen and (min-resolution: 192dpi), -only screen and (min-resolution: 2dppx) { - .chrome-tabs-optional-shadow-below-bottom-bar { - background-image: url("data:image/svg+xml;utf8,"); - } -} +.chrome-tabs .temporary-tab.chrome-tab .chrome-tab-edit, +.chrome-tabs .preview-tab.chrome-tab .chrome-tab-edit { + display: none !important; +} \ No newline at end of file From 80077c03f5caa9d601b3208d8a165ca3cc7b77af Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 27 Jan 2026 18:56:32 +0300 Subject: [PATCH 2/2] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index e5cd8dc7f..5644a8a13 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit e5cd8dc7f638519f04d857b20cbe27579bca7da2 +Subproject commit 5644a8a13a84075a8e9123631c36f06d684fafdc