From d88501a8721e4321f8ec3a1c66d5ed24c3190925 Mon Sep 17 00:00:00 2001 From: Jarno Date: Wed, 18 Feb 2026 22:20:25 +0200 Subject: [PATCH] feat: appwide darkmode toggle and daemon connection check --- frontend/src-tauri/capabilities/default.json | 5 +- frontend/src-tauri/capabilities/desktop.json | 16 +-- frontend/src-tauri/src/commands.rs | 24 ++++ frontend/src-tauri/src/main.rs | 13 ++- frontend/src-tauri/tauri.conf.json | 4 +- frontend/src/app.rs | 114 ++++++++++++++++--- frontend/src/bridge.rs | 3 + frontend/src/popup.rs | 85 ++++++++------ frontend/styles-input.css | 19 ---- 9 files changed, 190 insertions(+), 93 deletions(-) diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 4cdbf49..f778364 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -3,8 +3,5 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": [ - "core:default", - "opener:default" - ] + "permissions": ["core:default", "opener:default"] } diff --git a/frontend/src-tauri/capabilities/desktop.json b/frontend/src-tauri/capabilities/desktop.json index a452b12..738fae0 100644 --- a/frontend/src-tauri/capabilities/desktop.json +++ b/frontend/src-tauri/capabilities/desktop.json @@ -1,14 +1,6 @@ { "identifier": "desktop-capability", - "platforms": [ - "macOS", - "windows", - "linux" - ], - "windows": [ - "main" - ], - "permissions": [ - "global-shortcut:default" - ] -} \ No newline at end of file + "platforms": ["macOS", "windows", "linux"], + "windows": ["main", "dashboard", "popup"], + "permissions": ["global-shortcut:default", "core:event:allow-listen"] +} diff --git a/frontend/src-tauri/src/commands.rs b/frontend/src-tauri/src/commands.rs index 6928dfc..a1f031a 100644 --- a/frontend/src-tauri/src/commands.rs +++ b/frontend/src-tauri/src/commands.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use tauri::{Emitter, Manager, State}; use feshared::{ @@ -113,3 +114,26 @@ pub async fn daemon_state(state: State<'_, AppState>) -> Result, + handle: tauri::AppHandle, +) -> Result { + let mut config = state.config.lock().await; + config.dark_mode = !config.dark_mode; + handle + .emit( + "dark-mode-changed", + DarkMode { + is_dark_mode: config.dark_mode, + }, + ) + .unwrap(); + Ok(config.dark_mode) +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index e84dfad..9fa132d 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -6,12 +6,16 @@ mod commands; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; use tokio::sync::Mutex; -use commands::{chat, chat_history, daemon_state, toggle_popup}; +use commands::{chat, chat_history, daemon_state, toggle_dark_mode, toggle_popup}; use shared::ai::ai_daemon_client::AiDaemonClient; +pub struct AppConfig { + dark_mode: bool, +} + pub struct AppState { grpc_client: Mutex>, - current_chat: Mutex>, + config: Mutex, } #[tokio::main] @@ -22,14 +26,15 @@ async fn main() { tauri::Builder::default() .manage(AppState { grpc_client: Mutex::new(client), - current_chat: Mutex::new(None), + config: Mutex::new(AppConfig { dark_mode: true }), }) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .invoke_handler(tauri::generate_handler![ toggle_popup, chat_history, chat, - daemon_state + daemon_state, + toggle_dark_mode, ]) .setup(|app| { /* Auto-hide popup when focus is lost diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index bc148f1..ab1bca6 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -22,8 +22,8 @@ "label": "popup", "title": "AI Quick Action", "url": "/popup", - "width": 800, - "height": 400, + "width": 960, + "height": 720, "decorations": false, "transparent": true, "alwaysOnTop": true, diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 900bd40..24e448a 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,15 +1,17 @@ use std::time::Duration; use feshared::daemon::DaemonState; +use leptos::logging::log; use leptos::{prelude::*, reactive::spawn_local}; use leptos_router::{ components::{Route, Router, Routes}, path, }; -use wasm_bindgen::JsValue; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::{prelude::Closure, JsValue}; -use crate::bridge::invoke; -use crate::popup::Popup; +use crate::bridge::{invoke, listen}; +use crate::popup::PopupView; pub const BTN_PRIMARY: &str = "bg-slate-300 hover:bg-slate-400 dark:bg-slate-800 hover:dark-bg-slate-700 px-4 py-2 rounded-md"; @@ -19,7 +21,7 @@ pub fn App() -> impl IntoView { - + } @@ -33,18 +35,43 @@ fn Dashboard() -> impl IntoView { invoke("toggle_popup", empty_args).await; }); }; + + let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { + spawn_local(async { + let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; + }); + }; view! { -
- + +
+ + +
+
+
+
-
- -
} } +#[component] +pub fn DarkModeToggle() -> impl IntoView { + let toggle_dark_mode = |_ev: leptos::ev::MouseEvent| { + spawn_local(async { + let _ = invoke("toggle_dark_mode", JsValue::UNDEFINED).await; + }); + }; + view! { +
+ + + +
+ } +} + #[component] pub fn DaemonStatusIndicator() -> impl IntoView { let (poll_count, set_pool_count) = signal(0); @@ -89,9 +116,69 @@ pub fn DaemonStatusIndicator() -> impl IntoView { } } +#[component] +pub fn DaemonProvider(children: ChildrenFn) -> impl IntoView { + let (poll_count, set_pool_count) = signal(0); + set_interval( + move || set_pool_count.update(|v| *v += 1), + Duration::from_secs(1), + ); + let status_res = LocalResource::new(move || async move { + poll_count.get(); + let val = invoke("daemon_state", JsValue::NULL).await; + let s: DaemonState = serde_wasm_bindgen::from_value(val).unwrap(); + s + }); + + let is_daemon_ok = Memo::new(move |_| status_res.get().map(|s| (s.is_ok, s.error))); + + provide_context(status_res); + + move || match is_daemon_ok.get() { + Some((true, _)) => children().into_any(), + Some((false, err)) => view! { }.into_any(), + None => view! {

