feat(grid): replace toolbar search with Ctrl+F floating overlay

- Remove always-visible search box from toolbar
- Add floating search overlay triggered by Ctrl+F/Cmd+F
- Highlight matching cells in yellow, current match with ring
- Navigate matches with Enter/Shift+Enter
- Show match count (e.g. 1/5) in overlay
- Escape closes overlay and clears search
- Color WHERE label blue and ORDER BY label orange
This commit is contained in:
t8y2 2026-05-13 21:41:26 +08:00
parent 1eb5b89c17
commit e92f1ccdf5
6 changed files with 358 additions and 175 deletions

View File

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::database_capabilities;
use crate::db::agent_driver::AgentDriverClient;
use crate::models::connection::DatabaseType;
@ -166,33 +167,11 @@ impl AgentManager {
}
pub fn db_type_to_agent_key(db_type: &DatabaseType, driver_profile: Option<&str>) -> Option<&'static str> {
match db_type {
DatabaseType::Dameng => Some("dameng"),
DatabaseType::Kingbase => Some("kingbase"),
DatabaseType::Vastbase => Some("vastbase"),
DatabaseType::Goldendb => Some("goldendb"),
DatabaseType::Oracle => match driver_profile {
Some("oracle-10g") => Some("oracle-10g"),
_ => Some("oracle"),
},
DatabaseType::H2 => Some("h2"),
DatabaseType::Snowflake => Some("snowflake"),
DatabaseType::Trino => Some("trino"),
DatabaseType::Hive => Some("hive"),
DatabaseType::Db2 => Some("db2"),
DatabaseType::Informix => Some("informix"),
DatabaseType::Neo4j => Some("neo4j"),
DatabaseType::Cassandra => Some("cassandra"),
DatabaseType::Bigquery => Some("bigquery"),
DatabaseType::Kylin => Some("kylin"),
DatabaseType::Sundb => Some("sundb"),
DatabaseType::Gaussdb => Some("gaussdb"),
_ => None,
}
database_capabilities::agent_key(db_type, driver_profile)
}
pub fn is_agent_type(db_type: &DatabaseType) -> bool {
Self::db_type_to_agent_key(db_type, None).is_some()
database_capabilities::is_agent_type(db_type)
}
pub async fn spawn(

View File

@ -3,6 +3,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::database_capabilities;
use crate::db;
use crate::db::proxy_tunnel::ProxyTunnelManager;
use crate::db::ssh_tunnel::TunnelManager;
@ -61,7 +62,7 @@ pub struct AppState {
pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
let mut db_config = config.clone();
if matches!(db_config.db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks) {
if database_capabilities::is_metadata_connection_scoped(&db_config.db_type) {
db_config.database = None;
}
db_config
@ -122,17 +123,7 @@ impl AppState {
configs.get(connection_id).map(|c| c.db_type.clone())
};
let is_single_conn = matches!(
db_type,
Some(DatabaseType::Sqlite)
| Some(DatabaseType::DuckDb)
| Some(DatabaseType::Oracle)
| Some(DatabaseType::Dameng)
| Some(DatabaseType::Kingbase)
| Some(DatabaseType::Vastbase)
| Some(DatabaseType::Goldendb)
| Some(DatabaseType::Jdbc)
);
let is_single_conn = db_type.as_ref().is_some_and(database_capabilities::is_single_connection_pool);
let pool_key = if is_single_conn {
connection_id.to_string()
} else {
@ -357,15 +348,8 @@ impl AppState {
configs
.get(connection_id)
.map(|c| {
matches!(
c.db_type,
DatabaseType::Oracle
| DatabaseType::Elasticsearch
| DatabaseType::Dameng
| DatabaseType::Kingbase
| DatabaseType::Vastbase
| DatabaseType::Goldendb
)
database_capabilities::is_single_connection_pool(&c.db_type)
|| c.db_type == DatabaseType::Elasticsearch
})
.unwrap_or(false)
};
@ -404,29 +388,15 @@ pub fn redacted_connection_url_for_endpoint(config: &ConnectionConfig, host: &st
}
pub async fn probe_connection_endpoint(config: &ConnectionConfig, host: &str, port: u16) -> Result<(), String> {
match config.db_type {
DatabaseType::Sqlite | DatabaseType::DuckDb => Ok(()),
DatabaseType::MongoDb if config.connection_string.as_deref().is_some_and(|value| !value.is_empty()) => Ok(()),
DatabaseType::Jdbc => Ok(()),
DatabaseType::Dameng
| DatabaseType::Kingbase
| DatabaseType::Vastbase
| DatabaseType::Goldendb
| DatabaseType::Oracle
| DatabaseType::H2
| DatabaseType::Snowflake
| DatabaseType::Trino
| DatabaseType::Hive
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::Neo4j
| DatabaseType::Cassandra
| DatabaseType::Bigquery
| DatabaseType::Kylin
| DatabaseType::Sundb
| DatabaseType::Gaussdb => Ok(()),
_ => db::probe_tcp_endpoint(&format!("{:?}", config.db_type), host, port).await,
if config.db_type == DatabaseType::MongoDb
&& config.connection_string.as_deref().is_some_and(|value| !value.is_empty())
{
return Ok(());
}
if database_capabilities::skips_tcp_probe(&config.db_type) {
return Ok(());
}
db::probe_tcp_endpoint(&format!("{:?}", config.db_type), host, port).await
}
async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &sqlx::mysql::MySqlPool) -> MysqlMode {

View File

@ -1,5 +1,7 @@
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::de::DeserializeOwned;
@ -7,6 +9,7 @@ use serde_json::Value;
const RPC_TIMEOUT_SECS: u64 = 30;
const STARTUP_TIMEOUT_SECS: u64 = 15;
const STDERR_TAIL_LINES: usize = 20;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
@ -14,9 +17,41 @@ pub struct AgentDriverClient {
child: Child,
stdin: Option<BufWriter<ChildStdin>>,
stdout: Option<BufReader<ChildStdout>>,
stderr_tail: Arc<Mutex<StderrTail>>,
next_id: u64,
}
struct StderrTail {
lines: VecDeque<String>,
capacity: usize,
}
impl Default for StderrTail {
fn default() -> Self {
Self::with_capacity(STDERR_TAIL_LINES)
}
}
impl StderrTail {
fn with_capacity(capacity: usize) -> Self {
Self { lines: VecDeque::with_capacity(capacity), capacity }
}
fn push_line(&mut self, line: String) {
if self.capacity == 0 {
return;
}
while self.lines.len() >= self.capacity {
self.lines.pop_front();
}
self.lines.push_back(line.trim_end().to_string());
}
fn snapshot(&self) -> String {
self.lines.iter().filter(|line| !line.trim().is_empty()).cloned().collect::<Vec<_>>().join("\n")
}
}
impl AgentDriverClient {
/// Spawn a Java agent process and wait for it to signal readiness.
///
@ -36,7 +71,7 @@ impl AgentDriverClient {
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
.stderr(Stdio::piped());
#[cfg(windows)]
{
@ -48,12 +83,15 @@ impl AgentDriverClient {
let child_stdin = child.stdin.take().ok_or("Failed to capture agent stdin")?;
let child_stdout = child.stdout.take().ok_or("Failed to capture agent stdout")?;
let child_stderr = child.stderr.take().ok_or("Failed to capture agent stderr")?;
let stdin = BufWriter::new(child_stdin);
let mut stdout = BufReader::new(child_stdout);
let stderr_tail = Arc::new(Mutex::new(StderrTail::default()));
start_stderr_collector(child_stderr, stderr_tail.clone());
// Wait for the agent to signal readiness with {"ready":true}
let ready_stdout = tokio::time::timeout(
let startup_result = tokio::time::timeout(
Duration::from_secs(STARTUP_TIMEOUT_SECS),
tokio::task::spawn_blocking(move || {
let line = read_agent_line(&mut stdout, "startup line")?;
@ -65,11 +103,34 @@ impl AgentDriverClient {
Ok(stdout)
}),
)
.await
.map_err(|_| format!("Agent startup timed out ({STARTUP_TIMEOUT_SECS}s)"))?
.map_err(|e| format!("Agent startup task failed: {e}"))??;
.await;
Ok(Self { child, stdin: Some(stdin), stdout: Some(ready_stdout), next_id: 0 })
let ready_stdout = match startup_result {
Ok(Ok(Ok(stdout))) => stdout,
Ok(Ok(Err(e))) => {
return Err(format_agent_process_error(
&e,
child_exit_status(&mut child),
&stderr_tail_snapshot(&stderr_tail),
));
}
Ok(Err(e)) => {
return Err(format_agent_process_error(
&format!("Agent startup task failed: {e}"),
child_exit_status(&mut child),
&stderr_tail_snapshot(&stderr_tail),
));
}
Err(_) => {
return Err(format_agent_process_error(
&format!("Agent startup timed out ({STARTUP_TIMEOUT_SECS}s)"),
child_exit_status(&mut child),
&stderr_tail_snapshot(&stderr_tail),
));
}
};
Ok(Self { child, stdin: Some(stdin), stdout: Some(ready_stdout), stderr_tail, next_id: 0 })
}
/// Send a JSON-RPC 2.0 request and wait for the response.
@ -91,11 +152,18 @@ impl AgentDriverClient {
serde_json::to_string(&request).map_err(|e| format!("Failed to serialize JSON-RPC request: {e}"))?;
// Write request to stdin
{
let write_result = {
let writer = self.stdin.as_mut().ok_or("Agent stdin not available")?;
writer.write_all(request_line.as_bytes()).map_err(|e| format!("Failed to write to agent stdin: {e}"))?;
writer.write_all(b"\n").map_err(|e| format!("Failed to write newline to agent stdin: {e}"))?;
writer.flush().map_err(|e| format!("Failed to flush agent stdin: {e}"))?;
writer
.write_all(request_line.as_bytes())
.map_err(|e| format!("Failed to write to agent stdin: {e}"))
.and_then(|_| {
writer.write_all(b"\n").map_err(|e| format!("Failed to write newline to agent stdin: {e}"))
})
.and_then(|_| writer.flush().map_err(|e| format!("Failed to flush agent stdin: {e}")))
};
if let Err(e) = write_result {
return Err(self.format_agent_process_error(&e));
}
// Read response from stdout (blocking, with timeout)
@ -135,7 +203,7 @@ impl AgentDriverClient {
.map_err(|e| format!("Agent RPC task failed: {e}"))?;
let _ = self.stdout.insert(returned_reader);
result
result.map_err(|e| self.format_agent_process_error(&e))
}
/// Send a shutdown message to the agent and wait for the process to exit.
@ -177,6 +245,64 @@ fn read_agent_line<R: BufRead>(reader: &mut R, context: &str) -> Result<String,
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn start_stderr_collector(stderr: ChildStderr, stderr_tail: Arc<Mutex<StderrTail>>) {
std::thread::spawn(move || {
let mut reader = BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
log::warn!("[agent:stderr] {}", line.trim_end());
if let Ok(mut tail) = stderr_tail.lock() {
tail.push_line(line.clone());
}
}
Err(err) => {
log::warn!("[agent:stderr] failed to read stderr: {err}");
break;
}
}
}
});
}
fn child_exit_status(child: &mut Child) -> Option<String> {
match child.try_wait() {
Ok(Some(status)) => Some(status.to_string()),
Ok(None) => None,
Err(err) => Some(format!("status unavailable: {err}")),
}
}
fn stderr_tail_snapshot(stderr_tail: &Arc<Mutex<StderrTail>>) -> StderrTail {
let snapshot = stderr_tail.lock().map(|tail| tail.snapshot()).unwrap_or_default();
let mut tail = StderrTail::with_capacity(STDERR_TAIL_LINES);
for line in snapshot.lines() {
tail.push_line(line.to_string());
}
tail
}
fn format_agent_process_error(base: &str, exit_status: Option<String>, stderr_tail: &StderrTail) -> String {
let mut parts = vec![base.to_string()];
if let Some(status) = exit_status {
parts.push(format!("agent process exited with {status}"));
}
let stderr = stderr_tail.snapshot();
if !stderr.is_empty() {
parts.push(format!("recent stderr:\n{stderr}"));
}
parts.join(". ")
}
impl AgentDriverClient {
fn format_agent_process_error(&mut self, base: &str) -> String {
format_agent_process_error(base, child_exit_status(&mut self.child), &stderr_tail_snapshot(&self.stderr_tail))
}
}
impl Drop for AgentDriverClient {
fn drop(&mut self) {
self.kill();
@ -185,7 +311,7 @@ impl Drop for AgentDriverClient {
#[cfg(test)]
mod tests {
use super::read_agent_line;
use super::{format_agent_process_error, read_agent_line, StderrTail};
use std::io::Cursor;
#[test]
@ -197,4 +323,34 @@ mod tests {
assert_eq!(line, format!("{{\"error\":{}}}\n", "\u{fffd}\u{fffd}"));
}
#[test]
fn formats_agent_process_error_with_exit_status_and_stderr_tail() {
let mut stderr_tail = StderrTail::default();
stderr_tail.push_line("java.lang.NoClassDefFoundError: org/apache/hive/jdbc/HiveDriver".to_string());
stderr_tail.push_line("\tat com.dbx.agent.hive.HiveAgent.connect(HiveAgent.kt:21)".to_string());
let message = format_agent_process_error(
"Failed to read response from agent: end of stream",
Some("exit status: 1".to_string()),
&stderr_tail,
);
assert!(message.contains("Failed to read response from agent: end of stream"));
assert!(message.contains("agent process exited with exit status: 1"));
assert!(message.contains("recent stderr:"));
assert!(message.contains("NoClassDefFoundError"));
assert!(message.contains("HiveAgent.connect"));
}
#[test]
fn stderr_tail_keeps_recent_lines_only() {
let mut stderr_tail = StderrTail::with_capacity(3);
stderr_tail.push_line("line 1".to_string());
stderr_tail.push_line("line 2".to_string());
stderr_tail.push_line("line 3".to_string());
stderr_tail.push_line("line 4".to_string());
assert_eq!(stderr_tail.snapshot(), "line 2\nline 3\nline 4");
}
}

View File

@ -2,6 +2,7 @@ pub mod agent_manager;
pub mod ai;
pub mod connection;
pub mod connection_secrets;
pub mod database_capabilities;
pub mod database_export;
pub mod db;
pub mod external;

View File

@ -5,6 +5,7 @@ pub use dbx_core::connection::{
connection_url_for_endpoint, expand_tilde, metadata_connection_config, probe_connection_endpoint,
redacted_connection_url_for_endpoint, AppState, MysqlMode, PoolKind,
};
use dbx_core::database_capabilities;
use dbx_core::db;
use dbx_core::models::connection::{rewrite_jdbc_url_host, ConnectionConfig, DatabaseType};
@ -106,23 +107,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
);
db::elasticsearch_driver::test_connection(&client).await.map(|_| "Connection successful".to_string())
}
DatabaseType::Dameng
| DatabaseType::Kingbase
| DatabaseType::Vastbase
| DatabaseType::Goldendb
| DatabaseType::Oracle
| DatabaseType::H2
| DatabaseType::Snowflake
| DatabaseType::Trino
| DatabaseType::Hive
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::Neo4j
| DatabaseType::Cassandra
| DatabaseType::Bigquery
| DatabaseType::Kylin
| DatabaseType::Sundb
| DatabaseType::Gaussdb => {
db_type if database_capabilities::is_agent_type(&db_type) => {
state
.agent_manager
.call_daemon::<serde_json::Value>(
@ -149,6 +134,7 @@ pub async fn test_connection(state: State<'_, Arc<AppState>>, config: Connection
}
state.test_external_driver("jdbc", &jdbc_config).await
}
db_type => Err(format!("Unsupported database type: {db_type:?}")),
},
};
@ -222,23 +208,7 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
db::elasticsearch_driver::test_connection(&client).await?;
PoolKind::Elasticsearch(client)
}
DatabaseType::Dameng
| DatabaseType::Kingbase
| DatabaseType::Vastbase
| DatabaseType::Goldendb
| DatabaseType::Oracle
| DatabaseType::H2
| DatabaseType::Snowflake
| DatabaseType::Trino
| DatabaseType::Hive
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::Neo4j
| DatabaseType::Cassandra
| DatabaseType::Bigquery
| DatabaseType::Kylin
| DatabaseType::Sundb
| DatabaseType::Gaussdb => {
db_type if database_capabilities::is_agent_type(&db_type) => {
let mut client = state.agent_manager.spawn(&db_config.db_type, db_config.driver_profile.as_deref()).await?;
client
.call::<serde_json::Value>(
@ -255,6 +225,7 @@ pub async fn connect_db(state: State<'_, Arc<AppState>>, config: ConnectionConfi
PoolKind::Agent(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
}
DatabaseType::Jdbc => state.external_driver_pool("jdbc", &db_config).await?,
db_type => return Err(format!("Unsupported database type: {db_type:?}")),
};
state.connections.write().await.insert(id.clone(), pool);

View File

@ -77,7 +77,7 @@ import {
import { formatGridSqlLiteral } from "@/lib/dataGridSql";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { isCancelSearchShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts";
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
@ -224,6 +224,8 @@ const sortColIndex = ref<number | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
const deferredClientSearchText = ref("");
const searchOverlayVisible = ref(false);
const currentMatchIndex = ref(-1);
let _searchTimer: ReturnType<typeof setTimeout> | undefined;
const searchSuggestions = ref<string[]>([]);
@ -467,14 +469,23 @@ function navigateSuggestion(delta: number) {
}
function focusSearch(): boolean {
const input = searchInputRef.value;
if (!input) return false;
input.focus();
input.select();
updateSuggestionPosition();
searchOverlayVisible.value = true;
nextTick(() => {
const input = searchInputRef.value;
if (!input) return;
input.focus();
input.select();
updateSuggestionPosition();
});
return true;
}
function closeSearch() {
searchOverlayVisible.value = false;
searchText.value = "";
searchSuggestions.value = [];
}
const PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
function onSearchKeydown(e: KeyboardEvent) {
@ -536,7 +547,12 @@ function onSearchKeydown(e: KeyboardEvent) {
}
if (isCancelSearchShortcut(e)) {
e.preventDefault();
searchText.value = "";
closeSearch();
return;
}
if (e.key === "Enter") {
e.preventDefault();
navigateMatch(e.shiftKey ? -1 : 1);
}
}
@ -1068,6 +1084,67 @@ const displayItems = computed<RowItem[]>(() => {
return items.filter((item) => matchesRowStatusFilter(item.status, rowStatusFilter.value));
});
interface SearchMatch {
displayRow: number;
col: number;
}
const searchMatches = computed<SearchMatch[]>(() => {
const q = deferredClientSearchText.value;
if (!q) return [];
const items = displayItems.value;
const matches: SearchMatch[] = [];
for (let r = 0; r < items.length; r++) {
const data = items[r].data;
for (let c = 0; c < data.length; c++) {
if (data[c] !== null && String(data[c]).toLowerCase().includes(q)) {
matches.push({ displayRow: r, col: c });
}
}
}
return matches;
});
const searchMatchSet = computed(() => {
const set = new Set<string>();
for (const m of searchMatches.value) {
set.add(`${m.displayRow}:${m.col}`);
}
return set;
});
watch(searchMatches, (matches) => {
currentMatchIndex.value = matches.length > 0 ? 0 : -1;
});
function cellIsSearchMatch(displayRow: number, col: number): boolean {
return searchMatchSet.value.has(`${displayRow}:${col}`);
}
function cellIsCurrentMatch(displayRow: number, col: number): boolean {
const idx = currentMatchIndex.value;
if (idx < 0 || idx >= searchMatches.value.length) return false;
const m = searchMatches.value[idx];
return m.displayRow === displayRow && m.col === col;
}
function navigateMatch(delta: number) {
const total = searchMatches.value.length;
if (total === 0) return;
currentMatchIndex.value = (currentMatchIndex.value + delta + total) % total;
scrollToCurrentMatch();
}
function scrollToCurrentMatch() {
const idx = currentMatchIndex.value;
if (idx < 0 || idx >= searchMatches.value.length) return;
const match = searchMatches.value[idx];
const scrollEl = gridRef.value;
if (!scrollEl) return;
const rowEl = scrollEl.querySelector(`[data-row-index="${match.displayRow}"]`) as HTMLElement | null;
if (rowEl) rowEl.scrollIntoView({ block: "center" });
}
function getRowItem(rowId: number): RowItem | undefined {
return displayItems.value.find((item) => item.id === rowId);
}
@ -1575,6 +1652,11 @@ function cutSelection() {
}
async function onGridKeydown(event: KeyboardEvent) {
if (isFocusSearchShortcut(event)) {
event.preventDefault();
focusSearch();
return;
}
if (eventTargetAllowsNativeClipboard(event)) return;
if (clipboardShortcut(event, "c")) {
if (!hasCellSelection.value) return;
@ -1862,64 +1944,24 @@ defineExpose({
</SelectContent>
</Select>
</div>
<div class="flex-1 flex items-center gap-1 px-2 py-0.5 min-w-0">
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref="searchInputRef"
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="flex-1 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.search')"
@keydown="onSearchKeydown"
@click="updateSuggestionPosition"
/>
<span
ref="measureRef"
class="invisible absolute left-0 top-0 text-xs whitespace-pre pointer-events-none"
aria-hidden="true"
/>
<!-- Suggestion dropdown -->
<div
v-if="searchSuggestions.length > 0"
class="absolute top-full mt-0.5 z-50 min-w-[180px] rounded-md border bg-popover text-popover-foreground shadow-md"
:style="{ left: suggestionLeft + 24 + 'px' }"
>
<div
v-for="(sug, idx) in searchSuggestions"
:key="sug"
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
:class="idx === suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'"
@mousedown.prevent="
suggestionIndex = idx;
acceptSuggestion();
"
@mouseenter="suggestionIndex = idx"
<template v-if="hasLocalColumnFilters">
<div class="flex items-center gap-1 px-2 py-0.5 min-w-0">
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15"
:title="t('grid.clearLocalFilters')"
@click="clearLocalFilter()"
>
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
<span>{{ sug }}</span>
</div>
<Filter class="h-3 w-3" />
{{ localFilterCount }}
<X class="h-3 w-3" />
</button>
</div>
<span v-if="hasActiveFilter" class="text-xs text-muted-foreground shrink-0 px-1">
{{ displayItems.length }}/{{ totalFilterableRowCount }}
</span>
<button
v-if="hasLocalColumnFilters"
type="button"
class="flex shrink-0 items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary hover:bg-primary/15"
:title="t('grid.clearLocalFilters')"
@click="clearLocalFilter()"
>
<Filter class="h-3 w-3" />
{{ localFilterCount }}
<X class="h-3 w-3" />
</button>
</div>
</template>
<template v-if="canUseWhereSearch">
<div class="flex-1 flex items-center gap-1 px-2 py-0.5 border-l min-w-0 relative">
<span class="text-foreground/60 text-xs font-medium select-none shrink-0">WHERE</span>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">WHERE</span>
<input
ref="whereFilterInputRef"
v-model="whereFilterInput"
@ -1970,7 +2012,9 @@ defineExpose({
</button>
</div>
<div class="flex-1 flex items-center gap-1 px-2 py-0.5 border-l border-r min-w-0 relative">
<span class="text-foreground/60 text-xs font-medium select-none shrink-0">ORDER BY</span>
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0"
>ORDER BY</span
>
<input
ref="orderByInputRef"
v-model="orderByInput"
@ -2085,6 +2129,62 @@ defineExpose({
<!-- Content area: table + DDL drawer -->
<div class="flex-1 flex min-h-0 overflow-hidden">
<div class="flex-1 flex flex-col min-w-0 overflow-hidden relative">
<!-- Search overlay (Ctrl+F) -->
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-100"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="searchOverlayVisible"
class="absolute top-1 right-2 z-20 flex items-center gap-1 px-2 py-1 bg-background border rounded-md shadow-md"
>
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<input
ref="searchInputRef"
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="w-48 h-5 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.search')"
@keydown="onSearchKeydown"
@click="updateSuggestionPosition"
/>
<span
ref="measureRef"
class="invisible absolute left-0 top-0 text-xs whitespace-pre pointer-events-none"
aria-hidden="true"
/>
<div
v-if="searchSuggestions.length > 0"
class="absolute top-full right-0 mt-0.5 z-50 min-w-[180px] rounded-md border bg-popover text-popover-foreground shadow-md"
>
<div
v-for="(sug, idx) in searchSuggestions"
:key="sug"
class="flex items-center px-3 py-1.5 text-xs cursor-pointer"
:class="idx === suggestionIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'"
@mousedown.prevent="
suggestionIndex = idx;
acceptSuggestion();
"
@mouseenter="suggestionIndex = idx"
>
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
<span>{{ sug }}</span>
</div>
</div>
<span v-if="searchMatches.length > 0" class="text-xs text-muted-foreground shrink-0">
{{ currentMatchIndex + 1 }}/{{ searchMatches.length }}
</span>
<span v-else-if="deferredClientSearchText" class="text-xs text-muted-foreground shrink-0"> 0 </span>
<button class="text-muted-foreground hover:text-foreground shrink-0" @click="closeSearch">
<X class="w-3.5 h-3.5" />
</button>
</div>
</Transition>
<!-- Sticky header -->
<div
ref="headerRef"
@ -2348,6 +2448,7 @@ defineExpose({
'active-row': isRowActive(index) && !item.isDeleted,
}"
:style="{ height: '26px', width: 'var(--total-w)' }"
:data-row-index="index"
>
<div
class="shrink-0 px-2 py-1 border-r border-border text-center select-none cursor-default hover:bg-accent/50"
@ -2376,6 +2477,11 @@ defineExpose({
'text-muted-foreground italic': isNull(item.data[actualColIdx]),
'bg-yellow-500/10': item.isDirtyCol[actualColIdx],
'cell-selected': cellIsSelected(index, visibleColIdx),
'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(index, actualColIdx),
'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(
index,
actualColIdx,
),
'tabular-nums': typeof item.data[actualColIdx] === 'number',
'cursor-text hover:bg-accent/50': canEditRowItem(item),
'line-through': item.isDeleted,