feat: add per-connection read-only mode

Connections default to read-only. SQL editor wraps queries in a
read-only transaction so PostgreSQL rejects mutations. Data mutation
commands (update_row, insert_row, delete_rows) are blocked at the
Rust layer. Toolbar toggle with confirmation dialog lets users
switch to read-write. Badges shown in workspace, table viewer, and
status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:36:19 +03:00
parent 9b9d2cee94
commit 72c362dfae
14 changed files with 224 additions and 9 deletions

View File

@@ -68,6 +68,9 @@ pub async fn delete_connection(
pool.close().await;
}
let mut ro = state.read_only.write().await;
ro.remove(&id);
Ok(())
}
@@ -102,6 +105,9 @@ pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> Tu
let mut pools = state.pools.write().await;
pools.insert(config.id.clone(), pool);
let mut ro = state.read_only.write().await;
ro.insert(config.id.clone(), true);
Ok(())
}
@@ -138,5 +144,28 @@ pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<()
if let Some(pool) = pools.remove(&id) {
pool.close().await;
}
let mut ro = state.read_only.write().await;
ro.remove(&id);
Ok(())
}
#[tauri::command]
pub async fn set_read_only(
state: State<'_, AppState>,
connection_id: String,
read_only: bool,
) -> TuskResult<()> {
let mut map = state.read_only.write().await;
map.insert(connection_id, read_only);
Ok(())
}
#[tauri::command]
pub async fn get_read_only(
state: State<'_, AppState>,
connection_id: String,
) -> TuskResult<bool> {
Ok(state.is_read_only(&connection_id).await)
}

View File

