diff --git a/apps/desktop/app/(app)/page.tsx b/apps/desktop/app/(app)/page.tsx index 067e6611..000c2889 100644 --- a/apps/desktop/app/(app)/page.tsx +++ b/apps/desktop/app/(app)/page.tsx @@ -14,9 +14,10 @@ import { SendHorizontal, Sparkles, } from "lucide-react" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { listDocuments, type DocumentWithMemories } from "@/lib/api" import type { SearchResult } from "@/lib/search" +import { OPEN_MEMORY_EVENT, type SpotlightMemory } from "@/lib/spotlight" import { SearchCommand, useCommandK } from "@/components/search-command" type MemoryPreview = { @@ -51,6 +52,27 @@ export default function DashboardPage() { }, [documentsQuery.isPending, totalCount]) const visibleDocuments = documents.slice(0, 5) + useEffect(() => { + let unlisten: (() => void) | undefined + + import("@tauri-apps/api/event") + .then(({ listen }) => + listen(OPEN_MEMORY_EVENT, (event) => { + setSelectedMemory(spotlightMemoryToPreview(event.payload)) + }), + ) + .then((handler) => { + unlisten = handler + }) + .catch(() => { + unlisten = undefined + }) + + return () => { + unlisten?.() + } + }, []) + return (
@@ -273,6 +295,19 @@ function searchResultToPreview(result: SearchResult): MemoryPreview { } } +function spotlightMemoryToPreview(memory: SpotlightMemory): MemoryPreview { + return { + id: memory.id, + title: memory.title, + summary: memory.summary, + content: memory.content, + raw: memory.raw, + url: memory.url, + type: memory.type, + createdAt: memory.createdAt, + } +} + function formatDate(value: string | Date) { return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", diff --git a/apps/desktop/app/spotlight/page.tsx b/apps/desktop/app/spotlight/page.tsx index b7eaead1..5bb6636e 100644 --- a/apps/desktop/app/spotlight/page.tsx +++ b/apps/desktop/app/spotlight/page.tsx @@ -1,23 +1,198 @@ "use client" -import { Search } from "lucide-react" +import { useQuery } from "@tanstack/react-query" +import { FileText, Loader2, Search, Sparkles } from "lucide-react" +import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react" +import { searchMemories, type SearchResult } from "@/lib/search" +import { + hideSpotlight, + openSpotlightResult, + SPOTLIGHT_SHOWN_EVENT, + type SpotlightMemory, +} from "@/lib/spotlight" -// Phase 1 stub for the frameless spotlight window. Phase 5 wires the Rust -// global-shortcut, window show/hide on blur/Esc, input focus on show, and real -// /v3/search results. export default function SpotlightPage() { + const inputRef = useRef(null) + const [query, setQuery] = useState("") + const [activeIndex, setActiveIndex] = useState(0) + const trimmedQuery = query.trim() + const searchQuery = useQuery({ + queryKey: ["spotlight-search", trimmedQuery], + queryFn: () => searchMemories(trimmedQuery), + enabled: trimmedQuery.length > 0, + staleTime: 30 * 1000, + }) + const results = useMemo( + () => searchQuery.data?.results.slice(0, 6) ?? [], + [searchQuery.data?.results], + ) + + useEffect(() => { + let unlisten: (() => void) | undefined + + const focusInput = () => { + setTimeout(() => inputRef.current?.focus(), 0) + } + + focusInput() + import("@tauri-apps/api/event") + .then(({ listen }) => listen(SPOTLIGHT_SHOWN_EVENT, focusInput)) + .then((handler) => { + unlisten = handler + }) + .catch(() => { + unlisten = undefined + }) + + return () => { + unlisten?.() + } + }, []) + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault() + void hideSpotlight() + return + } + + if (event.key === "ArrowDown") { + event.preventDefault() + setActiveIndex((index) => Math.min(index + 1, results.length - 1)) + return + } + + if (event.key === "ArrowUp") { + event.preventDefault() + setActiveIndex((index) => Math.max(index - 1, 0)) + return + } + + if (event.key === "Enter" && results[activeIndex]) { + event.preventDefault() + void openResult(results[activeIndex]) + } + } + + async function openResult(result: SearchResult) { + await openSpotlightResult(searchResultToSpotlightMemory(result)) + setQuery("") + } + return (
-
-
- +
+
+
+ +
{ + setQuery(event.target.value) + setActiveIndex(0) + }} + onKeyDown={onKeyDown} + placeholder="Ask or search your memories..." + className="min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-[#6F7885]" /> +
+ + Spotlight +
+
+ +
+ {!trimmedQuery ? ( +
+

+ Search by meaning, title, or question. +

+

+ Press Enter to open a result in the main window. +

+
+ ) : null} + + {searchQuery.isFetching ? ( +
+ + Searching... +
+ ) : null} + + {searchQuery.isError ? ( +
+ Search failed. Check your token and API URL. +
+ ) : null} + + {trimmedQuery && !searchQuery.isFetching && results.length === 0 ? ( +
+ No results found. +
+ ) : null} + +
    + {results.map((result, index) => { + const title = result.title ?? result.documentId + const preview = + result.summary ?? + result.content ?? + result.chunks.find((chunk) => chunk.isRelevant)?.content + const active = index === activeIndex + + return ( +
  • + +
  • + ) + })} +
) } + +function searchResultToSpotlightMemory(result: SearchResult): SpotlightMemory { + return { + id: result.documentId, + title: result.title, + summary: result.summary, + content: + result.content ?? + result.chunks.find((chunk) => chunk.isRelevant)?.content ?? + null, + type: result.type, + createdAt: normalizeDate(result.createdAt), + } +} + +function normalizeDate(value: string | Date) { + return value instanceof Date ? value.toISOString() : value +} diff --git a/apps/desktop/lib/spotlight.ts b/apps/desktop/lib/spotlight.ts new file mode 100644 index 00000000..282658b4 --- /dev/null +++ b/apps/desktop/lib/spotlight.ts @@ -0,0 +1,29 @@ +"use client" + +import { invoke } from "@tauri-apps/api/core" + +export type SpotlightMemory = { + id: string + title: string | null + summary?: string | null + content?: string | null + raw?: string | null + url?: string | null + type?: string | null + createdAt: string +} + +export const OPEN_MEMORY_EVENT = "nav:open-memory" +export const SPOTLIGHT_SHOWN_EVENT = "spotlight:shown" + +export function showSpotlight() { + return invoke("spotlight_show") +} + +export function hideSpotlight() { + return invoke("spotlight_hide") +} + +export function openSpotlightResult(memory: SpotlightMemory) { + return invoke("spotlight_open_result", { memory }) +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index b66a781f..24c8f9a0 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -724,6 +724,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -993,6 +1003,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1116,6 +1136,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1683,6 +1721,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2598,6 +2642,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.41" @@ -3061,6 +3118,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-global-shortcut", ] [[package]] @@ -3298,6 +3356,37 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4672,6 +4761,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 26156168..1a29325c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,3 +20,4 @@ keyring = "3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-global-shortcut = "2" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 95dd7d51..7125cda9 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -2,6 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Core capabilities granted to the main window.", - "windows": ["main"], + "windows": ["main", "spotlight"], "permissions": ["core:default", "core:window:allow-start-dragging"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 489038c1..0ed16fa9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod auth; +mod spotlight; use serde::Serialize; @@ -43,15 +44,42 @@ async fn auth_whoami() -> Result { auth::whoami().await } +#[tauri::command] +fn spotlight_show(app: tauri::AppHandle) -> Result<(), String> { + spotlight::show(&app).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn spotlight_hide(app: tauri::AppHandle) -> Result<(), String> { + spotlight::hide(&app).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn spotlight_open_result( + app: tauri::AppHandle, + memory: spotlight::SpotlightMemory, +) -> Result<(), String> { + spotlight::open_result(&app, memory).map_err(|error| error.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .setup(|app| { + spotlight::create_window(app)?; + spotlight::register_shortcut(app); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ app_info, auth_store_token, auth_get_token, auth_clear, - auth_whoami + auth_whoami, + spotlight_show, + spotlight_hide, + spotlight_open_result ]) // Bootstrap failure is unrecoverable (no window, no app), so we abort // loudly here. This is the one sanctioned `expect` — see roadmap quality bar. diff --git a/apps/desktop/src-tauri/src/spotlight.rs b/apps/desktop/src-tauri/src/spotlight.rs new file mode 100644 index 00000000..7f07217a --- /dev/null +++ b/apps/desktop/src-tauri/src/spotlight.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use tauri::{ + App, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, + WindowEvent, +}; +use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; + +const MAIN_LABEL: &str = "main"; +const SPOTLIGHT_LABEL: &str = "spotlight"; +const OPEN_MEMORY_EVENT: &str = "nav:open-memory"; +const SHOWN_EVENT: &str = "spotlight:shown"; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotlightMemory { + id: String, + title: Option, + summary: Option, + content: Option, + raw: Option, + url: Option, + #[serde(rename = "type")] + memory_type: Option, + created_at: String, +} + +pub fn shortcut() -> Shortcut { + Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM) +} + +pub fn create_window(app: &App) -> tauri::Result { + if let Some(window) = app.get_webview_window(SPOTLIGHT_LABEL) { + return Ok(window); + } + + let window = WebviewWindowBuilder::new( + app, + SPOTLIGHT_LABEL, + WebviewUrl::App("spotlight/".into()), + ) + .title("Supermemory Spotlight") + .inner_size(760.0, 420.0) + .min_inner_size(560.0, 220.0) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .center() + .build()?; + + let window_for_blur = window.clone(); + window.on_window_event(move |event| { + if matches!(event, WindowEvent::Focused(false)) { + let _ = window_for_blur.hide(); + } + }); + + Ok(window) +} + +pub fn register_shortcut(app: &App) { + let result = app + .global_shortcut() + .on_shortcut(shortcut(), |app, shortcut, event| { + if event.state == ShortcutState::Pressed && shortcut.matches( + Modifiers::SUPER | Modifiers::SHIFT, + Code::KeyM, + ) { + let _ = toggle(app); + } + }); + + if let Err(error) = result { + eprintln!("failed to register Supermemory spotlight shortcut: {error}"); + } +} + +pub fn toggle(app: &AppHandle) -> tauri::Result<()> { + let window = spotlight_window(app)?; + if window.is_visible()? { + window.hide() + } else { + show(app) + } +} + +pub fn show(app: &AppHandle) -> tauri::Result<()> { + let window = spotlight_window(app)?; + window.center()?; + window.show()?; + window.set_focus()?; + window.emit(SHOWN_EVENT, ())?; + Ok(()) +} + +pub fn hide(app: &AppHandle) -> tauri::Result<()> { + spotlight_window(app)?.hide() +} + +pub fn open_result(app: &AppHandle, memory: SpotlightMemory) -> tauri::Result<()> { + if let Some(main) = app.get_webview_window(MAIN_LABEL) { + main.show()?; + main.set_focus()?; + main.emit(OPEN_MEMORY_EVENT, memory)?; + } + + hide(app) +} + +fn spotlight_window(app: &AppHandle) -> tauri::Result { + app.get_webview_window(SPOTLIGHT_LABEL) + .ok_or_else(|| tauri::Error::WindowNotFound) +}