diff --git a/.github/workflows/test-build-all-platforms.yml b/.github/workflows/test-build-all-platforms.yml index 3fccb8d..29ffe9d 100644 --- a/.github/workflows/test-build-all-platforms.yml +++ b/.github/workflows/test-build-all-platforms.yml @@ -334,7 +334,7 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: test-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${{ github.run_number }} + name: test-build-${{ matrix.build_name }}-run${{ github.run_number }} path: | artifacts/* drivers/** diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 0f8f4fe..a7661c0 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file +d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3aa2f01..c0c5436 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -283,6 +283,7 @@ function App() { let inFlight = false; let lastRatio = Number(window.devicePixelRatio) || 1; let lastFixAt = 0; + let activationTimer: number | null = null; const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); @@ -334,17 +335,55 @@ function App() { void fixWindowScaleIfNeeded(); }; + const scheduleActivationFix = () => { + if (cancelled) return; + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + } + activationTimer = window.setTimeout(() => { + activationTimer = null; + if (cancelled) return; + void fixWindowScaleIfNeeded(); + }, 80); + }; + + const handleWindowFocus = () => { + if (cancelled) return; + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + + const handleVisibilityChange = () => { + if (cancelled) return; + if (document.visibilityState !== 'visible') { + return; + } + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + + const handlePageShow = () => { + if (cancelled) return; + checkDevicePixelRatio(); + scheduleActivationFix(); + }; + const pollTimer = window.setInterval(checkDevicePixelRatio, 900); window.addEventListener('resize', checkDevicePixelRatio); - window.addEventListener('focus', checkDevicePixelRatio); - document.addEventListener('visibilitychange', checkDevicePixelRatio); + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('pageshow', handlePageShow); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { cancelled = true; + if (activationTimer !== null) { + window.clearTimeout(activationTimer); + } window.clearInterval(pollTimer); window.removeEventListener('resize', checkDevicePixelRatio); - window.removeEventListener('focus', checkDevicePixelRatio); - document.removeEventListener('visibilitychange', checkDevicePixelRatio); + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('pageshow', handlePageShow); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index f23caa0..f527778 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -610,6 +610,8 @@ interface DataGridProps { exportSqlWithFilter?: string; onApplyFilter?: (conditions: GridFilterCondition[]) => void; appliedFilterConditions?: FilterCondition[]; + scrollSnapshot?: { top: number; left: number }; + onScrollSnapshotChange?: (snapshot: { top: number; left: number }) => void; } type GridFilterCondition = FilterCondition & { @@ -629,7 +631,8 @@ type ColumnMeta = { const DataGrid: React.FC = ({ data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions + onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, + scrollSnapshot, onScrollSnapshotChange }) => { const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); @@ -778,6 +781,8 @@ const DataGrid: React.FC = ({ const lastTableScrollLeftRef = useRef(0); const lastExternalScrollLeftRef = useRef(0); const pendingScrollToBottomRef = useRef(false); + const lastReportedScrollRef = useRef<{ top: number; left: number }>({ top: 0, left: 0 }); + const didRestoreScrollRef = useRef(false); // 批量编辑模式状态 const [cellEditMode, setCellEditMode] = useState(false); @@ -2795,6 +2800,13 @@ const DataGrid: React.FC = ({ return active ? [active] : []; }, []); + const pickVerticalScrollTarget = useCallback((tableContainer: HTMLElement): HTMLElement | null => { + const virtualHolder = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null; + const rcVirtualHolder = tableContainer.querySelector('.rc-virtual-list-holder') as HTMLElement | null; + const body = tableContainer.querySelector('.ant-table-body') as HTMLElement | null; + return virtualHolder || rcVirtualHolder || body; + }, []); + const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => { const externalScroll = externalHScrollRef.current; if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') { @@ -2877,12 +2889,162 @@ const DataGrid: React.FC = ({ }; }, [horizontalScrollVisible]); + // 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。 + // 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body,导致只能在表头/底部滚动条区域滚动。 + useEffect(() => { + if (viewMode !== 'table' || enableVirtual) return; + const container = tableContainerRef.current; + if (!(container instanceof HTMLElement)) return; + + const resolveHorizontalDelta = (event: WheelEvent) => { + if (Math.abs(event.deltaX) > 0.5) { + return event.deltaX; + } + if (event.shiftKey && Math.abs(event.deltaY) > 0.5) { + return event.deltaY; + } + return 0; + }; + + const isTableDataAreaTarget = (target: EventTarget | null) => { + const element = target instanceof HTMLElement ? target : null; + if (!element) return false; + if (element.closest('.data-grid-external-hscroll')) return false; + return !!element.closest('.ant-table-body, .ant-table-content, .ant-table-cell, .ant-table-row, .ant-table-tbody'); + }; + + const handleContainerHorizontalWheel = (event: WheelEvent) => { + const horizontalDelta = resolveHorizontalDelta(event); + if (!Number.isFinite(horizontalDelta) || Math.abs(horizontalDelta) < 0.5) return; + if (!isTableDataAreaTarget(event.target)) return; + + const targets = pickHorizontalScrollTargets(container); + const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0]; + if (!(activeTarget instanceof HTMLElement)) return; + + const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth); + if (maxScrollLeft <= 0) return; + + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta)); + if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return; + + event.preventDefault(); + event.stopPropagation(); + + horizontalSyncSourceRef.current = 'table'; + activeTarget.scrollLeft = nextScrollLeft; + lastTableScrollLeftRef.current = nextScrollLeft; + + const externalScroll = externalHScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) { + externalScroll.scrollLeft = nextScrollLeft; + lastExternalScrollLeftRef.current = nextScrollLeft; + } + horizontalSyncSourceRef.current = ''; + }; + + container.addEventListener('wheel', handleContainerHorizontalWheel, { passive: false, capture: true }); + return () => { + container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions); + }; + }, [viewMode, enableVirtual, pickHorizontalScrollTargets]); + useEffect(() => { if (viewMode !== 'table') return; const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current)); return () => cancelAnimationFrame(rafId); }, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]); + useEffect(() => { + if (viewMode !== 'table' || !onScrollSnapshotChange) return; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + + let rafId: number | null = null; + let boundVerticalTarget: HTMLElement | null = null; + let boundHorizontalTargets: HTMLElement[] = []; + const externalScroll = externalHScrollRef.current; + const hasStoredScroll = !!scrollSnapshot && (Math.abs(scrollSnapshot.top) > 0.5 || Math.abs(scrollSnapshot.left) > 0.5); + + const emitSnapshot = () => { + if (!didRestoreScrollRef.current && hasStoredScroll) { + return; + } + const verticalTarget = boundVerticalTarget || pickVerticalScrollTarget(tableContainer); + const horizontalTargets = boundHorizontalTargets.length > 0 ? boundHorizontalTargets : pickHorizontalScrollTargets(tableContainer); + const top = verticalTarget ? verticalTarget.scrollTop : 0; + const left = horizontalTargets[0]?.scrollLeft ?? externalScroll?.scrollLeft ?? 0; + if (Math.abs(lastReportedScrollRef.current.top - top) < 1 && Math.abs(lastReportedScrollRef.current.left - left) < 1) { + return; + } + lastReportedScrollRef.current = { top, left }; + onScrollSnapshotChange({ top, left }); + }; + + const bindTargets = () => { + if (boundVerticalTarget) { + boundVerticalTarget.removeEventListener('scroll', emitSnapshot); + } + boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); + externalScroll?.removeEventListener('scroll', emitSnapshot); + + boundVerticalTarget = pickVerticalScrollTarget(tableContainer); + boundHorizontalTargets = pickHorizontalScrollTargets(tableContainer); + + boundVerticalTarget?.addEventListener('scroll', emitSnapshot, { passive: true }); + boundHorizontalTargets.forEach(target => target.addEventListener('scroll', emitSnapshot, { passive: true })); + externalScroll?.addEventListener('scroll', emitSnapshot, { passive: true }); + emitSnapshot(); + }; + + rafId = requestAnimationFrame(bindTargets); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + if (boundVerticalTarget) { + boundVerticalTarget.removeEventListener('scroll', emitSnapshot); + } + boundHorizontalTargets.forEach(target => target.removeEventListener('scroll', emitSnapshot)); + externalScroll?.removeEventListener('scroll', emitSnapshot); + }; + }, [viewMode, mergedDisplayData.length, onScrollSnapshotChange, pickHorizontalScrollTargets, pickVerticalScrollTarget, scrollSnapshot]); + + useEffect(() => { + if (viewMode !== 'table') return; + if (!scrollSnapshot) return; + if (didRestoreScrollRef.current) return; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + if (mergedDisplayData.length === 0) return; + + let rafId = requestAnimationFrame(() => { + const verticalTarget = pickVerticalScrollTarget(tableContainer); + const horizontalTargets = pickHorizontalScrollTargets(tableContainer); + const nextTop = Math.max(0, scrollSnapshot.top); + const nextLeft = Math.max(0, scrollSnapshot.left); + if (verticalTarget && Math.abs(verticalTarget.scrollTop - scrollSnapshot.top) > 1) { + verticalTarget.scrollTop = nextTop; + } + if (Math.abs(nextLeft) > 0.5) { + horizontalTargets.forEach(target => { + if (Math.abs(target.scrollLeft - nextLeft) > 1) { + target.scrollLeft = nextLeft; + } + }); + const externalScroll = externalHScrollRef.current; + if (externalScroll && Math.abs(externalScroll.scrollLeft - nextLeft) > 1) { + externalScroll.scrollLeft = nextLeft; + } + lastTableScrollLeftRef.current = nextLeft; + lastExternalScrollLeftRef.current = nextLeft; + } + lastReportedScrollRef.current = { top: nextTop, left: nextLeft }; + didRestoreScrollRef.current = true; + onScrollSnapshotChange?.({ top: nextTop, left: nextLeft }); + }); + + return () => cancelAnimationFrame(rafId); + }, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]); + // 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动 useEffect(() => { if (viewMode !== 'table' || !enableVirtual) return; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index adc2240..597523e 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -155,6 +155,16 @@ const reverseOrderBySQL = (orderBySQL: string): string => { type ViewerFilterSnapshot = { showFilter: boolean; conditions: FilterCondition[]; + currentPage: number; + pageSize: number; + sortInfo: { columnKey: string, order: string } | null; + scrollTop: number; + scrollLeft: number; +}; + +type ViewerScrollSnapshot = { + top: number; + left: number; }; const viewerFilterSnapshotsByTab = new Map(); @@ -175,15 +185,23 @@ const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefin const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { const cached = viewerFilterSnapshotsByTab.get(String(tabId || '').trim()); if (!cached) { - return { showFilter: false, conditions: [] }; + return { showFilter: false, conditions: [], currentPage: 1, pageSize: 100, sortInfo: null, scrollTop: 0, scrollLeft: 0 }; } return { showFilter: cached.showFilter === true, conditions: normalizeViewerFilterConditions(cached.conditions), + currentPage: Number.isFinite(Number(cached.currentPage)) && Number(cached.currentPage) > 0 ? Number(cached.currentPage) : 1, + pageSize: Number.isFinite(Number(cached.pageSize)) && Number(cached.pageSize) > 0 ? Number(cached.pageSize) : 100, + sortInfo: cached.sortInfo && cached.sortInfo.columnKey && (cached.sortInfo.order === 'ascend' || cached.sortInfo.order === 'descend') + ? { columnKey: String(cached.sortInfo.columnKey), order: cached.sortInfo.order } + : null, + scrollTop: Number.isFinite(Number(cached.scrollTop)) ? Number(cached.scrollTop) : 0, + scrollLeft: Number.isFinite(Number(cached.scrollLeft)) ? Number(cached.scrollLeft) : 0, }; }; const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { + const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]); const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); const [pkColumns, setPkColumns] = useState([]); @@ -204,10 +222,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const latestDbNameRef = useRef(''); const latestCountSqlRef = useRef(''); const latestCountKeyRef = useRef(''); + const scrollSnapshotRef = useRef({ + top: initialViewerSnapshot.scrollTop, + left: initialViewerSnapshot.scrollLeft, + }); + const initialLoadRef = useRef(false); const [pagination, setPagination] = useState({ - current: 1, - pageSize: 100, + current: initialViewerSnapshot.currentPage, + pageSize: initialViewerSnapshot.pageSize, total: 0, totalKnown: false, totalApprox: false, @@ -215,10 +238,10 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { totalCountCancelled: false, }); - const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null); + const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(initialViewerSnapshot.sortInfo); - const [showFilter, setShowFilter] = useState(() => getViewerFilterSnapshot(tab.id).showFilter); - const [filterConditions, setFilterConditions] = useState(() => getViewerFilterSnapshot(tab.id).conditions); + const [showFilter, setShowFilter] = useState(initialViewerSnapshot.showFilter); + const [filterConditions, setFilterConditions] = useState(initialViewerSnapshot.conditions); const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); @@ -229,16 +252,25 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const snapshot = getViewerFilterSnapshot(tab.id); setShowFilter(snapshot.showFilter); setFilterConditions(snapshot.conditions); + setSortInfo(snapshot.sortInfo); + scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; + initialLoadRef.current = false; }, [tab.id]); useEffect(() => { viewerFilterSnapshotsByTab.set(tab.id, { showFilter, conditions: normalizeViewerFilterConditions(filterConditions), + currentPage: pagination.current, + pageSize: pagination.pageSize, + sortInfo, + scrollTop: scrollSnapshotRef.current.top, + scrollLeft: scrollSnapshotRef.current.left, }); - }, [tab.id, showFilter, filterConditions]); + }, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]); useEffect(() => { + const snapshot = getViewerFilterSnapshot(tab.id); setPkColumns([]); pkKeyRef.current = ''; countKeyRef.current = ''; @@ -250,16 +282,29 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { latestDbNameRef.current = ''; latestCountSqlRef.current = ''; latestCountKeyRef.current = ''; + scrollSnapshotRef.current = { top: snapshot.scrollTop, left: snapshot.scrollLeft }; + initialLoadRef.current = false; setPagination(prev => ({ ...prev, - current: 1, + current: snapshot.currentPage, + pageSize: snapshot.pageSize, total: 0, totalKnown: false, totalApprox: false, totalCountLoading: false, totalCountCancelled: false, })); - }, [tab.connectionId, tab.dbName, tab.tableName]); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName]); + + const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => { + scrollSnapshotRef.current = snapshot; + const cached = getViewerFilterSnapshot(tab.id); + viewerFilterSnapshotsByTab.set(tab.id, { + ...cached, + scrollTop: snapshot.top, + scrollLeft: snapshot.left, + }); + }, [tab.id]); const handleDuckDBManualCount = useCallback(async () => { if (latestDbTypeRef.current !== 'duckdb') { @@ -765,8 +810,13 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab.tableName, currentConnConfig?.type, filterConditions, sortInfo, pkColumns]); useEffect(() => { - fetchData(1, pagination.pageSize); - }, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter + if (!initialLoadRef.current) { + initialLoadRef.current = true; + fetchData(pagination.current, pagination.pageSize); + return; + } + fetchData(1, pagination.pageSize); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions]); // Initial load and re-load on sort/filter return (
@@ -792,6 +842,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { readOnly={forceReadOnly} sortInfoExternal={sortInfo} exportSqlWithFilter={exportSqlWithFilter || undefined} + scrollSnapshot={scrollSnapshotRef.current} + onScrollSnapshotChange={handleTableScrollSnapshotChange} />
); diff --git a/internal/db/query_value.go b/internal/db/query_value.go index fa28bd7..4850d1f 100644 --- a/internal/db/query_value.go +++ b/internal/db/query_value.go @@ -31,12 +31,44 @@ func normalizeQueryValue(v interface{}) interface{} { } func normalizeQueryValueWithDBType(v interface{}, databaseTypeName string) interface{} { + if tm, ok := v.(time.Time); ok { + return normalizeTemporalValueForDisplay(tm, databaseTypeName) + } if b, ok := v.([]byte); ok { return bytesToDisplayValue(b, databaseTypeName) } return normalizeCompositeQueryValue(v) } +func normalizeTemporalValueForDisplay(value time.Time, databaseTypeName string) interface{} { + if value.IsZero() { + if zeroValue, ok := zeroTemporalDisplayValue(databaseTypeName); ok { + return zeroValue + } + } + return value.Format(time.RFC3339Nano) +} + +func zeroTemporalDisplayValue(databaseTypeName string) (string, bool) { + typeName := strings.ToUpper(strings.TrimSpace(databaseTypeName)) + if typeName == "" { + return "0000-00-00 00:00:00", true + } + + switch { + case strings.Contains(typeName, "TIMESTAMP") || strings.Contains(typeName, "DATETIME"): + return "0000-00-00 00:00:00", true + case typeName == "DATE" || typeName == "NEWDATE": + return "0000-00-00", true + case strings.Contains(typeName, "TIME"): + return "00:00:00", true + case strings.Contains(typeName, "YEAR"): + return "0000", true + default: + return "", false + } +} + func normalizeCompositeQueryValue(v interface{}) interface{} { if v == nil { return nil @@ -91,7 +123,7 @@ func normalizeCompositeQueryValue(v interface{}) interface{} { // 部分驱动(如 Kingbase)会返回复杂结构体值,直接透传会导致前端渲染和比较开销激增。 // 统一降级为可读字符串,避免对象深层序列化触发 UI 卡顿。 if tm, ok := v.(time.Time); ok { - return tm.Format(time.RFC3339Nano) + return normalizeTemporalValueForDisplay(tm, "") } if stringer, ok := v.(fmt.Stringer); ok { return stringer.String() diff --git a/internal/db/query_value_test.go b/internal/db/query_value_test.go index 285344e..a66faf4 100644 --- a/internal/db/query_value_test.go +++ b/internal/db/query_value_test.go @@ -195,3 +195,33 @@ func TestNormalizeQueryValueWithDBType_TimeStructToRFC3339(t *testing.T) { t.Fatalf("time.Time 规整值异常,实际=%s", text) } } + +func TestNormalizeQueryValueWithDBType_ZeroTemporalValues(t *testing.T) { + zero := time.Time{} + cases := []struct { + name string + dbType string + wantText string + }{ + {name: "date", dbType: "DATE", wantText: "0000-00-00"}, + {name: "newdate", dbType: "NEWDATE", wantText: "0000-00-00"}, + {name: "datetime", dbType: "DATETIME", wantText: "0000-00-00 00:00:00"}, + {name: "timestamp", dbType: "TIMESTAMP", wantText: "0000-00-00 00:00:00"}, + {name: "time", dbType: "TIME", wantText: "00:00:00"}, + {name: "year", dbType: "YEAR", wantText: "0000"}, + {name: "unknown", dbType: "", wantText: "0000-00-00 00:00:00"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeQueryValueWithDBType(zero, tc.dbType) + text, ok := got.(string) + if !ok { + t.Fatalf("期望 string,实际=%v(%T)", got, got) + } + if text != tc.wantText { + t.Fatalf("dbType=%s 期望=%s,实际=%s", tc.dbType, tc.wantText, text) + } + }) + } +}