feat(ui): add readonly mode visual badge to sidebar and tab bar

Add visual indicators so users can distinguish readonly connections from
read-write connections at a glance:

- Sidebar: show a lock icon badge on readonly connection nodes
- Tab bar: show a lock icon with tooltip on tabs with readonly connections
- Add isConnectionReadonly() helper in tabPresentation.ts
- Add readOnlyBadge i18n key for all 6 locales (en, zh-CN, zh-TW, es, it, pt-BR)
This commit is contained in:
Abeautifulsnow 2026-06-10 15:56:37 +08:00 committed by GitHub
parent cb6c09e346
commit 1ca3081c2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 31 additions and 11 deletions

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, ref, watch, nextTick } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check, Lock } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@ -10,7 +10,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { useTabDrag } from "@/composables/useTabDrag";
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
import { hexToRgba } from "@/lib/color";
import type { QueryTab } from "@/types/database";
@ -307,6 +307,12 @@ function activateTab(tabId: string) {
@blur="commitRenameTab(tab)"
/>
<span v-else class="min-w-0 truncate flex-1">{{ tabDisplayTitle(tab, t) }}</span>
<Tooltip v-if="isConnectionReadonly(tab.connectionId)">
<TooltipTrigger as-child>
<Lock class="h-3 w-3 text-muted-foreground shrink-0" />
</TooltipTrigger>
<TooltipContent>{{ t("connection.readOnlyBadge") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="inline-flex rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100" :class="tab.pinned ? 'visible text-primary' : 'invisible group-hover:visible'" @click.stop="queryStore.togglePinnedTab(tab.id)">

View File

@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { ref, computed, nextTick, watch, onBeforeUnmount, inject } from "vue";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useI18n } from "vue-i18n";
@ -47,6 +47,7 @@ import {
Package,
Clipboard,
UsersRound,
Lock,
} from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@ -2236,6 +2237,7 @@ const columnComment = computed(() => (props.node.type === "column" && props.node
const tableComment = computed(() => ((props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection") && props.node.comment ? props.node.comment : null));
const paddingLeft = computed(() => treeItemPaddingLeft(props.depth));
const isConnected = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId));
const isConnectionReadonly = computed(() => props.node.type === "connection" && !!props.node.connectionId && (connectionStore.getConfig(props.node.connectionId)?.read_only ?? false));
const canCloseDatabaseConnection = computed(() => props.node.type === "database" && !!props.node.connectionId && props.node.database != null && connectionStore.connectedIds.has(props.node.connectionId));
const nodeIconClass = computed(() => {
const infoClass = getIconInfo(props.node)?.colorClass;
@ -2976,6 +2978,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{ columnComment }}</span>
<span v-if="tableComment && !settingsStore.editorSettings.sidebarHideTableComments" class="truncate text-muted-foreground/60 text-[10px] max-w-[25%] group-hover:hidden" :title="tableComment">{{ tableComment }}</span>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
<button
v-if="canPin"

View File

@ -1,4 +1,4 @@
export default {
export default {
app: {
name: "DBX",
},
@ -300,6 +300,7 @@ export default {
idleTimeout: "Idle Timeout (seconds)",
readOnly: "Read Only",
readOnlyHint: "Block all write operations (INSERT, UPDATE, DELETE, etc.)",
readOnlyBadge: "Read-only",
proxy: "Proxy",
proxyEnable: "Connect database through proxy",
proxyType: "Proxy Type",

View File

@ -1,4 +1,4 @@
export default {
export default {
app: {
name: "DBX",
},
@ -282,6 +282,7 @@ export default {
idleTimeout: "Tiempo de espera inactivo (segundos)",
readOnly: "Solo lectura",
readOnlyHint: "Bloquear todas las operaciones de escritura (INSERT, UPDATE, DELETE, etc.)",
readOnlyBadge: "Solo lectura",
proxy: "Proxy",
proxyEnable: "Conectar la base de datos mediante proxy",
proxyType: "Tipo de proxy",

View File

@ -1,4 +1,4 @@
export default {
export default {
app: {
name: "DBX",
},
@ -287,6 +287,7 @@ export default {
idleTimeout: "Timeout Inattività (secondi)",
readOnly: "Sola lettura",
readOnlyHint: "Blocca tutte le operazioni di scrittura (INSERT, UPDATE, DELETE, ecc.)",
readOnlyBadge: "Sola lettura",
proxy: "Proxy",
proxyEnable: "Connetti database tramite proxy",
proxyType: "Tipo Proxy",

View File

@ -1,4 +1,4 @@
export default {
export default {
app: {
name: "DBX",
},
@ -287,6 +287,7 @@ export default {
idleTimeout: "Timeout de Inatividade (segundos)",
readOnly: "Somente leitura",
readOnlyHint: "Bloquear todas as operações de escrita (INSERT, UPDATE, DELETE, etc.)",
readOnlyBadge: "Somente leitura",
proxy: "Proxy",
proxyEnable: "Conectar ao banco de dados via proxy",
proxyType: "Tipo de Proxy",

View File

@ -1,4 +1,4 @@
export default {
export default {
app: {
name: "DBX",
},
@ -301,6 +301,7 @@ export default {
idleTimeout: "空闲超时(秒)",
readOnly: "只读模式",
readOnlyHint: "阻止所有写操作INSERT、UPDATE、DELETE 等)",
readOnlyBadge: "只读",
proxy: "代理",
proxyEnable: "通过代理连接数据库",
proxyType: "代理类型",

View File

@ -1,4 +1,4 @@
export default {
export default {
app: {
name: "DBX",
},
@ -287,6 +287,7 @@ export default {
idleTimeout: "閒置逾時(秒)",
readOnly: "唯讀模式",
readOnlyHint: "阻止所有寫入操作INSERT、UPDATE、DELETE 等)",
readOnlyBadge: "唯讀",
proxy: "代理伺服器",
proxyEnable: "藉由代理伺服器連線資料庫",
proxyType: "代理伺服器類型",

View File

@ -1,4 +1,4 @@
import { useConnectionStore } from "@/stores/connectionStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { QueryResult, QueryTab } from "@/types/database";
@ -14,6 +14,11 @@ export function connectionColor(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.color || "";
}
export function isConnectionReadonly(connectionId: string): boolean {
const connectionStore = useConnectionStore();
return connectionStore.getConfig(connectionId)?.read_only ?? false;
}
export function databaseDisplayNameForTab(connectionId: string, database: string, t: Translate): string {
const connectionStore = useConnectionStore();
const connection = connectionStore.getConfig(connectionId);