Merge pull request #64 from yavon007/main
feat: SSH key passphrase, file picker, and DataGrid search autocomplete
This commit is contained in:
commit
5bd346f505
|
|
@ -171,6 +171,7 @@ impl AppState {
|
|||
&config.ssh_user,
|
||||
&config.ssh_password,
|
||||
&config.ssh_key_path,
|
||||
&config.ssh_key_passphrase,
|
||||
&config.host,
|
||||
config.port,
|
||||
config.ssh_expose_lan,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use tauri::{AppHandle, Manager};
|
|||
|
||||
pub(super) const MAIN_PASSWORD_KEY: &str = "password";
|
||||
pub(super) const SSH_PASSWORD_KEY: &str = "ssh_password";
|
||||
pub(super) const SSH_KEY_PASSPHRASE_KEY: &str = "ssh_key_passphrase";
|
||||
pub(super) const CONNECTION_STRING_KEY: &str = "connection_string";
|
||||
|
||||
const KEYRING_SERVICE: &str = "dev.dbx.connections";
|
||||
|
|
@ -103,6 +104,7 @@ pub(super) fn save_connections_to_file(
|
|||
for config in configs {
|
||||
persist_secret(store, &config.id, MAIN_PASSWORD_KEY, &config.password)?;
|
||||
persist_secret(store, &config.id, SSH_PASSWORD_KEY, &config.ssh_password)?;
|
||||
persist_secret(store, &config.id, SSH_KEY_PASSPHRASE_KEY, &config.ssh_key_passphrase)?;
|
||||
persist_optional_secret(
|
||||
store,
|
||||
&config.id,
|
||||
|
|
@ -143,6 +145,15 @@ pub(super) fn load_connections_from_file(
|
|||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
if config.ssh_key_passphrase.is_empty() {
|
||||
if let Some(secret) = store.get_secret(&config.id, SSH_KEY_PASSPHRASE_KEY)? {
|
||||
config.ssh_key_passphrase = secret;
|
||||
}
|
||||
} else {
|
||||
store.set_secret(&config.id, SSH_KEY_PASSPHRASE_KEY, &config.ssh_key_passphrase)?;
|
||||
needs_rewrite = true;
|
||||
}
|
||||
|
||||
match config
|
||||
.connection_string
|
||||
.as_deref()
|
||||
|
|
@ -187,6 +198,7 @@ fn delete_removed_connection_secrets(
|
|||
}
|
||||
store.delete_secret(&config.id, MAIN_PASSWORD_KEY)?;
|
||||
store.delete_secret(&config.id, SSH_PASSWORD_KEY)?;
|
||||
store.delete_secret(&config.id, SSH_KEY_PASSPHRASE_KEY)?;
|
||||
store.delete_secret(&config.id, CONNECTION_STRING_KEY)?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -235,6 +247,7 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec<ConnectionConfig> {
|
|||
.map(|mut config| {
|
||||
config.password.clear();
|
||||
config.ssh_password.clear();
|
||||
config.ssh_key_passphrase.clear();
|
||||
config.connection_string = None;
|
||||
config
|
||||
})
|
||||
|
|
@ -352,6 +365,7 @@ mod tests {
|
|||
ssh_user: String::new(),
|
||||
ssh_password: ssh_password.to_string(),
|
||||
ssh_key_path: String::new(),
|
||||
ssh_key_passphrase: String::new(),
|
||||
ssh_expose_lan: false,
|
||||
ssl: false,
|
||||
connection_string: None,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ async fn connect_and_authenticate(
|
|||
ssh_user: &str,
|
||||
ssh_password: &str,
|
||||
ssh_key_path: &str,
|
||||
ssh_key_passphrase: &str,
|
||||
) -> Result<Handle<SshClient>, String> {
|
||||
let config = Arc::new(Config {
|
||||
nodelay: true,
|
||||
|
|
@ -39,7 +40,8 @@ async fn connect_and_authenticate(
|
|||
.map_err(|e| format!("SSH connection failed: {e}"))?;
|
||||
|
||||
if !ssh_key_path.is_empty() {
|
||||
let key_pair = load_secret_key(ssh_key_path, None)
|
||||
let passphrase = if ssh_key_passphrase.is_empty() { None } else { Some(ssh_key_passphrase) };
|
||||
let key_pair = load_secret_key(ssh_key_path, passphrase)
|
||||
.map_err(|e| format!("Failed to load SSH key: {e}"))?;
|
||||
let auth_res = session
|
||||
.authenticate_publickey(
|
||||
|
|
@ -158,6 +160,7 @@ impl TunnelManager {
|
|||
ssh_user: &str,
|
||||
ssh_password: &str,
|
||||
ssh_key_path: &str,
|
||||
ssh_key_passphrase: &str,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
expose_to_lan: bool,
|
||||
|
|
@ -165,7 +168,7 @@ impl TunnelManager {
|
|||
let local_port = portpicker::pick_unused_port().ok_or("No available port")?;
|
||||
|
||||
let session =
|
||||
connect_and_authenticate(ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path)
|
||||
connect_and_authenticate(ssh_host, ssh_port, ssh_user, ssh_password, ssh_key_path, ssh_key_passphrase)
|
||||
.await?;
|
||||
|
||||
let bind_addr = if expose_to_lan { "0.0.0.0" } else { "127.0.0.1" };
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub struct ConnectionConfig {
|
|||
#[serde(default)]
|
||||
pub ssh_key_path: String,
|
||||
#[serde(default)]
|
||||
pub ssh_key_passphrase: String,
|
||||
#[serde(default)]
|
||||
pub ssh_expose_lan: bool,
|
||||
#[serde(default)]
|
||||
pub ssl: bool,
|
||||
|
|
|
|||
|
|
@ -12,10 +12,13 @@ import {
|
|||
DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { FolderOpen } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
|
@ -54,6 +57,7 @@ const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
|
|||
ssh_user: "",
|
||||
ssh_password: "",
|
||||
ssh_key_path: "",
|
||||
ssh_key_passphrase: "",
|
||||
ssh_expose_lan: false,
|
||||
ssl: false,
|
||||
connection_string: undefined,
|
||||
|
|
@ -156,6 +160,7 @@ watch(() => props.editConfig, (config) => {
|
|||
ssh_user: config.ssh_user || "",
|
||||
ssh_password: config.ssh_password || "",
|
||||
ssh_key_path: config.ssh_key_path || "",
|
||||
ssh_key_passphrase: config.ssh_key_passphrase || "",
|
||||
ssh_expose_lan: config.ssh_expose_lan || false,
|
||||
ssl: config.ssl || false,
|
||||
connection_string: config.connection_string,
|
||||
|
|
@ -298,6 +303,16 @@ const dialogTitle = ref("");
|
|||
watch([() => editingId.value, () => open.value], () => {
|
||||
dialogTitle.value = editingId.value ? t('connection.editTitle') : t('connection.title');
|
||||
});
|
||||
|
||||
async function browseSshKeyPath() {
|
||||
const selected = await openFileDialog({
|
||||
title: "Select SSH Private Key",
|
||||
multiple: false,
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
form.value.ssh_key_path = selected;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -307,7 +322,7 @@ watch([() => editingId.value, () => open.value], () => {
|
|||
<DialogTitle>{{ editingId ? t('connection.editTitle') : t('connection.title') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-4 py-4 pr-2 max-h-[65vh] overflow-y-auto">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.name') }}</Label>
|
||||
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
|
||||
|
|
@ -514,7 +529,21 @@ watch([() => editingId.value, () => open.value], () => {
|
|||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
|
||||
<Input v-model="form.ssh_key_path" class="col-span-3" placeholder="~/.ssh/id_rsa" />
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="form.ssh_key_path" class="flex-1" placeholder="~/.ssh/id_rsa" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseSshKeyPath">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('connection.sshKeyPathBrowse') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshKeyPassphrase') }}</Label>
|
||||
<Input v-model="form.ssh_key_passphrase" type="password" class="col-span-3" :placeholder="t('connection.sshKeyPassphrasePlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
|
|
|
|||
|
|
@ -142,6 +142,155 @@ const showTranspose = ref(false);
|
|||
const sortCol = ref<string | null>(null);
|
||||
const sortDir = ref<"asc" | "desc">("asc");
|
||||
const searchText = ref("");
|
||||
const searchSuggestions = ref<string[]>([]);
|
||||
const suggestionIndex = ref(-1);
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
const measureRef = ref<HTMLSpanElement>();
|
||||
const suggestionLeft = ref(0);
|
||||
|
||||
function updateSuggestionPosition() {
|
||||
nextTick(() => {
|
||||
const input = searchInputRef.value;
|
||||
const measure = measureRef.value;
|
||||
if (!input || !measure) return;
|
||||
const cursorPos = input.selectionStart ?? 0;
|
||||
measure.textContent = searchText.value.slice(0, cursorPos);
|
||||
suggestionLeft.value = measure.getBoundingClientRect().width;
|
||||
});
|
||||
}
|
||||
|
||||
watch(searchText, (val) => {
|
||||
searchSuggestions.value = [];
|
||||
if (!canUseWhereSearch.value || !props.tableMeta?.columns?.length) return;
|
||||
|
||||
const trimmed = val.trimStart();
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (trimmed.length > 0 && lower !== "where" && "where".startsWith(lower)) {
|
||||
searchSuggestions.value = ["WHERE "];
|
||||
suggestionIndex.value = 0;
|
||||
updateSuggestionPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
const m = val.match(/^\s*where\s+(.+)$/i);
|
||||
if (m) {
|
||||
const lastToken = m[1].split(/[\s,()><=!]+/).pop() || "";
|
||||
if (lastToken.length > 0) {
|
||||
const tl = lastToken.toLowerCase();
|
||||
searchSuggestions.value = props.tableMeta.columns
|
||||
.map((c) => c.name)
|
||||
.filter((n) => n.toLowerCase().startsWith(tl) && n.toLowerCase() !== tl)
|
||||
.slice(0, 8);
|
||||
suggestionIndex.value = 0;
|
||||
updateSuggestionPosition();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function acceptSuggestion() {
|
||||
const idx = suggestionIndex.value;
|
||||
if (idx < 0 || idx >= searchSuggestions.value.length) return;
|
||||
const sug = searchSuggestions.value[idx];
|
||||
|
||||
if (sug === "WHERE ") {
|
||||
const trimmed = searchText.value.trimStart();
|
||||
const leading = searchText.value.slice(0, searchText.value.length - trimmed.length);
|
||||
searchText.value = leading + "WHERE ";
|
||||
} else {
|
||||
const lastWordMatch = searchText.value.match(/([^\s,()><=!]+)$/);
|
||||
if (lastWordMatch) {
|
||||
const lastWord = lastWordMatch[1];
|
||||
const prefix = searchText.value.slice(0, -lastWord.length);
|
||||
searchText.value = prefix + sug;
|
||||
}
|
||||
}
|
||||
searchSuggestions.value = [];
|
||||
suggestionIndex.value = -1;
|
||||
searchInputRef.value?.focus();
|
||||
}
|
||||
|
||||
function dismissSuggestions() {
|
||||
searchSuggestions.value = [];
|
||||
suggestionIndex.value = -1;
|
||||
}
|
||||
|
||||
function navigateSuggestion(delta: number) {
|
||||
if (searchSuggestions.value.length === 0) return;
|
||||
suggestionIndex.value = Math.min(
|
||||
Math.max(suggestionIndex.value + delta, 0),
|
||||
searchSuggestions.value.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
const PAIRS: Record<string, string> = { "'": "'", '"': '"', "(": ")" };
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key in PAIRS && !e.ctrlKey && !e.metaKey) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
const close = PAIRS[e.key];
|
||||
|
||||
if (start !== end) {
|
||||
// Wrap selection: 'text' → 'text'
|
||||
e.preventDefault();
|
||||
const selected = searchText.value.slice(start, end);
|
||||
searchText.value = searchText.value.slice(0, start) + e.key + selected + close + searchText.value.slice(end);
|
||||
nextTick(() => {
|
||||
input.setSelectionRange(start + 1 + selected.length, start + 1 + selected.length);
|
||||
});
|
||||
suggestionIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchText.value[start] === close) {
|
||||
// Cursor before closing char → skip over it
|
||||
e.preventDefault();
|
||||
input.setSelectionRange(start + 1, start + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
searchText.value = searchText.value.slice(0, start) + e.key + close + searchText.value.slice(end);
|
||||
nextTick(() => {
|
||||
input.setSelectionRange(start + 1, start + 1);
|
||||
});
|
||||
suggestionIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchSuggestions.value.length > 0) {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
acceptSuggestion();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
dismissSuggestions();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
navigateSuggestion(1);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
navigateSuggestion(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
onSearchEnter(e);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
searchText.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const saveError = ref("");
|
||||
const isApplyingWhere = ref(false);
|
||||
const rowStatusFilter = ref<RowStatusFilter>("all");
|
||||
|
|
@ -997,14 +1146,35 @@ function escapeAndHighlightKeywords(s: string): string {
|
|||
<ContextMenuTrigger as-child>
|
||||
<div v-if="hasData" class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Search bar -->
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20">
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20 relative">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchText"
|
||||
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="canUseWhereSearch ? t('grid.searchOrWhere') : t('grid.search')"
|
||||
@keydown.enter="onSearchEnter"
|
||||
@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"
|
||||
>
|
||||
<Search class="w-3 h-3 mr-2 text-muted-foreground shrink-0" />
|
||||
<span>{{ sug }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
v-if="editable && tableMeta"
|
||||
:model-value="rowStatusFilter"
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export default {
|
|||
sshPassword: "SSH Password",
|
||||
sshPasswordPlaceholder: "Leave empty to use key",
|
||||
sshKeyPath: "Key Path",
|
||||
sshKeyPassphrase: "Key Passphrase",
|
||||
sshKeyPassphrasePlaceholder: "Leave empty if key is not encrypted",
|
||||
sshKeyPathBrowse: "Browse",
|
||||
sshExposeLan: "Expose tunnel to LAN",
|
||||
compatible: "Compatible",
|
||||
mainstream: "Popular",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export default {
|
|||
sshPassword: "SSH 密码",
|
||||
sshPasswordPlaceholder: "留空则使用密钥",
|
||||
sshKeyPath: "密钥路径",
|
||||
sshKeyPassphrase: "密钥密码",
|
||||
sshKeyPassphrasePlaceholder: "密钥未加密则留空",
|
||||
sshKeyPathBrowse: "浏览",
|
||||
sshExposeLan: "允许局域网访问隧道",
|
||||
compatible: "兼容",
|
||||
mainstream: "主流",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface ConnectionConfig {
|
|||
ssh_user?: string;
|
||||
ssh_password?: string;
|
||||
ssh_key_path?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_expose_lan?: boolean;
|
||||
ssl?: boolean;
|
||||
connection_string?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue