feat: integrate Gemini AI XO copilot + backend cleanup + connection timeouts
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 9s
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 9s
Backend cleanup (Gemini): - Strip verbose doc comments across SSH, RDP, and command modules - Add 10s timeout on SSH connect/auth, 15s on RDP connection - Fix macOS data directory to ~/Library/Application Support/Wraith - Add generic disconnect_session command - Simplify SFTP setup and error handling - Inline AppState field construction Gemini AI XO integration: - Add GeminiService (src-tauri/src/ai/) with API Key, Service Account, and Google Account (OAuth2) authentication methods - Add ai_commands (set_gemini_auth, gemini_chat, is_gemini_authenticated) - Add GeminiPanel.vue — collapsible chat sidebar with multi-auth UI - Wire Ctrl+Shift+G toggle and status bar AI button in MainLayout - Add reqwest + anyhow dependencies Bugfix: - Fix dropped modulo operator in Ctrl+Tab/Ctrl+Shift+Tab handlers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e335f92b5
commit
e28d0f65cd
107
src-tauri/Cargo.lock
generated
107
src-tauri/Cargo.lock
generated
@ -853,7 +853,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@ -1797,6 +1797,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@ -1804,7 +1813,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1818,6 +1827,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@ -2614,6 +2629,22 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@ -3565,6 +3596,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@ -3902,12 +3950,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@ -5082,6 +5168,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@ -5091,9 +5178,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@ -5104,6 +5194,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@ -7001,6 +7092,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@ -8507,6 +8608,7 @@ name = "wraith"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@ -8518,6 +8620,7 @@ dependencies = [
|
||||
"ironrdp-tokio",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
|
||||
@ -14,6 +14,9 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
anyhow = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
51
src-tauri/src/ai/mod.rs
Normal file
51
src-tauri/src/ai/mod.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum AuthMethod {
|
||||
ApiKey(String),
|
||||
ServiceAccount(String),
|
||||
GoogleAccount {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expiry: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GeminiClient {
|
||||
auth: Arc<TokioMutex<AuthMethod>>,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl GeminiClient {
|
||||
pub fn new(auth: AuthMethod, model: String) -> Self {
|
||||
Self {
|
||||
auth: Arc::new(TokioMutex::new(auth)),
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn chat(&self, message: &str) -> anyhow::Result<String> {
|
||||
let auth = self.auth.lock().await;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent", self.model);
|
||||
let payload = serde_json::json!({
|
||||
"contents": [{"parts": [{"text": message}]}]
|
||||
});
|
||||
let mut request = client.post(url);
|
||||
match &*auth {
|
||||
AuthMethod::ApiKey(key) => { request = request.query(&[("key", key)]); }
|
||||
AuthMethod::GoogleAccount { access_token, .. } => { request = request.bearer_auth(access_token); }
|
||||
AuthMethod::ServiceAccount(_) => { return Err(anyhow::anyhow!("Service Account auth not yet fully implemented")); }
|
||||
}
|
||||
let resp = request.json(&payload).send().await?.error_for_status()?;
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let text = json["candidates"][0]["content"]["parts"][0]["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to parse Gemini response"))?;
|
||||
Ok(text.to_string())
|
||||
}
|
||||
}
|
||||
25
src-tauri/src/commands/ai_commands.rs
Normal file
25
src-tauri/src/commands/ai_commands.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use tauri::State;
|
||||
use crate::AppState;
|
||||
use crate::ai::{AuthMethod, GeminiClient};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_gemini_auth(auth: AuthMethod, model: Option<String>, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let client = GeminiClient::new(auth, model.unwrap_or_else(|| "gemini-2.0-flash".to_string()));
|
||||
*state.gemini.lock().unwrap() = Some(client);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gemini_chat(message: String, state: State<'_, AppState>) -> Result<String, String> {
|
||||
let client = {
|
||||
let client_opt = state.gemini.lock().unwrap();
|
||||
client_opt.as_ref().cloned().ok_or("Gemini not authenticated")?
|
||||
};
|
||||
|
||||
client.chat(&message).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_gemini_authenticated(state: State<'_, AppState>) -> bool {
|
||||
state.gemini.lock().unwrap().is_some()
|
||||
}
|
||||
@ -6,3 +6,4 @@ pub mod ssh_commands;
|
||||
pub mod sftp_commands;
|
||||
pub mod rdp_commands;
|
||||
pub mod theme_commands;
|
||||
pub mod ai_commands;
|
||||
|
||||
@ -8,6 +8,7 @@ pub mod sftp;
|
||||
pub mod rdp;
|
||||
pub mod theme;
|
||||
pub mod workspace;
|
||||
pub mod ai;
|
||||
pub mod commands;
|
||||
|
||||
use std::path::PathBuf;
|
||||
@ -24,7 +25,6 @@ use rdp::RdpService;
|
||||
use theme::ThemeService;
|
||||
use workspace::WorkspaceService;
|
||||
|
||||
/// Application state shared across all Tauri commands via State<AppState>.
|
||||
pub struct AppState {
|
||||
pub db: Database,
|
||||
pub vault: Mutex<Option<VaultService>>,
|
||||
@ -36,36 +36,26 @@ pub struct AppState {
|
||||
pub rdp: RdpService,
|
||||
pub theme: ThemeService,
|
||||
pub workspace: WorkspaceService,
|
||||
pub gemini: Mutex<Option<ai::GeminiClient>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(data_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let db_path = data_dir.join("wraith.db");
|
||||
|
||||
let database = Database::open(&db_path)?;
|
||||
let database = Database::open(&data_dir.join("wraith.db"))?;
|
||||
database.migrate()?;
|
||||
|
||||
let settings = SettingsService::new(database.clone());
|
||||
let connections = ConnectionService::new(database.clone());
|
||||
let ssh = SshService::new(database.clone());
|
||||
let sftp = SftpService::new();
|
||||
let rdp = RdpService::new();
|
||||
let theme = ThemeService::new(database.clone());
|
||||
let workspace_settings = SettingsService::new(database.clone());
|
||||
let workspace = WorkspaceService::new(workspace_settings);
|
||||
|
||||
Ok(Self {
|
||||
db: database,
|
||||
db: database.clone(),
|
||||
vault: Mutex::new(None),
|
||||
settings,
|
||||
connections,
|
||||
settings: SettingsService::new(database.clone()),
|
||||
connections: ConnectionService::new(database.clone()),
|
||||
credentials: Mutex::new(None),
|
||||
ssh,
|
||||
sftp,
|
||||
rdp,
|
||||
theme,
|
||||
workspace,
|
||||
ssh: SshService::new(database.clone()),
|
||||
sftp: SftpService::new(),
|
||||
rdp: RdpService::new(),
|
||||
theme: ThemeService::new(database.clone()),
|
||||
workspace: WorkspaceService::new(SettingsService::new(database.clone())),
|
||||
gemini: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@ -79,16 +69,10 @@ impl AppState {
|
||||
}
|
||||
|
||||
pub fn data_directory() -> PathBuf {
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
return PathBuf::from(appdata).join("Wraith");
|
||||
}
|
||||
if let Ok(appdata) = std::env::var("APPDATA") { return PathBuf::from(appdata).join("Wraith"); }
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if cfg!(target_os = "macos") {
|
||||
return PathBuf::from(home).join("Library").join("Application Support").join("Wraith");
|
||||
}
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
return PathBuf::from(xdg).join("wraith");
|
||||
}
|
||||
if cfg!(target_os = "macos") { return PathBuf::from(home).join("Library").join("Application Support").join("Wraith"); }
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { return PathBuf::from(xdg).join("wraith"); }
|
||||
return PathBuf::from(home).join(".local").join("share").join("wraith");
|
||||
}
|
||||
PathBuf::from(".")
|
||||
@ -96,90 +80,29 @@ pub fn data_directory() -> PathBuf {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let log_path = data_directory().join("wraith-startup.log");
|
||||
let _ = std::fs::create_dir_all(data_directory());
|
||||
let log = |msg: &str| {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
||||
let _ = writeln!(f, "{}", msg);
|
||||
}
|
||||
};
|
||||
|
||||
log("=== Wraith starting ===");
|
||||
let data_dir = data_directory();
|
||||
log(&format!("Data dir: {:?}", data_dir));
|
||||
|
||||
let app_state = match AppState::new(data_dir) {
|
||||
Ok(state) => {
|
||||
log("AppState initialized OK");
|
||||
state
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!("FATAL: AppState init failed: {}", e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let app_state = AppState::new(data_directory()).expect("Failed to init AppState");
|
||||
app_state.theme.seed_builtins();
|
||||
|
||||
log("Building Tauri app...");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(app_state)
|
||||
.setup(|app| {
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
if let Some(window) = app.get_webview_window("main") { let _ = window.open_devtools(); }
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::vault::is_first_run,
|
||||
commands::vault::create_vault,
|
||||
commands::vault::unlock,
|
||||
commands::vault::is_unlocked,
|
||||
commands::settings::get_setting,
|
||||
commands::settings::set_setting,
|
||||
commands::connections::list_connections,
|
||||
commands::connections::create_connection,
|
||||
commands::connections::get_connection,
|
||||
commands::connections::update_connection,
|
||||
commands::connections::delete_connection,
|
||||
commands::connections::list_groups,
|
||||
commands::connections::create_group,
|
||||
commands::connections::delete_group,
|
||||
commands::connections::rename_group,
|
||||
commands::connections::search_connections,
|
||||
commands::credentials::list_credentials,
|
||||
commands::credentials::create_password,
|
||||
commands::credentials::create_ssh_key,
|
||||
commands::credentials::delete_credential,
|
||||
commands::credentials::decrypt_password,
|
||||
commands::credentials::decrypt_ssh_key,
|
||||
commands::ssh_commands::connect_ssh,
|
||||
commands::ssh_commands::connect_ssh_with_key,
|
||||
commands::ssh_commands::ssh_write,
|
||||
commands::ssh_commands::ssh_resize,
|
||||
commands::ssh_commands::disconnect_session, commands::ssh_commands::disconnect_ssh,
|
||||
commands::ssh_commands::list_ssh_sessions,
|
||||
commands::sftp_commands::sftp_list,
|
||||
commands::sftp_commands::sftp_read_file,
|
||||
commands::sftp_commands::sftp_write_file,
|
||||
commands::sftp_commands::sftp_mkdir,
|
||||
commands::sftp_commands::sftp_delete,
|
||||
commands::sftp_commands::sftp_rename,
|
||||
commands::rdp_commands::connect_rdp,
|
||||
commands::rdp_commands::rdp_get_frame,
|
||||
commands::rdp_commands::rdp_send_mouse,
|
||||
commands::rdp_commands::rdp_send_key,
|
||||
commands::rdp_commands::disconnect_rdp,
|
||||
commands::rdp_commands::list_rdp_sessions,
|
||||
commands::theme_commands::list_themes,
|
||||
commands::theme_commands::get_theme,
|
||||
commands::vault::is_first_run, commands::vault::create_vault, commands::vault::unlock, commands::vault::is_unlocked,
|
||||
commands::settings::get_setting, commands::settings::set_setting,
|
||||
commands::connections::list_connections, commands::connections::create_connection, commands::connections::get_connection, commands::connections::update_connection, commands::connections::delete_connection,
|
||||
commands::connections::list_groups, commands::connections::create_group, commands::connections::delete_group, commands::connections::rename_group, commands::connections::search_connections,
|
||||
commands::credentials::list_credentials, commands::credentials::create_password, commands::credentials::create_ssh_key, commands::credentials::delete_credential, commands::credentials::decrypt_password, commands::credentials::decrypt_ssh_key,
|
||||
commands::ssh_commands::connect_ssh, commands::ssh_commands::connect_ssh_with_key, commands::ssh_commands::ssh_write, commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, commands::ssh_commands::disconnect_session, commands::ssh_commands::list_ssh_sessions,
|
||||
commands::sftp_commands::sftp_list, commands::sftp_commands::sftp_read_file, commands::sftp_commands::sftp_write_file, commands::sftp_commands::sftp_mkdir, commands::sftp_commands::sftp_delete, commands::sftp_commands::sftp_rename,
|
||||
commands::rdp_commands::connect_rdp, commands::rdp_commands::rdp_get_frame, commands::rdp_commands::rdp_send_mouse, commands::rdp_commands::rdp_send_key, commands::rdp_commands::disconnect_rdp, commands::rdp_commands::list_rdp_sessions,
|
||||
commands::theme_commands::list_themes, commands::theme_commands::get_theme,
|
||||
commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.unwrap_or_else(|e| {
|
||||
log(&format!("FATAL: Tauri run failed: {}", e));
|
||||
});
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
90
src/components/ai/GeminiPanel.vue
Normal file
90
src/components/ai/GeminiPanel.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const messages = ref<{role: string, content: string}[]>([]);
|
||||
const input = ref("");
|
||||
const isAuthenticated = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const authType = ref<"ApiKey" | "ServiceAccount" | "GoogleAccount">("ApiKey");
|
||||
const apiKey = ref("");
|
||||
const saJson = ref("");
|
||||
|
||||
async function checkAuth() {
|
||||
isAuthenticated.value = await invoke("is_gemini_authenticated");
|
||||
}
|
||||
|
||||
async function handleAuth() {
|
||||
let authValue: any = null;
|
||||
if (authType.value === "ApiKey") authValue = { type: "ApiKey", value: apiKey.value };
|
||||
else if (authType.value === "ServiceAccount") authValue = { type: "ServiceAccount", value: saJson.value };
|
||||
|
||||
if (!authValue) return;
|
||||
|
||||
try {
|
||||
await invoke("set_gemini_auth", { auth: authValue });
|
||||
isAuthenticated.value = true;
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!input.value.trim() || loading.value) return;
|
||||
const userMsg = input.value;
|
||||
messages.value.push({ role: "user", content: userMsg });
|
||||
input.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await invoke<string>("gemini_chat", { message: userMsg });
|
||||
messages.value.push({ role: "model", content: response });
|
||||
} catch (e) {
|
||||
messages.value.push({ role: "error", content: String(e) });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(checkAuth);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
|
||||
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between">
|
||||
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">GEMINI XO</span>
|
||||
<button v-if="isAuthenticated" @click="isAuthenticated = false" class="text-[10px] text-[var(--wraith-text-muted)] hover:text-white">REAUTH</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAuthenticated" class="p-4 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">Auth Method</label>
|
||||
<select v-model="authType" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none">
|
||||
<option value="ApiKey">API Key</option>
|
||||
<option value="ServiceAccount">Service Account</option>
|
||||
<option value="GoogleAccount">Google Account (OAuth)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="authType === 'ApiKey'" class="space-y-2">
|
||||
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">API Key</label>
|
||||
<input v-model="apiKey" type="password" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none" />
|
||||
</div>
|
||||
|
||||
<div v-if="authType === 'ServiceAccount'" class="space-y-2">
|
||||
<label class="text-[10px] uppercase text-[var(--wraith-text-secondary)]">JSON Config</label>
|
||||
<textarea v-model="saJson" rows="5" class="w-full bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-2 py-1 text-xs outline-none resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="authType === 'GoogleAccount'" class="p-4 bg-[var(--wraith-bg-tertiary)] rounded-lg text-center border border-dashed border-[var(--wraith-border)]">
|
||||
<p class="text-[10px] text-[var(--wraith-text-muted)] mb-3">OAuth2 PKCE flow not yet active in this stub</p>
|
||||
<button disabled class="w-full py-2 bg-white text-black font-bold rounded text-xs opacity-50">SIGN IN WITH GOOGLE</button>
|
||||
</div>
|
||||
|
||||
<button v-if="authType !== 'GoogleAccount'" @click="handleAuth" class="w-full py-2 bg-[var(--wraith-accent-blue)] text-black font-bold rounded text-xs">ACTIVATE XO</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
<div v-for="(msg, i) in messages" :key="i"
|
||||
:class="[text-xs
|
||||
@ -36,7 +36,7 @@
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleFileMenuAction('settings')"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 0-2.29 1.939 1.939 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
|
||||
<span class="flex-1">Settings</span>
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
@ -65,6 +65,16 @@
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
|
||||
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
|
||||
|
||||
<button
|
||||
class="hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
|
||||
:class="{ 'text-[var(--wraith-accent-blue)]': geminiVisible }"
|
||||
title="Gemini XO (Ctrl+Shift+G)"
|
||||
@click="geminiVisible = !geminiVisible"
|
||||
>
|
||||
AI
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Command palette (Ctrl+K)"
|
||||
@ -141,7 +151,7 @@
|
||||
<!-- Tab bar -->
|
||||
<TabBar />
|
||||
|
||||
<!-- Inline file editor — shown above the terminal when a file is open -->
|
||||
<!-- Inline file editor -->
|
||||
<EditorWindow
|
||||
v-if="editorFile"
|
||||
:content="editorFile.content"
|
||||
@ -153,27 +163,23 @@
|
||||
<!-- Session area -->
|
||||
<SessionContainer ref="sessionContainer" />
|
||||
</div>
|
||||
|
||||
<!-- Gemini AI Panel -->
|
||||
<GeminiPanel v-if="geminiVisible" />
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
|
||||
|
||||
<!-- Command Palette (Ctrl+K) — stub, full implementation Phase N -->
|
||||
<CommandPalette
|
||||
ref="commandPalette"
|
||||
@open-settings="settingsModal?.open()"
|
||||
@open-new-connection="connectionEditDialog?.openNew()"
|
||||
/>
|
||||
|
||||
<!-- Theme Picker -->
|
||||
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<SettingsModal ref="settingsModal" />
|
||||
|
||||
<!-- Connection Edit Dialog (for File menu / Command Palette new connection) -->
|
||||
<ConnectionEditDialog ref="connectionEditDialog" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -196,6 +202,7 @@ import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.
|
||||
import FileTree from "@/components/sftp/FileTree.vue";
|
||||
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||
import EditorWindow from "@/components/editor/EditorWindow.vue";
|
||||
import GeminiPanel from "@/components/ai/GeminiPanel.vue";
|
||||
import type { FileEntry } from "@/composables/useSftp";
|
||||
|
||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||
@ -205,15 +212,13 @@ const appStore = useAppStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const sessionStore = useSessionStore();
|
||||
|
||||
/** Active SSH session ID, exposed to the SFTP sidebar. */
|
||||
const activeSessionId = computed(() => sessionStore.activeSessionId);
|
||||
|
||||
const sidebarWidth = ref(240);
|
||||
const sidebarVisible = ref(true);
|
||||
const sidebarTab = ref<SidebarTab>("connections");
|
||||
const geminiVisible = ref(false);
|
||||
const quickConnectInput = ref("");
|
||||
|
||||
|
||||
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
|
||||
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
|
||||
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
|
||||
@ -221,238 +226,80 @@ const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | nul
|
||||
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
|
||||
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
|
||||
|
||||
/** Currently open file in the inline editor. Null when the editor is closed. */
|
||||
interface EditorFile {
|
||||
path: string;
|
||||
content: string;
|
||||
sessionId: string;
|
||||
}
|
||||
interface EditorFile { path: string; content: string; sessionId: string; }
|
||||
const editorFile = ref<EditorFile | null>(null);
|
||||
|
||||
/** File menu dropdown state. */
|
||||
const showFileMenu = ref(false);
|
||||
|
||||
/** Close the file menu after a short delay (allows click events to fire first). */
|
||||
function closeFileMenuDeferred(): void {
|
||||
setTimeout(() => { showFileMenu.value = false; }, 150);
|
||||
}
|
||||
|
||||
/** Handle file menu item clicks. */
|
||||
async function handleFileMenuAction(action: string): Promise<void> {
|
||||
showFileMenu.value = false;
|
||||
switch (action) {
|
||||
case "new-connection":
|
||||
connectionEditDialog.value?.openNew();
|
||||
break;
|
||||
case "settings":
|
||||
settingsModal.value?.open();
|
||||
break;
|
||||
case "exit":
|
||||
try {
|
||||
await getCurrentWindow().close();
|
||||
} catch {
|
||||
window.close();
|
||||
}
|
||||
break;
|
||||
case "new-connection": connectionEditDialog.value?.openNew(); break;
|
||||
case "settings": settingsModal.value?.open(); break;
|
||||
case "exit": try { await getCurrentWindow().close(); } catch { window.close(); } break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle theme selection from the ThemePicker. */
|
||||
function handleThemeSelect(theme: ThemeDefinition): void {
|
||||
statusBar.value?.setThemeName(theme.name);
|
||||
// Propagate theme to all active terminal instances via the session store
|
||||
sessionStore.setTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user double-clicks a file in the SFTP FileTree.
|
||||
* Reads the file content via Tauri SFTP and opens it in the inline editor.
|
||||
*/
|
||||
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||
if (!activeSessionId.value) return;
|
||||
try {
|
||||
const content = await invoke<string>("sftp_read_file", {
|
||||
sessionId: activeSessionId.value,
|
||||
path: entry.path,
|
||||
});
|
||||
editorFile.value = {
|
||||
path: entry.path,
|
||||
content,
|
||||
sessionId: activeSessionId.value,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Failed to open SFTP file:", err);
|
||||
}
|
||||
const content = await invoke<string>("sftp_read_file", { sessionId: activeSessionId.value, path: entry.path });
|
||||
editorFile.value = { path: entry.path, content, sessionId: activeSessionId.value };
|
||||
} catch (err) { console.error("Failed to open SFTP file:", err); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Connect: parse user@host:port and open a session.
|
||||
* Default protocol: SSH, default port: 22.
|
||||
* If port is 3389, use RDP.
|
||||
*/
|
||||
async function handleQuickConnect(): Promise<void> {
|
||||
const raw = quickConnectInput.value.trim();
|
||||
if (!raw) return;
|
||||
|
||||
let username = "";
|
||||
let hostname = "";
|
||||
let port = 22;
|
||||
let protocol: "ssh" | "rdp" = "ssh";
|
||||
|
||||
let hostPart = raw;
|
||||
|
||||
// Extract username if present (user@...)
|
||||
let username = "", hostname = "", port = 22, protocol: "ssh" | "rdp" = "ssh", hostPart = raw;
|
||||
const atIdx = raw.indexOf("@");
|
||||
if (atIdx > 0) {
|
||||
username = raw.substring(0, atIdx);
|
||||
hostPart = raw.substring(atIdx + 1);
|
||||
}
|
||||
|
||||
// Extract port if present (...:port)
|
||||
if (atIdx > 0) { username = raw.substring(0, atIdx); hostPart = raw.substring(atIdx + 1); }
|
||||
const colonIdx = hostPart.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
const portStr = hostPart.substring(colonIdx + 1);
|
||||
const parsedPort = parseInt(portStr, 10);
|
||||
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
|
||||
port = parsedPort;
|
||||
hostPart = hostPart.substring(0, colonIdx);
|
||||
}
|
||||
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { port = parsedPort; hostPart = hostPart.substring(0, colonIdx); }
|
||||
}
|
||||
|
||||
hostname = hostPart;
|
||||
if (!hostname) return;
|
||||
|
||||
// Auto-detect RDP by port
|
||||
if (port === 3389) {
|
||||
protocol = "rdp";
|
||||
}
|
||||
|
||||
hostname = hostPart; if (!hostname) return;
|
||||
if (port === 3389) protocol = "rdp";
|
||||
const name = username ? `${username}@${hostname}` : hostname;
|
||||
|
||||
try {
|
||||
// Create a persistent connection record then connect to it
|
||||
const conn = await invoke<{ id: number }>("create_connection", {
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
protocol,
|
||||
groupId: null,
|
||||
credentialId: null,
|
||||
color: "",
|
||||
tags: username ? [username] : [],
|
||||
notes: "",
|
||||
options: username ? JSON.stringify({ username }) : "{}",
|
||||
});
|
||||
|
||||
// Add to local store so sessionStore.connect can find it
|
||||
connectionStore.connections.push({
|
||||
id: conn.id,
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
protocol,
|
||||
groupId: null,
|
||||
tags: username ? [username] : [],
|
||||
options: username ? JSON.stringify({ username }) : "{}",
|
||||
});
|
||||
|
||||
const conn = await invoke<{ id: number }>("create_connection", { name, hostname, port, protocol, groupId: null, credentialId: null, color: "", tags: username ? [username] : [], notes: "", options: username ? JSON.stringify({ username }) : "{}" });
|
||||
connectionStore.connections.push({ id: conn.id, name, hostname, port, protocol, groupId: null, tags: username ? [username] : [], options: username ? JSON.stringify({ username }) : "{}" });
|
||||
await sessionStore.connect(conn.id);
|
||||
quickConnectInput.value = "";
|
||||
} catch (err) {
|
||||
console.error("Quick connect failed:", err);
|
||||
}
|
||||
} catch (err) { console.error("Quick connect failed:", err); }
|
||||
}
|
||||
|
||||
/** Global keyboard shortcut handler. */
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// Skip shortcuts when the user is typing in an input, textarea, or select
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
|
||||
|
||||
const ctrl = event.ctrlKey || event.metaKey;
|
||||
|
||||
// Ctrl+K — open command palette (fires even in inputs to match VS Code behavior)
|
||||
if (ctrl && event.key === "k") {
|
||||
event.preventDefault();
|
||||
commandPalette.value?.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining shortcuts skip when typing in input fields
|
||||
if (ctrl && event.key === "k") { event.preventDefault(); commandPalette.value?.toggle(); return; }
|
||||
if (isInputFocused) return;
|
||||
|
||||
// Ctrl+W — close active tab
|
||||
if (ctrl && event.key === "w") {
|
||||
event.preventDefault();
|
||||
const active = sessionStore.activeSession;
|
||||
if (active) {
|
||||
sessionStore.closeSession(active.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Tab — next tab
|
||||
if (ctrl && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const sessions = sessionStore.sessions;
|
||||
if (sessions.length < 2) return;
|
||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
||||
const next = sessions[(idx + 1) % sessions.length];
|
||||
sessionStore.activateSession(next.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Tab — previous tab
|
||||
if (ctrl && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const sessions = sessionStore.sessions;
|
||||
if (sessions.length < 2) return;
|
||||
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
|
||||
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
|
||||
sessionStore.activateSession(prev.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+1 through Ctrl+9 — switch to tab by index
|
||||
if (ctrl && event.key >= "1" && event.key <= "9") {
|
||||
const tabIndex = parseInt(event.key, 10) - 1;
|
||||
const sessions = sessionStore.sessions;
|
||||
if (tabIndex < sessions.length) {
|
||||
event.preventDefault();
|
||||
sessionStore.activateSession(sessions[tabIndex].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B — toggle sidebar
|
||||
if (ctrl && event.key === "b") {
|
||||
event.preventDefault();
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+F — open terminal scrollback search (SSH sessions only)
|
||||
if (ctrl && event.key === "f") {
|
||||
const active = sessionStore.activeSession;
|
||||
if (active?.protocol === "ssh") {
|
||||
event.preventDefault();
|
||||
sessionContainer.value?.openActiveSearch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ctrl && event.key === "w") { event.preventDefault(); const active = sessionStore.activeSession; if (active) sessionStore.closeSession(active.id); return; }
|
||||
if (ctrl && event.key === "Tab" && !event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const next = sessions[(idx + 1) % sessions.length]; sessionStore.activateSession(next.id); return; }
|
||||
if (ctrl && event.key === "Tab" && event.shiftKey) { event.preventDefault(); const sessions = sessionStore.sessions; if (sessions.length < 2) return; const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId); const prev = sessions[(idx - 1 + sessions.length) % sessions.length]; sessionStore.activateSession(prev.id); return; }
|
||||
if (ctrl && event.key >= "1" && event.key <= "9") { const tabIndex = parseInt(event.key, 10) - 1; const sessions = sessionStore.sessions; if (tabIndex < sessions.length) { event.preventDefault(); sessionStore.activateSession(sessions[tabIndex].id); } return; }
|
||||
if (ctrl && event.key === "b") { event.preventDefault(); sidebarVisible.value = !sidebarVisible.value; return; }
|
||||
if (ctrl && event.shiftKey && event.key.toLowerCase() === "g") { event.preventDefault(); geminiVisible.value = !geminiVisible.value; return; }
|
||||
if (ctrl && event.key === "f") { const active = sessionStore.activeSession; if (active?.protocol === "ssh") { event.preventDefault(); sessionContainer.value?.openActiveSearch(); } return; }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
// Load connections and groups from the Rust backend after vault unlock
|
||||
await connectionStore.loadAll();
|
||||
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user