diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index cb51527263..1fda58e19b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -305,6 +305,7 @@ import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime"; import { useDataGridEditor } from "@/composables/useDataGridEditor"; import { useDataGridSort } from "@/composables/useDataGridSort"; import { useDataGridSearch, type DataGridSearchMatch } from "@/composables/useDataGridSearch"; +import { findDataGridReplacementMatches, replaceDataGridText, type DataGridReplaceScope } from "@/lib/dataGrid/dataGridReplace"; import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle"; import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh"; import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface"; @@ -1012,11 +1013,21 @@ const transposeScrollLeft = ref(0); const transposeViewportWidth = ref(0); const { sortColumn: sortCol, sortColumnIndex: sortColIndex, sortDirection: sortDir, sortMode, setSort, clearSort } = useDataGridSort(); const searchBarRef = ref<{ focus: (select?: boolean) => void } | null>(null); +const replaceOpen = ref(false); +const replacementText = ref(""); +const replaceScope = ref("loaded"); +const replaceCaseSensitive = ref(false); +const replaceColumn = ref(-1); const dataGridSearch = useDataGridSearch({ columns: () => props.result.columns, suggestionColumns: () => props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns, rows: () => displayItems.value, getCellSearchText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : rowLowerTextCache.get(row.data, columnIndex)), + getCellRawSearchText: (row, columnIndex) => (typeof row.data[columnIndex] === "string" ? (row.data[columnIndex] as string) : replaceOpen.value ? "" : String(row.data[columnIndex] ?? "")), + caseSensitive: () => replaceOpen.value && replaceCaseSensitive.value, + literalQuery: replaceOpen, + includeColumnMatches: () => !replaceOpen.value, + isCellSearchable: (row, columnIndex) => !replaceOpen.value || (canReplaceGridCell(row, columnIndex) && replacementCellInScope(row.id, columnIndex)), onNavigate: () => nextTick(scrollToCurrentMatch), // Same key as useDataGridEditor below: table data tabs use the tab id, query // results use resultGridInstanceKey so a re-execute starts with a clean search. @@ -1724,12 +1735,23 @@ function focusSearch(target: Element | null = null): boolean { } function closeSearch() { + replaceOpen.value = false; dataGridSearch.close(); } const PAIRS: Record = { "'": "'", '"': '"', "(": ")" }; function onSearchKeydown(e: KeyboardEvent) { + if (replaceOpen.value) { + if (isCancelSearchShortcut(e)) { + e.preventDefault(); + closeSearch(); + } else if (e.key === "Enter") { + e.preventDefault(); + navigateMatch(e.shiftKey ? -1 : 1); + } + return; + } if (e.key in PAIRS && !e.ctrlKey && !e.metaKey) { const input = e.target as HTMLInputElement; const start = input.selectionStart ?? 0; @@ -3704,6 +3726,7 @@ const { commitEditAndMaybeAutoSave, commitEditFromBlur, applyCellValue, + stageCellReplacements, restoreCellValue, cancelEdit, onEditKeydown, @@ -4367,7 +4390,7 @@ const rollbackToolbarCapability = computed(() = const sortedRows = computed(() => { let indices = localFilteredRows.value; const q = deferredClientSearchText.value; - if (q && dataGridSearchMode.value === "filter") { + if (q && dataGridSearchMode.value === "filter" && !replaceOpen.value) { // Preserve the legacy Ctrl+F behavior when the user chooses row filtering. const rows = props.result.rows; indices = indices.filter((sourceIndex) => { @@ -4771,7 +4794,7 @@ const deleteRowDetails = computed(() => { }); const hasVisibleRows = computed(() => displayRowCount.value > 0); -const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value); +const hasActiveFilter = computed(() => (dataGridSearchMode.value === "filter" && !replaceOpen.value && !!deferredClientSearchText.value) || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value || hasServerColumnFilters.value); const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows"))); const emptyDescription = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRowsDescription") : t("grid.noRowsDescription"))); watch( @@ -8059,6 +8082,84 @@ function selectedRangeTargetsOnlyDraftRow(): boolean { return displayItemAt(range.startRow)?.isDraft === true; } +const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canEditExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value); +const replaceBusy = computed(() => isSaving.value || gridSurfaceBusy.value || props.loading === true); + +function replacementRowItem(rowId: number): RowItem | undefined { + const row = props.result.rows[rowId]; + if (!row || rowId < 0) return undefined; + return { id: rowId, displayIndex: displayRowIndexById(rowId), sourceIndex: rowId, data: rowDataWithChanges(row, rowId), isNew: false, isDeleted: deletedRows.value.has(rowId), isDirtyCol: [], status: dirtyRows.value.has(rowId) ? "edited" : "clean" }; +} + +function canReplaceGridCell(item: RowItem | undefined, col: number): boolean { + const type = allColumnTypes.value[col]; + return ( + replaceAvailable.value && + !!item && + item.sourceIndex !== undefined && + !item.isNew && + !item.isDraft && + typeof item.data[col] === "string" && + canEditCellItem(item, col) && + !isLargeValuePreview(item, col) && + !isBinaryCellColumnType(type) && + !isNumericColumnType(type) && + !isBooleanGridCell(item, col) + ); +} + +function replacementCellInScope(rowId: number, col: number): boolean { + if (replaceScope.value === "loaded") return true; + if (replaceScope.value === "column") return col === replaceColumn.value; + const rowIndex = displayRowIndexById(rowId); + const visibleCol = visibleColumnIndexes.value.indexOf(col); + return rowIndex >= 0 && visibleCol >= 0 && (cellIsSelected(rowIndex, visibleCol) || isRowSelected(rowId) || columnIsSelected(visibleCol)); +} + +const replacementMatches = computed(() => { + if (!replaceOpen.value || !replaceAvailable.value) return []; + const items = new Map(props.result.rows.map((_, rowId) => [rowId, replacementRowItem(rowId)!])); + return findDataGridReplacementMatches({ + rows: [...items.values()].map((item) => ({ rowId: item.id, data: item.data })), + search: deferredClientSearchText.value, + caseSensitive: replaceCaseSensitive.value, + includesCell: replacementCellInScope, + canReplaceCell: (rowId, col) => canReplaceGridCell(items.get(rowId), col), + }); +}); + +const canReplaceCurrent = computed(() => { + if (searchText.value !== deferredClientSearchText.value) return false; + const match = currentSearchMatch.value; + if (!match || match.kind !== "cell") return false; + const item = displayItemAt(match.displayRow); + return !!item && replacementMatches.value.some((candidate) => candidate.rowId === item.id && candidate.col === match.col); +}); + +watch([replaceOpen, replaceScope], () => { + if (!replaceOpen.value || replaceScope.value !== "column") return; + const selectionCol = selectionFocus.value?.colIndex ?? [...selectedColumnIndexes.value][0]; + replaceColumn.value = selectionCol !== null && selectionCol !== undefined ? actualColumnIndex(selectionCol) : (currentSearchMatch.value?.col ?? visibleColumnIndexes.value[0] ?? -1); +}); + +watch( + () => props.result, + () => { + replaceOpen.value = false; + replaceColumn.value = -1; + }, +); + +function replaceGridMatches(currentOnly = false) { + if (!replaceAvailable.value || replaceBusy.value) return; + if (currentOnly && !canReplaceCurrent.value) return; + const current = currentSearchMatch.value; + const currentRowId = current?.kind === "cell" ? displayItemAt(current.displayRow)?.id : undefined; + const matches = replacementMatches.value.filter((match) => !currentOnly || (match.rowId === currentRowId && match.col === current?.col)); + const count = stageCellReplacements(matches.map((match) => ({ ...match, previousValue: match.value, value: replaceDataGridText(match.value, deferredClientSearchText.value, replacementText.value, replaceCaseSensitive.value) }))); + if (count > 0) toast(t("grid.replaceStagedCells", { count }), 5000); +} + function fillSelectionWithValue(value: string | null, options: { preserveEmptyString?: boolean; emptyStringAsNull?: boolean } = {}): boolean { const range = selectedRange.value; let applied = false; @@ -11842,6 +11943,16 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val (hasPendingChanges.value || hasPendingDataEditorDraft.val :values-truncated="(props.result.large_value_cells?.length ?? 0) > 0" @keydown="onSearchKeydown" @navigate="navigateMatch" + @replace-current="replaceGridMatches(true)" + @replace-all="replaceGridMatches()" @close="closeSearch" @accept-suggestion=" suggestionIndex = $event; diff --git a/apps/desktop/src/components/grid/DataGridSearchBar.vue b/apps/desktop/src/components/grid/DataGridSearchBar.vue index a7a912255e..fb47bd2734 100644 --- a/apps/desktop/src/components/grid/DataGridSearchBar.vue +++ b/apps/desktop/src/components/grid/DataGridSearchBar.vue @@ -1,29 +1,45 @@