+
)
@@ -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