Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::RwLock;
use tauri::AppHandle;
use tauri::Manager;
use std::sync::RwLock;

use std::collections::HashMap;

Expand Down Expand Up @@ -79,6 +79,8 @@ pub struct AppConfig {
/// Default: `true` — matches the behaviour users expect from most editors.
pub editor_accept_suggestion_on_enter: Option<bool>,
pub run_statement_under_cursor: Option<bool>,
/// Delay destructive-query and production-write confirmations for five seconds. Default: false.
pub safety_confirmation_delay_enabled: Option<bool>,
// ----- SQL Formatter -----
pub formatter_keyword_case: Option<String>,
pub formatter_indent_style: Option<String>,
Expand Down Expand Up @@ -372,6 +374,10 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
if config.run_statement_under_cursor.is_some() {
existing_config.run_statement_under_cursor = config.run_statement_under_cursor;
}
if config.safety_confirmation_delay_enabled.is_some() {
existing_config.safety_confirmation_delay_enabled =
config.safety_confirmation_delay_enabled;
}
if config.ping_interval.is_some() {
let old_interval = existing_config.ping_interval;
existing_config.ping_interval = config.ping_interval;
Expand Down Expand Up @@ -923,6 +929,7 @@ mod tests {
#[test]
fn editor_fields_default_to_none() {
let config = AppConfig::default();
assert!(config.safety_confirmation_delay_enabled.is_none());
assert!(config.editor_theme.is_none());
assert!(config.editor_font_family.is_none());
assert!(config.editor_font_size.is_none());
Expand All @@ -944,6 +951,7 @@ mod tests {
config.editor_show_line_numbers = Some(true);
config.editor_theme = Some("tabularis-light".to_string());
config.editor_accept_suggestion_on_enter = Some(true);
config.safety_confirmation_delay_enabled = Some(true);

let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("editorFontFamily"));
Expand All @@ -954,9 +962,11 @@ mod tests {
assert!(json.contains("editorShowLineNumbers"));
assert!(json.contains("editorTheme"));
assert!(json.contains("editorAcceptSuggestionOnEnter"));
assert!(json.contains("safetyConfirmationDelayEnabled"));
// snake_case must not appear
assert!(!json.contains("editor_font_family"));
assert!(!json.contains("editor_accept_suggestion_on_enter"));
assert!(!json.contains("safety_confirmation_delay_enabled"));
}

#[test]
Expand All @@ -969,7 +979,8 @@ mod tests {
"editorWordWrap": true,
"editorShowLineNumbers": true,
"editorTheme": "tabularis-dark",
"editorAcceptSuggestionOnEnter": true
"editorAcceptSuggestionOnEnter": true,
"safetyConfirmationDelayEnabled": true
}"#;

let config: AppConfig = serde_json::from_str(json).unwrap();
Expand All @@ -980,6 +991,7 @@ mod tests {
assert_eq!(config.editor_show_line_numbers, Some(true));
assert_eq!(config.editor_theme.as_deref(), Some("tabularis-dark"));
assert_eq!(config.editor_accept_suggestion_on_enter, Some(true));
assert_eq!(config.safety_confirmation_delay_enabled, Some(true));
}

#[test]
Expand Down
27 changes: 10 additions & 17 deletions src/components/notebook/NotebookView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,8 @@ import { usesMultiDatabaseLayout } from "../../utils/database";
import { useSettings } from "../../hooks/useSettings";
import { useAlert } from "../../hooks/useAlert";
import { useKeybindings } from "../../hooks/useKeybindings";
import {
useDangerousQueryGuard,
DANGEROUS_QUERY_I18N,
} from "../../hooks/useDangerousQueryGuard";
import { useProductionGuard } from "../../hooks/useProductionGuard";
import { DANGEROUS_QUERY_I18N } from "../../hooks/useDangerousQueryGuard";
import { useQueryGuards } from "../../hooks/useQueryGuards";
import { ConfirmModal } from "../modals/ConfirmModal";
import { NotebookToolbar } from "./NotebookToolbar";
import { NotebookHistoryPanel } from "./NotebookHistoryPanel";
Expand Down Expand Up @@ -106,10 +103,9 @@ export function NotebookView({
const { matchesShortcut } = useKeybindings();
const {
pending: dangerousQuery,
guardQuery: guardDangerousQuery,
guardQuery: guardQueryExecution,
resolve: resolveDangerousQuery,
} = useDangerousQueryGuard();
const guardProductionWrite = useProductionGuard();
} = useQueryGuards(connectionId);

// Local notebook state — loaded from store/disk, NOT from tab
const [notebook, setNotebook] = useState<NotebookState | null>(() =>
Expand Down Expand Up @@ -369,12 +365,8 @@ export function NotebookView({
return;
}

if (!(await guardDangerousQuery(resolvedSql))) {
updateCell(cellId, { isLoading: false });
return;
}

if (!(await guardProductionWrite(connectionId, resolvedSql))) {
const mayRun = await guardQueryExecution(resolvedSql);
if (!mayRun) {
updateCell(cellId, { isLoading: false });
return;
}
Expand Down Expand Up @@ -433,8 +425,7 @@ export function NotebookView({
updateCell,
params,
activeDriver,
guardDangerousQuery,
guardProductionWrite,
guardQueryExecution,
],
);

Expand Down Expand Up @@ -863,7 +854,9 @@ export function NotebookView({
sql={dangerousQuery?.sql}
confirmLabel={t("editor.dangerousQueryConfirm")}
variant="danger"
confirmDelaySeconds={5}
confirmDelaySeconds={
settings.safetyConfirmationDelayEnabled ? 5 : undefined
}
/>
<NotebookToolbar {...toolbarProps} />
{showHistory && (
Expand Down
12 changes: 12 additions & 0 deletions src/components/settings/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,18 @@ export function GeneralTab() {
onChange={(v) => updateSetting("runStatementUnderCursor", v)}
/>
</SettingRow>

<SettingRow
label={t("settings.safetyConfirmationDelay")}
description={t("settings.safetyConfirmationDelayDesc")}
>
<SettingToggle
checked={settings.safetyConfirmationDelayEnabled === true}
onChange={(v) =>
updateSetting("safetyConfirmationDelayEnabled", v)
}
/>
</SettingRow>
</SettingSection>

<SettingSection title={t("settings.connectionHealthCheck")}>
Expand Down
24 changes: 20 additions & 4 deletions src/contexts/ProductionGuardContext.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useCallback, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert, X } from "lucide-react";
import { Modal } from "../components/ui/Modal";
import { SqlPreview } from "../components/ui/SqlPreview";
import { useSettings } from "../hooks/useSettings";
import {
ProductionGuardContext,
snoozedConnectionIds,
Expand All @@ -23,25 +24,37 @@ interface PendingPrompt {

export function ProductionGuardProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { settings } = useSettings();
const [pending, setPending] = useState<PendingPrompt | null>(null);
const [snooze, setSnooze] = useState(false);
const [remaining, setRemaining] = useState(0);

const request = useCallback<GuardRequest>(
(connectionId, connectionName, sql) => {
return new Promise<boolean>((resolve) => {
setSnooze(false);
setRemaining(settings.safetyConfirmationDelayEnabled ? 5 : 0);
setPending({ connectionId, connectionName, sql, resolve });
});
},
[],
[settings.safetyConfirmationDelayEnabled],
);

useEffect(() => {
if (!pending || remaining <= 0) return;
const timeout = setTimeout(() => {
setRemaining((previous) => Math.max(0, previous - 1));
}, 1000);
return () => clearTimeout(timeout);
}, [pending, remaining]);

const finish = (ok: boolean) => {
if (!pending) return;
if (ok && snooze && pending.connectionId) {
snoozedConnectionIds.add(pending.connectionId);
}
pending.resolve(ok);
setRemaining(0);
setPending(null);
};

Expand Down Expand Up @@ -97,9 +110,12 @@ export function ProductionGuardProvider({ children }: { children: ReactNode }) {
</button>
<button
onClick={() => finish(true)}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-sm font-medium transition-colors"
disabled={remaining > 0}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("environment.warnConfirm")}
{remaining > 0
? `${t("environment.warnConfirm")} (${remaining})`
: t("environment.warnConfirm")}
</button>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/contexts/SettingsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface Settings {
editorShowLineNumbers?: boolean;
editorAcceptSuggestionOnEnter?: boolean;
runStatementUnderCursor?: boolean;
/** Delay destructive-query and production-write confirmations for five seconds. Default: false. */
safetyConfirmationDelayEnabled?: boolean;
// SQL Formatter
formatterKeywordCase?: "upper" | "lower" | "preserve";
formatterIndentStyle?: "standard" | "tabularLeft" | "tabularRight";
Expand Down Expand Up @@ -162,6 +164,7 @@ export const DEFAULT_SETTINGS: Settings = {
editorShowLineNumbers: true,
editorAcceptSuggestionOnEnter: true,
runStatementUnderCursor: true,
safetyConfirmationDelayEnabled: false,
formatterKeywordCase: "upper",
formatterIndentStyle: "standard",
formatterTabWidth: 2,
Expand Down
9 changes: 6 additions & 3 deletions src/hooks/useDangerousQueryGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ export const DANGEROUS_QUERY_I18N: Record<
* dialog) for safe statements; for dangerous ones it opens the dialog and
* resolves once the user answers. A second dangerous statement submitted while
* a dialog is already open is declined immediately instead of replacing the
* pending one, so the first caller's promise always settles.
* pending one, so the first caller's promise always settles. Pass `false` to
* disable this guard when a higher-priority safety prompt handles the action.
*/
export function useDangerousQueryGuard() {
export function useDangerousQueryGuard(enabled = true) {
const [pending, setPending] = useState<DangerousQueryInfo | null>(null);
const resolverRef = useRef<((confirmed: boolean) => void) | null>(null);

Expand All @@ -64,6 +65,8 @@ export function useDangerousQueryGuard() {

const guardQuery = useCallback(
(sqlOrQueries: string | string[]): Promise<boolean> => {
if (!enabled) return Promise.resolve(true);

const statements = Array.isArray(sqlOrQueries)
? sqlOrQueries
: [sqlOrQueries];
Expand All @@ -80,7 +83,7 @@ export function useDangerousQueryGuard() {
if (!first) return Promise.resolve(true);
return requestConfirmation({ ...first, count });
},
[requestConfirmation],
[enabled, requestConfirmation],
);

return { pending, isPending: pending !== null, guardQuery, resolve };
Expand Down
52 changes: 52 additions & 0 deletions src/hooks/useQueryGuards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCallback } from "react";
import { useDatabase } from "./useDatabase";
import {
useDangerousQueryGuard,
type DangerousQueryInfo,
} from "./useDangerousQueryGuard";
import { useProductionGuard } from "./useProductionGuard";
import { isProductionConnection } from "../utils/environment";
import { passQueryGuards } from "../utils/queryGuard";

interface QueryGuards {
pending: DangerousQueryInfo | null;
isPending: boolean;
guardQuery: (sqlOrQueries: string | string[]) => Promise<boolean>;
resolve: (confirmed: boolean) => void;
}

/**
* Composes production and dangerous-query confirmations into one ordered gate.
* On production connections, the production prompt replaces the standard
* dangerous-query prompt so a write never displays two confirmations.
*/
export function useQueryGuards(
connectionId: string | null | undefined,
): QueryGuards {
const { connections } = useDatabase();
const isProduction = isProductionConnection(connections, connectionId);
const {
pending,
isPending,
guardQuery: guardDangerousQuery,
resolve,
} = useDangerousQueryGuard(!isProduction);
const guardProductionWrite = useProductionGuard();

const guardQuery = useCallback(
(sqlOrQueries: string | string[]) => {
const productionSql = Array.isArray(sqlOrQueries)
? sqlOrQueries.join(";\n")
: sqlOrQueries;

return passQueryGuards({
guardProduction: () =>
guardProductionWrite(connectionId, productionSql),
guardDangerousQuery: () => guardDangerousQuery(sqlOrQueries),
});
},
[connectionId, guardDangerousQuery, guardProductionWrite],
);

return { pending, isPending, guardQuery, resolve };
}
2 changes: 2 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,8 @@
"queryExecution": "Abfrageausführung",
"runStatementUnderCursor": "Anweisung unter dem Cursor ausführen",
"runStatementUnderCursorDesc": "Wenn ein Skript mehrere Anweisungen enthält, wird die Anweisung unter dem Cursor ausgeführt, statt die Abfrageauswahl anzuzeigen. Markiere Text, um einen bestimmten Bereich auszuführen.",
"safetyConfirmationDelay": "Sicherheitsbestätigungen verzögern",
"safetyConfirmationDelayDesc": "Deaktiviert Bestätigungsschaltflächen für destruktive Abfragen und Produktionsschreibvorgänge fünf Sekunden lang.",
"formatter_title": "SQL-Formatierung",
"formatter_keywordCase": "Schlüsselwort-Schreibweise",
"formatter_keywordCaseDesc": "Groß-/Kleinschreibung von SQL-Schlüsselwörtern (SELECT, FROM, WHERE) nach der Formatierung.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,8 @@
"queryExecution": "Query Execution",
"runStatementUnderCursor": "Run statement under cursor",
"runStatementUnderCursorDesc": "When a script has multiple statements, run the one under the cursor instead of showing the query picker. Select text to run a specific range.",
"safetyConfirmationDelay": "Delay safety confirmations",
"safetyConfirmationDelayDesc": "Disable confirmation buttons for five seconds for destructive queries and production writes.",
"formatter_title": "SQL Formatter",
"formatter_keywordCase": "Keyword Case",
"formatter_keywordCaseDesc": "How SQL keywords (SELECT, FROM, WHERE) are capitalized after formatting.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@
"queryExecution": "Ejecución de consultas",
"runStatementUnderCursor": "Ejecutar la instrucción bajo el cursor",
"runStatementUnderCursorDesc": "Cuando un script tiene varias instrucciones, ejecuta la que está bajo el cursor en lugar de mostrar el selector de consultas. Selecciona texto para ejecutar un rango específico.",
"safetyConfirmationDelay": "Retrasar las confirmaciones de seguridad",
"safetyConfirmationDelayDesc": "Desactiva los botones de confirmación durante cinco segundos para consultas destructivas y escrituras en producción.",
"formatter_title": "Formateador SQL",
"formatter_keywordCase": "Mayúsculas en palabras clave",
"formatter_keywordCaseDesc": "Cómo se capitalizan las palabras clave SQL (SELECT, FROM, WHERE) después del formateo.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@
"queryExecution": "Exécution des requêtes",
"runStatementUnderCursor": "Exécuter l'instruction sous le curseur",
"runStatementUnderCursorDesc": "Lorsqu'un script contient plusieurs instructions, exécute celle sous le curseur au lieu d'afficher le sélecteur de requêtes. Sélectionnez du texte pour exécuter une plage spécifique.",
"safetyConfirmationDelay": "Retarder les confirmations de sécurité",
"safetyConfirmationDelayDesc": "Désactive les boutons de confirmation pendant cinq secondes pour les requêtes destructives et les écritures en production.",
"formatter_title": "Formateur SQL",
"formatter_keywordCase": "Casse des mots-clés",
"formatter_keywordCaseDesc": "Comment les mots-clés SQL (SELECT, FROM, WHERE) sont capitalisés après le formatage.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@
"queryExecution": "Esecuzione query",
"runStatementUnderCursor": "Esegui l'istruzione sotto il cursore",
"runStatementUnderCursorDesc": "Quando uno script contiene più istruzioni, esegue quella sotto il cursore invece di mostrare il selettore di query. Seleziona il testo per eseguire un intervallo specifico.",
"safetyConfirmationDelay": "Ritarda le conferme di sicurezza",
"safetyConfirmationDelayDesc": "Disabilita i pulsanti di conferma per cinque secondi per le query distruttive e le scritture in produzione.",
"formatter_title": "Formattatore SQL",
"formatter_keywordCase": "Maiuscole parole chiave",
"formatter_keywordCaseDesc": "Come vengono capitalizzate le parole chiave SQL (SELECT, FROM, WHERE) dopo la formattazione.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@
"queryExecution": "クエリの実行",
"runStatementUnderCursor": "カーソル位置のステートメントを実行",
"runStatementUnderCursorDesc": "スクリプトに複数のステートメントがある場合、クエリ選択画面を表示せずにカーソル位置のステートメントを実行します。特定の範囲を実行するにはテキストを選択してください。",
"safetyConfirmationDelay": "安全確認を遅延",
"safetyConfirmationDelayDesc": "破壊的なクエリと本番環境への書き込みでは、確認ボタンを5秒間無効にします。",
"formatter_title": "SQLフォーマッター",
"formatter_keywordCase": "キーワードの大文字/小文字",
"formatter_keywordCaseDesc": "フォーマット後のSQLキーワード(SELECT、FROM、WHERE)の大文字/小文字。",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@
"queryExecution": "쿼리 실행",
"runStatementUnderCursor": "커서 위치의 구문 실행",
"runStatementUnderCursorDesc": "스크립트에 여러 구문이 있을 때 쿼리 선택 창을 표시하지 않고 커서 위치의 구문을 실행합니다. 특정 범위를 실행하려면 텍스트를 선택하세요.",
"safetyConfirmationDelay": "안전 확인 지연",
"safetyConfirmationDelayDesc": "파괴적인 쿼리와 프로덕션 쓰기의 확인 버튼을 5초 동안 비활성화합니다.",
"formatter_title": "SQL 포맷터",
"formatter_keywordCase": "키워드 대소문자",
"formatter_keywordCaseDesc": "포맷 후 SQL 키워드(SELECT, FROM, WHERE)의 대소문자 처리.",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@
"queryExecution": "Execução de Consultas",
"runStatementUnderCursor": "Executar instrução sob o cursor",
"runStatementUnderCursorDesc": "Quando um script tem várias instruções, executa a que está sob o cursor em vez de mostrar o seletor de consultas. Selecione um texto para executar um intervalo específico.",
"safetyConfirmationDelay": "Atrasar confirmações de segurança",
"safetyConfirmationDelayDesc": "Desativa os botões de confirmação por cinco segundos para consultas destrutivas e gravações em produção.",
"formatter_title": "Formatador SQL",
"formatter_keywordCase": "Caixa das Palavras-Chave",
"formatter_keywordCaseDesc": "Como as palavras-chave SQL (SELECT, FROM, WHERE) são capitalizadas após a formatação.",
Expand Down
Loading
Loading