@@ -107,6 +107,10 @@ pub async fn update_row(
column: String,
value: Value,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
@@ -148,6 +152,10 @@ pub async fn insert_row(
columns: Vec<String>,
values: Vec<Value>,
) -> TuskResult<()> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
@@ -184,6 +192,10 @@ pub async fn delete_rows(
pk_columns: Vec<String>,
pk_values_list: Vec<Vec<Value>>,
) -> TuskResult<u64> {
if state.is_read_only(&connection_id).await {
return Err(TuskError::ReadOnly);
}
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)

View File

@@ -74,16 +74,32 @@ pub async fn execute_query(
connection_id: String,
sql: String,
) -> TuskResult<QueryResult> {
let read_only = state.is_read_only(&connection_id).await;
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let start = Instant::now();
let rows = sqlx::query(&sql)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let rows = if read_only {
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
sqlx::query("SET TRANSACTION READ ONLY")
.execute(&mut *tx)
.await
.map_err(TuskError::Database)?;
let result = sqlx::query(&sql)
.fetch_all(&mut *tx)
.await
.map_err(TuskError::Database);
tx.rollback().await.map_err(TuskError::Database)?;
result?
} else {
sqlx::query(&sql)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?
};
let execution_time_ms = start.elapsed().as_millis();
let mut columns = Vec::new();

View File

@@ -17,6 +17,9 @@ pub enum TuskError {
#[error("Not connected: {0}")]
NotConnected(String),
#[error("Connection is in read-only mode")]
ReadOnly,
#[error("{0}")]
Custom(String),
}

View File

@@ -19,6 +19,8 @@ pub fn run() {
commands::connections::connect,
commands::connections::switch_database,
commands::connections::disconnect,
commands::connections::set_read_only,
commands::connections::get_read_only,
// queries
commands::queries::execute_query,
// schema

View File

@@ -6,6 +6,7 @@ use tokio::sync::RwLock;
pub struct AppState {
pub pools: RwLock<HashMap<String, PgPool>>,
pub config_path: RwLock<Option<PathBuf>>,
pub read_only: RwLock<HashMap<String, bool>>,
}
impl AppState {
@@ -13,6 +14,12 @@ impl AppState {
Self {
pools: RwLock::new(HashMap::new()),
config_path: RwLock::new(None),
read_only: RwLock::new(HashMap::new()),
}
}
pub async fn is_read_only(&self, id: &str) -> bool {
let map = self.read_only.read().await;
map.get(id).copied().unwrap_or(true)
}
}

View File

@@ -0,0 +1,68 @@
import { useAppStore } from "@/stores/app-store";
import { useToggleReadOnly } from "@/hooks/use-read-only";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Lock, LockOpen } from "lucide-react";
export function ReadOnlyToggle() {
const { activeConnectionId, readOnlyMap, connectedIds } = useAppStore();
const toggleMutation = useToggleReadOnly();
if (!activeConnectionId || !connectedIds.has(activeConnectionId)) {
return null;
}
const isReadOnly = readOnlyMap[activeConnectionId] ?? true;
const handleToggle = () => {
if (isReadOnly) {
const confirmed = window.confirm(
"Switch to Read-Write mode?\n\nThis will allow executing INSERT, UPDATE, DELETE, DROP, and other mutating queries."
);
if (!confirmed) return;
}
toggleMutation.mutate({
connectionId: activeConnectionId,
readOnly: !isReadOnly,
});
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`h-7 gap-1.5 text-xs font-medium ${
isReadOnly
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
}`}
onClick={handleToggle}
disabled={toggleMutation.isPending}
>
{isReadOnly ? (
<>
<Lock className="h-3.5 w-3.5" />
Read-Only
</>
) : (
<>
<LockOpen className="h-3.5 w-3.5" />
Read-Write
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isReadOnly
? "Read-Only mode: mutations are blocked. Click to enable writes."
: "Read-Write mode: all queries are allowed. Click to restrict to read-only."}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -8,7 +8,7 @@ interface Props {
}
export function StatusBar({ rowCount, executionTime }: Props) {
const { activeConnectionId, connectedIds, pgVersion } = useAppStore();
const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore();
const { data: connections } = useConnections();
const activeConn = connections?.find((c) => c.id === activeConnectionId);
@@ -25,6 +25,17 @@ export function StatusBar({ rowCount, executionTime }: Props) {
/>
{activeConn ? activeConn.name : "No connection"}
</span>
{isConnected && activeConnectionId && (
<span
className={`font-semibold ${
(readOnlyMap[activeConnectionId] ?? true)
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
}`}
>
{(readOnlyMap[activeConnectionId] ?? true) ? "RO" : "RW"}
</span>
)}
{pgVersion && (
<span className="hidden sm:inline">{pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")}</span>
)}

View File

@@ -4,6 +4,7 @@ import { Separator } from "@/components/ui/separator";
import { ConnectionSelector } from "@/components/connections/ConnectionSelector";
import { ConnectionList } from "@/components/connections/ConnectionList";
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { Database, Plus } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
@@ -45,6 +46,10 @@ export function Toolbar() {
<Separator orientation="vertical" className="h-5" />
<ReadOnlyToggle />
<Separator orientation="vertical" className="h-5" />
<Button
variant="ghost"
size="sm"

View File

@@ -7,8 +7,9 @@ import { Input } from "@/components/ui/input";
import { updateRow as updateRowApi } from "@/lib/tauri";
import { getTableColumns } from "@/lib/tauri";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAppStore } from "@/stores/app-store";
import { toast } from "sonner";
import { Save, RotateCcw, Filter, Loader2 } from "lucide-react";
import { Save, RotateCcw, Filter, Loader2, Lock } from "lucide-react";
interface Props {
connectionId: string;
@@ -17,6 +18,9 @@ interface Props {
}
export function TableDataView({ connectionId, schema, table }: Props) {
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const isReadOnly = readOnlyMap[connectionId] ?? true;
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sortColumn, _setSortColumn] = useState<string | undefined>();
@@ -57,6 +61,12 @@ export function TableDataView({ connectionId, schema, table }: Props) {
const handleCellDoubleClick = useCallback(
(rowIndex: number, colIndex: number, value: unknown) => {
if (isReadOnly) {
toast.warning("Read-only mode is active", {
description: "Switch to Read-Write mode to edit data.",
});
return;
}
const key = `${rowIndex}:${colIndex}`;
const currentValue = pendingChanges.get(key)?.value ?? value;
const newVal = prompt("Edit value (leave empty and click NULL for null):",
@@ -69,7 +79,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
});
}
},
[pendingChanges]
[pendingChanges, isReadOnly]
);
const handleCommit = async () => {
@@ -121,6 +131,12 @@ export function TableDataView({ connectionId, schema, table }: Props) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-2 py-1">
{isReadOnly && (
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
<Lock className="h-3 w-3" />
Read-Only
</span>
)}
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="WHERE clause (e.g. id > 10)"

View File

@@ -7,8 +7,9 @@ import {
import { SqlEditor } from "@/components/editor/SqlEditor";
import { ResultsPanel } from "@/components/results/ResultsPanel";
import { useQueryExecution } from "@/hooks/use-query-execution";
import { useAppStore } from "@/stores/app-store";
import { Button } from "@/components/ui/button";
import { Play, Loader2 } from "lucide-react";
import { Play, Loader2, Lock } from "lucide-react";
import type { QueryResult } from "@/types";
interface Props {
@@ -24,6 +25,9 @@ export function WorkspacePanel({
onSqlChange,
onResult,
}: Props) {
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const isReadOnly = readOnlyMap[connectionId] ?? true;
const [sqlValue, setSqlValue] = useState(initialSql);
const [result, setResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -79,6 +83,12 @@ export function WorkspacePanel({
<span className="text-[11px] text-muted-foreground">
Ctrl+Enter to execute
</span>
{isReadOnly && (
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
<Lock className="h-3 w-3" />
Read-Only
</span>
)}
</div>
<div className="flex-1 overflow-hidden">
<SqlEditor

View File

@@ -0,0 +1,20 @@
import { useMutation } from "@tanstack/react-query";
import { setReadOnly as setReadOnlyApi } from "@/lib/tauri";
import { useAppStore } from "@/stores/app-store";
export function useToggleReadOnly() {
const setReadOnly = useAppStore((s) => s.setReadOnly);
return useMutation({
mutationFn: ({
connectionId,
readOnly,
}: {
connectionId: string;
readOnly: boolean;
}) => setReadOnlyApi(connectionId, readOnly),
onSuccess: (_data, { connectionId, readOnly }) => {
setReadOnly(connectionId, readOnly);
},
});
}

View File

@@ -31,6 +31,13 @@ export const disconnectDb = (id: string) =>
export const switchDatabase = (config: ConnectionConfig, database: string) =>
invoke<void>("switch_database", { config, database });
// Read-Only
export const setReadOnly = (connectionId: string, readOnly: boolean) =>
invoke<void>("set_read_only", { connectionId, readOnly });
export const getReadOnly = (connectionId: string) =>
invoke<boolean>("get_read_only", { connectionId });
// Queries
export const executeQuery = (connectionId: string, sql: string) =>
invoke<QueryResult>("execute_query", { connectionId, sql });

View File

@@ -6,6 +6,7 @@ interface AppState {
activeConnectionId: string | null;
currentDatabase: string | null;
connectedIds: Set<string>;
readOnlyMap: Record<string, boolean>;
tabs: Tab[];
activeTabId: string | null;
sidebarWidth: number;
@@ -16,6 +17,7 @@ interface AppState {
setCurrentDatabase: (db: string | null) => void;
addConnectedId: (id: string) => void;
removeConnectedId: (id: string) => void;
setReadOnly: (connectionId: string, readOnly: boolean) => void;
setPgVersion: (version: string | null) => void;
addTab: (tab: Tab) => void;
@@ -30,6 +32,7 @@ export const useAppStore = create<AppState>((set) => ({
activeConnectionId: null,
currentDatabase: null,
connectedIds: new Set(),
readOnlyMap: {},
tabs: [],
activeTabId: null,
sidebarWidth: 260,
@@ -41,13 +44,19 @@ export const useAppStore = create<AppState>((set) => ({
addConnectedId: (id) =>
set((state) => ({
connectedIds: new Set([...state.connectedIds, id]),
readOnlyMap: { ...state.readOnlyMap, [id]: true },
})),
removeConnectedId: (id) =>
set((state) => {
const next = new Set(state.connectedIds);
next.delete(id);
return { connectedIds: next };
const { [id]: _, ...restRo } = state.readOnlyMap;
return { connectedIds: next, readOnlyMap: restRo };
}),
setReadOnly: (connectionId, readOnly) =>
set((state) => ({
readOnlyMap: { ...state.readOnlyMap, [connectionId]: readOnly },
})),
setPgVersion: (version) => set({ pgVersion: version }),
addTab: (tab) =>