Connecting...

}.into_any(), + } +} + +#[component] +fn DaemonErrorStatus(error: Option) -> impl IntoView { + view! { + +
+

{ error.unwrap_or("Daemon error!".to_string()) }

+
+
+ } +} + +#[derive(Deserialize, Serialize, Clone)] +struct DarkMode { + is_dark_mode: bool, +} + #[component] pub fn ThemeProvider(children: Children) -> impl IntoView { let (is_dark, set_dark) = signal(false); + + Effect::new(move |_| { + spawn_local(async move { + let handler = Closure::wrap(Box::new(move |evt: JsValue| { + log!("Received!!!"); + #[derive(Deserialize)] + struct TauriEvent { + payload: T, + } + if let Ok(wrapper) = serde_wasm_bindgen::from_value::>(evt) { + set_dark.set(wrapper.payload.is_dark_mode); + } + }) as Box); + let unlisten = listen("dark-mode-changed", &handler).await; + // TODO use on_cleanup to call the unlisten JS function. + handler.forget() + }); + }); + Effect::new(move |_| { let el = document() .document_element() @@ -104,13 +191,6 @@ pub fn ThemeProvider(children: Children) -> impl IntoView { }); view! { -
- - {children()} -
+ {children()} } } diff --git a/frontend/src/bridge.rs b/frontend/src/bridge.rs index fb0a08b..6d6391f 100644 --- a/frontend/src/bridge.rs +++ b/frontend/src/bridge.rs @@ -3,4 +3,7 @@ use wasm_bindgen::prelude::*; extern "C" { #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])] pub async fn invoke(cmd: &str, args: JsValue) -> JsValue; + + #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "event"])] + pub async fn listen(event: &str, handler: &Closure) -> JsValue; } diff --git a/frontend/src/popup.rs b/frontend/src/popup.rs index 43e0886..b4a57cc 100644 --- a/frontend/src/popup.rs +++ b/frontend/src/popup.rs @@ -1,18 +1,33 @@ use crate::{ - app::{DaemonStatusIndicator, ThemeProvider}, + app::{DaemonProvider, DarkModeToggle, ThemeProvider}, bridge::invoke, }; -use feshared::chatmessage::{Message, MessageHistory}; +use feshared::{ + chatmessage::{Message, MessageHistory}, + daemon::DaemonState, +}; use leptos::{ev::keydown, html::Input, prelude::*}; use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; use wasm_bindgen_futures::spawn_local; +#[component] +pub fn PopupView() -> impl IntoView { + view! { + + + + + } +} + #[component] pub fn Popup() -> impl IntoView { // Prompt signals and and action let prompt_input_ref = NodeRef::::new(); let (prompt_text, set_prompt_text) = signal(String::new()); let (messages, set_messages) = signal(Vec::::new()); + let status_res = + use_context::>().expect("No daemon connection context!"); let init_history = Action::new_local(|(): &()| async move { let response = invoke( @@ -23,8 +38,12 @@ pub fn Popup() -> impl IntoView { let history: MessageHistory = serde_wasm_bindgen::from_value(response).unwrap(); history }); - Effect::new(move |_| { - init_history.dispatch(()); + Effect::new(move |prev_status: Option| { + let current_ok = status_res.get().map(|s| s.is_ok).unwrap_or(false); + if current_ok && prev_status != Some(true) { + init_history.dispatch(()); + } + current_ok }); Effect::new(move |_| { if let Some(mut dat) = init_history.value().get() { @@ -77,39 +96,35 @@ pub fn Popup() -> impl IntoView { }); view! { - -
-
- +
+ -
-
- -
{msg.text}
-
-
-
-
- -
+ } + prop:value=prompt_text + /> +
+
+
+ +
{msg.text}
+
- +
+
} } diff --git a/frontend/styles-input.css b/frontend/styles-input.css index c38c746..73a727f 100644 --- a/frontend/styles-input.css +++ b/frontend/styles-input.css @@ -21,25 +21,6 @@ body { margin: 0; } -.dark-input { - padding: 12px 20px; - margin: 8px 0; - - /* Colors & Background */ - background-color: #1e1e1e; - color: #ffffff; - border: 1px solid #333333; - border-radius: 8px; /* Soft rounded corners */ - - /* Typography */ - font-family: "Inter", sans-serif; - font-size: 16px; - - /* Smooth Transition */ - transition: all 0.3s ease; - outline: none; -} - @keyframes slideIn { from { opacity: 0;