Ship floating launcher, automatic discovery and bounded media search improvements
This commit is contained in:
1 parent
01279a6b25
commit
521966a2ac
43 files changed
+3165
-267
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "1.1.344",
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -3799,7 +3799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "searchi-desktop"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"image",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "searchi-desktop"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
edition = "2021"
|
||||
description = "Local Searchi desktop shell"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
//! Bounded native application discovery. The webview receives opaque catalog
|
||||
//! identifiers, never executable paths or command lines.
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const MAX_ENTRIES: usize = 8192;
|
||||
const MAX_APPS: usize = 4096;
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ApplicationResult {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
struct Application {
|
||||
result: ApplicationResult,
|
||||
normalized_name: String,
|
||||
compact_name: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
pub struct ApplicationCatalog {
|
||||
apps: Vec<Application>,
|
||||
#[cfg(windows)]
|
||||
registered: std::sync::Arc<std::sync::Mutex<Vec<RegisteredApplication>>>,
|
||||
}
|
||||
impl ApplicationCatalog {
|
||||
pub fn discover() -> Self {
|
||||
let catalog = Self::from_roots(application_roots());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let destination = catalog.registered.clone();
|
||||
// Store/UWP enumeration is optional background work. Startup and
|
||||
// searches never wait for PowerShell or model setup.
|
||||
std::thread::spawn(move || {
|
||||
if let Some(apps) = registered_applications() {
|
||||
if let Ok(mut destination) = destination.lock() {
|
||||
*destination = apps;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catalog
|
||||
}
|
||||
fn from_roots(roots: Vec<PathBuf>) -> Self {
|
||||
let mut apps = Vec::new();
|
||||
let mut visited = 0;
|
||||
let mut names = HashSet::new();
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut desktop_ids = HashSet::new();
|
||||
for root in roots {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
let mut pending = vec![(root.clone(), 0)];
|
||||
while let Some((directory, depth)) = pending.pop() {
|
||||
let Ok(entries) = fs::read_dir(directory) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if visited >= MAX_ENTRIES || apps.len() >= MAX_APPS {
|
||||
break;
|
||||
}
|
||||
visited += 1;
|
||||
let Ok(kind) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
// No directory symlink traversal or external catalog paths.
|
||||
if kind.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if kind.is_dir() && !is_app_bundle(&path) {
|
||||
if depth < MAX_DEPTH {
|
||||
pending.push((path, depth + 1));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if extension_is(&path, "desktop") {
|
||||
// User entries (including Hidden=true) shadow the same
|
||||
// desktop ID in system roots, as required by XDG.
|
||||
let relative = path.strip_prefix(&root).ok().map(|path| {
|
||||
path.components()
|
||||
.map(|part| part.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
});
|
||||
if relative.is_some_and(|id| !desktop_ids.insert(id)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(name) = application_name(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(path) = path.canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
if !path.starts_with(&root) {
|
||||
continue;
|
||||
}
|
||||
let normalized_name = name.to_lowercase();
|
||||
if !names.insert(normalized_name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let id = format!(
|
||||
"app:{:x}",
|
||||
Sha256::digest(path.to_string_lossy().as_bytes())
|
||||
);
|
||||
apps.push(Application {
|
||||
result: ApplicationResult { id, name },
|
||||
compact_name: compact_name(&normalized_name),
|
||||
normalized_name,
|
||||
path,
|
||||
});
|
||||
}
|
||||
if visited >= MAX_ENTRIES || apps.len() >= MAX_APPS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
apps.sort_by(|a, b| a.normalized_name.cmp(&b.normalized_name));
|
||||
Self {
|
||||
apps,
|
||||
#[cfg(windows)]
|
||||
registered: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str, limit: usize) -> Vec<ApplicationResult> {
|
||||
let query = query.trim().to_lowercase();
|
||||
if query.is_empty() || query.len() > 512 {
|
||||
return Vec::new();
|
||||
}
|
||||
let words: Vec<_> = query.split_whitespace().collect();
|
||||
let compact_query = compact_name(&query);
|
||||
let mut matches: Vec<_> = self
|
||||
.apps
|
||||
.iter()
|
||||
.filter_map(|app| {
|
||||
let rank = name_match_rank(
|
||||
&app.normalized_name,
|
||||
&app.compact_name,
|
||||
&query,
|
||||
&compact_query,
|
||||
&words,
|
||||
)?;
|
||||
Some((rank, app))
|
||||
})
|
||||
.map(|(rank, app)| (rank, app.normalized_name.clone(), app.result.clone()))
|
||||
.collect();
|
||||
#[cfg(windows)]
|
||||
if let Ok(registered) = self.registered.try_lock() {
|
||||
for app in registered.iter() {
|
||||
if matches
|
||||
.iter()
|
||||
.any(|(_, name, _)| name == &app.normalized_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = &app.normalized_name;
|
||||
let Some(rank) =
|
||||
name_match_rank(name, &app.compact_name, &query, &compact_query, &words)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
matches.push((rank, name.clone(), app.result.clone()));
|
||||
}
|
||||
}
|
||||
matches.sort_by(|(ra, a, _), (rb, b, _)| ra.cmp(rb).then_with(|| a.cmp(b)));
|
||||
matches
|
||||
.into_iter()
|
||||
.take(limit.min(32))
|
||||
.map(|(_, _, result)| result)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn launch(&self, app: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
if let Some(app_id) = self.registered.lock().ok().and_then(|apps| {
|
||||
apps.iter()
|
||||
.find(|entry| entry.result.id == id)
|
||||
.map(|entry| entry.app_id.clone())
|
||||
}) {
|
||||
// Explorer invokes the registered Windows application ID. It is a
|
||||
// single argument from the catalog, never frontend command text.
|
||||
std::process::Command::new("explorer.exe")
|
||||
.arg(format!("shell:AppsFolder\\{app_id}"))
|
||||
.spawn()
|
||||
.map_err(|error| format!("Could not launch application: {error}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
let entry = self
|
||||
.apps
|
||||
.iter()
|
||||
.find(|entry| entry.result.id == id)
|
||||
.ok_or_else(|| "Application is not in the local catalog.".to_owned())?;
|
||||
if !entry.path.exists() {
|
||||
return Err(
|
||||
"Application is no longer installed. Restart searchi to refresh applications."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// GIO implements the freedesktop Desktop Entry launch semantics,
|
||||
// including quoting/field codes. Never interpret Exec as shell text.
|
||||
let status = std::process::Command::new("gio")
|
||||
.arg("launch")
|
||||
.arg(&entry.path)
|
||||
.status()
|
||||
.map_err(|error| format!("Could not launch application using GIO: {error}"))?;
|
||||
if !status.success() {
|
||||
return Err("The operating system could not launch this application.".into());
|
||||
}
|
||||
let _ = app;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
app.opener()
|
||||
.open_path(entry.path.to_string_lossy().into_owned(), None::<&str>)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|character| !character.is_whitespace())
|
||||
.collect()
|
||||
}
|
||||
fn name_match_rank(
|
||||
name: &str,
|
||||
compact: &str,
|
||||
query: &str,
|
||||
compact_query: &str,
|
||||
words: &[&str],
|
||||
) -> Option<u8> {
|
||||
if name == query {
|
||||
Some(0)
|
||||
} else if name.starts_with(query) {
|
||||
Some(1)
|
||||
} else if name.contains(query) {
|
||||
Some(2)
|
||||
} else if words.iter().all(|word| name.contains(word)) {
|
||||
Some(3)
|
||||
} else if compact.contains(compact_query)
|
||||
|| (compact.starts_with("visualstudiocode") && compact_query == "vscode")
|
||||
{
|
||||
Some(4)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn application_roots() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
return ["APPDATA", "PROGRAMDATA"]
|
||||
.into_iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.map(|root| PathBuf::from(root).join("Microsoft/Windows/Start Menu/Programs"))
|
||||
.collect();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut roots = vec![
|
||||
PathBuf::from("/Applications"),
|
||||
PathBuf::from("/System/Applications"),
|
||||
];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
roots.insert(0, PathBuf::from(home).join("Applications"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
let mut roots = Vec::new();
|
||||
if let Some(home) = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
|
||||
})
|
||||
{
|
||||
roots.push(home.join("applications"));
|
||||
}
|
||||
let directories = std::env::var_os("XDG_DATA_DIRS")
|
||||
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
roots.extend(std::env::split_paths(&directories).map(|root| root.join("applications")));
|
||||
roots
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct RegisteredApplication {
|
||||
result: ApplicationResult,
|
||||
normalized_name: String,
|
||||
compact_name: String,
|
||||
app_id: String,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn registered_applications() -> Option<Vec<RegisteredApplication>> {
|
||||
use std::{
|
||||
io::{Read, Seek, SeekFrom},
|
||||
os::windows::process::CommandExt,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Entry {
|
||||
#[serde(rename = "Name")]
|
||||
name: String,
|
||||
#[serde(rename = "AppID")]
|
||||
app_id: String,
|
||||
}
|
||||
let mut output = tempfile::tempfile().ok()?;
|
||||
let script = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-StartApps | Select-Object -First 4096 Name,AppID | ConvertTo-Json -Compress";
|
||||
let mut child = std::process::Command::new("powershell.exe")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.creation_flags(0x08000000)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(output.try_clone().ok()?)
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => break,
|
||||
Ok(Some(_)) => return None,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if output.metadata().ok()?.len() > 4 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
output.seek(SeekFrom::Start(0)).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
output.read_to_end(&mut bytes).ok()?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
let values = match value {
|
||||
serde_json::Value::Array(values) => values,
|
||||
value => vec![value],
|
||||
};
|
||||
Some(
|
||||
values
|
||||
.into_iter()
|
||||
.take(MAX_APPS)
|
||||
.filter_map(|value| {
|
||||
let entry: Entry = serde_json::from_value(value).ok()?;
|
||||
if !valid_registered_id(&entry.app_id)
|
||||
|| entry.name.trim().is_empty()
|
||||
|| entry.name.len() > 512
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let normalized_name = entry.name.to_lowercase();
|
||||
let id = format!(
|
||||
"app:registered:{:x}",
|
||||
Sha256::digest(entry.app_id.as_bytes())
|
||||
);
|
||||
Some(RegisteredApplication {
|
||||
result: ApplicationResult {
|
||||
id,
|
||||
name: entry.name,
|
||||
},
|
||||
compact_name: compact_name(&normalized_name),
|
||||
normalized_name,
|
||||
app_id: entry.app_id,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn valid_registered_id(id: &str) -> bool {
|
||||
id.len() <= 512
|
||||
&& id.contains('!')
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._-!".contains(&byte))
|
||||
}
|
||||
fn is_app_bundle(path: &Path) -> bool {
|
||||
cfg!(target_os = "macos") && extension_is(path, "app")
|
||||
}
|
||||
fn extension_is(path: &Path, expected: &str) -> bool {
|
||||
path.extension()
|
||||
.is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case(expected))
|
||||
}
|
||||
fn application_name(path: &Path) -> Option<String> {
|
||||
#[cfg(windows)]
|
||||
if extension_is(path, "lnk") {
|
||||
return path
|
||||
.file_stem()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if is_app_bundle(path) {
|
||||
return path
|
||||
.file_stem()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
if extension_is(path, "desktop") {
|
||||
if fs::metadata(path).ok()?.len() > 128 * 1024 {
|
||||
return None;
|
||||
}
|
||||
return desktop_name(&fs::read_to_string(path).ok()?);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn desktop_name(contents: &str) -> Option<String> {
|
||||
let mut in_entry = false;
|
||||
let mut name = None;
|
||||
let mut application = false;
|
||||
let mut executable = false;
|
||||
for line in contents.lines().map(str::trim) {
|
||||
if line.starts_with('[') {
|
||||
in_entry = line == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if !in_entry || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"Hidden" | "NoDisplay" if value == "true" => return None,
|
||||
"Type" => application = value == "Application",
|
||||
"Exec" => executable = !value.trim().is_empty(),
|
||||
"Name" => name = Some(value.trim().to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let name = name?;
|
||||
(application && executable && !name.is_empty() && name.len() <= 512).then_some(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn registered_ids_reject_paths_and_command_text() {
|
||||
assert!(valid_registered_id(
|
||||
"Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
|
||||
));
|
||||
for value in [
|
||||
"C:\\program.exe",
|
||||
"app!id & bad",
|
||||
"app!id/other",
|
||||
"",
|
||||
"app!id\n",
|
||||
] {
|
||||
assert!(!valid_registered_id(value));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
#[ignore = "Reads the actual Windows registered-app catalog; launches nothing"]
|
||||
fn real_windows_application_catalog_is_searchable() {
|
||||
let catalog = ApplicationCatalog::discover();
|
||||
assert!(
|
||||
!catalog.apps.is_empty(),
|
||||
"Expected installed desktop shortcuts"
|
||||
);
|
||||
let apps = registered_applications().expect("Windows Get-StartApps inventory");
|
||||
assert!(!apps.is_empty(), "Expected registered Windows apps");
|
||||
let query = apps[0].result.name.clone();
|
||||
*catalog.registered.lock().unwrap() = apps;
|
||||
assert!(!catalog.search(&query, 10).is_empty());
|
||||
eprintln!(
|
||||
"Native shortcuts: {}; registered applications: {}",
|
||||
catalog.apps.len(),
|
||||
catalog.registered.lock().unwrap().len()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn desktop_entries_require_visible_executable_applications() {
|
||||
let valid = "[Desktop Entry]\nType=Application\nName=Text Editor\nExec=editor %F\n";
|
||||
assert_eq!(desktop_name(valid).as_deref(), Some("Text Editor"));
|
||||
for invalid in [
|
||||
format!("{valid}Hidden=true\n"),
|
||||
format!("{valid}NoDisplay=true\n"),
|
||||
"[Other]\nType=Application\nName=Fake\nExec=bad\n".into(),
|
||||
"[Desktop Entry]\nType=Link\nName=Website\nURL=https://example.org\n".into(),
|
||||
] {
|
||||
assert!(desktop_name(&invalid).is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn names_match_without_exposing_launch_paths() {
|
||||
let catalog = ApplicationCatalog {
|
||||
#[cfg(windows)]
|
||||
registered: Default::default(),
|
||||
apps: ["Calculator", "Visual Studio Code", "Code"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Application {
|
||||
result: ApplicationResult {
|
||||
id: format!("app:{i}"),
|
||||
name: name.into(),
|
||||
},
|
||||
normalized_name: name.to_lowercase(),
|
||||
compact_name: compact_name(&name.to_lowercase()),
|
||||
path: PathBuf::from("private"),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
assert_eq!(catalog.search("CODE", 10)[0].name, "Code");
|
||||
assert_eq!(catalog.search("VS Code", 10)[0].name, "Visual Studio Code");
|
||||
assert_eq!(catalog.search("vscode", 10)[0].name, "Visual Studio Code");
|
||||
assert_eq!(
|
||||
catalog.search("visualstudiocode", 10)[0].name,
|
||||
"Visual Studio Code"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.search("visual code", 10)[0].name,
|
||||
"Visual Studio Code"
|
||||
);
|
||||
assert!(catalog.search("", 10).is_empty());
|
||||
assert!(catalog.search("not installed", 10).is_empty());
|
||||
assert!(catalog.search("code", 0).is_empty());
|
||||
assert!(!serde_json::to_string(&catalog.search("code", 10))
|
||||
.unwrap()
|
||||
.contains("private"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Platform discovery for automatic local search. No network shares are mounted.
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn roots(custom: &[PathBuf], enabled: bool) -> Vec<PathBuf> {
|
||||
let mut candidates = custom.to_vec();
|
||||
if enabled && std::env::var("SEARCHI_DISABLE_AUTOMATIC_INDEXING").as_deref() != Ok("1") {
|
||||
candidates.extend(local_roots());
|
||||
}
|
||||
let mut normalized: Vec<PathBuf> = candidates
|
||||
.into_iter()
|
||||
.filter_map(|path| std::fs::canonicalize(path).ok())
|
||||
.filter(|path| path.is_dir())
|
||||
.collect();
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
let mut result: Vec<PathBuf> = Vec::new();
|
||||
for path in normalized {
|
||||
if !result.iter().any(|parent| path.starts_with(parent)) {
|
||||
result.push(path);
|
||||
}
|
||||
}
|
||||
result.truncate(64);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn local_roots() -> Vec<PathBuf> {
|
||||
use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives};
|
||||
let mask = unsafe { GetLogicalDrives() };
|
||||
(0..26)
|
||||
.filter_map(|letter| {
|
||||
if mask & (1 << letter) == 0 {
|
||||
return None;
|
||||
}
|
||||
let path = format!("{}:\\", char::from(b'A' + letter));
|
||||
let wide: Vec<u16> = path.encode_utf16().chain(Some(0)).collect();
|
||||
// DRIVE_FIXED=3; DRIVE_REMOVABLE=2. Skip network, optical and absent media.
|
||||
matches!(unsafe { GetDriveTypeW(wide.as_ptr()) }, 2 | 3).then(|| PathBuf::from(path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn local_roots() -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/")]
|
||||
}
|
||||
|
||||
pub fn exclusions() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
let mut paths: Vec<PathBuf> = ["WINDIR", "ProgramFiles", "ProgramFiles(x86)", "ProgramData"]
|
||||
.iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
#[cfg(not(windows))]
|
||||
let mut paths: Vec<PathBuf> = [
|
||||
"/proc", "/sys", "/dev", "/run", "/tmp", "/var", "/usr", "/etc", "/boot", "/snap",
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
#[cfg(windows)]
|
||||
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
|
||||
paths.push(PathBuf::from(local).join("Programs"));
|
||||
}
|
||||
if let Some(temp) = std::env::temp_dir().is_absolute().then(std::env::temp_dir) {
|
||||
paths.push(temp);
|
||||
}
|
||||
paths
|
||||
.into_iter()
|
||||
.filter_map(|path| super::data_directory::canonical_exclusion(&path).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub const EXCLUDED_DIRECTORY_NAMES: &[&str] = &[
|
||||
".git",
|
||||
"node_modules",
|
||||
"target",
|
||||
".searchi",
|
||||
".cache",
|
||||
".cargo",
|
||||
".rustup",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"Cache",
|
||||
"Caches",
|
||||
"CachedData",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"$RECYCLE.BIN",
|
||||
"System Volume Information",
|
||||
".ssh",
|
||||
".gnupg",
|
||||
".codex",
|
||||
".agents",
|
||||
".aws",
|
||||
".azure",
|
||||
".kube",
|
||||
"secrets",
|
||||
".secrets",
|
||||
];
|
||||
|
||||
pub fn excluded_names() -> Vec<String> {
|
||||
EXCLUDED_DIRECTORY_NAMES
|
||||
.iter()
|
||||
.map(|name| (*name).to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Commit common personal folders separately before the first whole-volume
|
||||
/// reconciliation so keyword search becomes useful while the larger walk runs.
|
||||
pub fn first_use_scopes(root: &std::path::Path) -> Vec<PathBuf> {
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
|
||||
.and_then(|value| std::fs::canonicalize(PathBuf::from(value)).ok());
|
||||
let Some(home) = home.filter(|home| home.starts_with(root)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
[
|
||||
"Desktop",
|
||||
"Documents",
|
||||
"Downloads",
|
||||
"Pictures",
|
||||
"Music",
|
||||
"Videos",
|
||||
]
|
||||
.iter()
|
||||
.map(|name| home.join(name))
|
||||
.filter(|path| path.is_dir())
|
||||
.filter_map(|path| path.canonicalize().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn custom_roots_are_canonical_nonoverlapping_and_missing_roots_are_skipped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let child = dir.path().join("child");
|
||||
std::fs::create_dir(&child).unwrap();
|
||||
assert_eq!(
|
||||
roots(
|
||||
&[child, dir.path().to_owned(), dir.path().join("absent")],
|
||||
false
|
||||
),
|
||||
vec![dir.path().canonicalize().unwrap()]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn exclusions_do_not_exclude_user_documents_by_name() {
|
||||
let names = excluded_names();
|
||||
assert!(names.iter().any(|name| name == "Cache"));
|
||||
assert!(!names
|
||||
.iter()
|
||||
.any(|name| name == "Documents" || name == "AppData"));
|
||||
assert!(exclusions().iter().all(|path| path.is_absolute()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_scan_keeps_documents_and_prunes_credential_stores() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("Documents")).unwrap();
|
||||
std::fs::create_dir(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Documents/notes.txt"),
|
||||
"visible fixture document",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("secrets/credentials.json"),
|
||||
"{\"test_only\":true}",
|
||||
)
|
||||
.unwrap();
|
||||
let db = tempfile::tempdir().unwrap();
|
||||
let mut index = searchi_core::SearchIndex::open(db.path().join("index.sqlite")).unwrap();
|
||||
index
|
||||
.index_folder_with_options(
|
||||
dir.path(),
|
||||
&searchi_core::IndexOptions {
|
||||
excluded_directory_names: excluded_names(),
|
||||
excluded_paths: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(index.stats().unwrap().files, 1);
|
||||
assert!(!index.search("notes.txt", 10).unwrap().is_empty());
|
||||
assert!(index.search("credentials.json", 10).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,61 @@ pub fn hide_search(app: tauri::AppHandle) -> Result<(), String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// A launcher grows only when it has content, staying inside its current display.
|
||||
#[tauri::command]
|
||||
pub fn set_search_layout(app: tauri::AppHandle, expanded: bool) -> Result<(), String> {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "Search window is unavailable.".to_owned())?;
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.map_err(|error| error.to_string())?
|
||||
.or(window
|
||||
.primary_monitor()
|
||||
.map_err(|error| error.to_string())?);
|
||||
let (width, height) = if expanded {
|
||||
(1080.0, 700.0)
|
||||
} else {
|
||||
(720.0, 110.0)
|
||||
};
|
||||
if let Some(monitor) = monitor {
|
||||
let scale = monitor.scale_factor();
|
||||
let area = monitor.work_area();
|
||||
let size = &area.size;
|
||||
let width = (width * scale)
|
||||
.min(f64::from(size.width).max(80.0) - 48.0)
|
||||
.max(32.0) as u32;
|
||||
let height = (height * scale)
|
||||
.min(f64::from(size.height).max(80.0) - 80.0)
|
||||
.max(32.0) as u32;
|
||||
window
|
||||
.set_size(tauri::PhysicalSize::new(width, height))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let origin = &area.position;
|
||||
window
|
||||
.set_position(tauri::PhysicalPosition::new(
|
||||
origin.x + ((size.width - width) / 2) as i32,
|
||||
origin.y + ((size.height - height) / 2) as i32,
|
||||
))
|
||||
.map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
window
|
||||
.set_size(tauri::LogicalSize::new(width, height))
|
||||
.map_err(|error| error.to_string())?;
|
||||
window.center().map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dragging is limited to our launcher; no general window access is exposed.
|
||||
#[tauri::command]
|
||||
pub fn drag_search(app: tauri::AppHandle) -> Result<(), String> {
|
||||
app.get_webview_window("main")
|
||||
.ok_or_else(|| "Search window is unavailable.".to_owned())?
|
||||
.start_dragging()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,6 +5,8 @@ compile_error!("Release desktop builds require --features custom-protocol to emb
|
||||
|
||||
mod ai_commands;
|
||||
mod appearance;
|
||||
mod applications;
|
||||
mod automatic_indexing;
|
||||
mod data_directory;
|
||||
mod instance;
|
||||
mod media_preview;
|
||||
@@ -36,6 +38,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
struct AppState {
|
||||
applications: applications::ApplicationCatalog,
|
||||
appearance: Arc<Mutex<appearance::AppearanceStatus>>,
|
||||
ai: Arc<AiSession>,
|
||||
database: PathBuf,
|
||||
@@ -119,6 +122,38 @@ fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn search_applications(
|
||||
query: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Vec<applications::ApplicationResult> {
|
||||
state.applications.search(&query, 20)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn launch_application(
|
||||
id: String,
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.applications.launch(&app, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_automatic_indexing(enabled: bool, state: State<'_, AppState>) -> Result<Settings, String> {
|
||||
let mut store = state
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings are unavailable.")?;
|
||||
let mut next = store.value.clone();
|
||||
next.automatic_indexing = enabled;
|
||||
store.save(next)?;
|
||||
state
|
||||
.watching
|
||||
.replace_roots(automatic_indexing::roots(&store.value.roots, enabled))?;
|
||||
Ok(store.value.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
|
||||
if state.indexing.load(Ordering::Acquire) {
|
||||
@@ -512,6 +547,21 @@ fn start_folder_watching(
|
||||
let result = (|| -> Result<bool, String> {
|
||||
let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?;
|
||||
index.set_document_worker(document_worker.clone());
|
||||
if request.paths.is_none()
|
||||
&& index.stats().map_err(|error| error.to_string())?.files == 0
|
||||
{
|
||||
for scope in automatic_indexing::first_use_scopes(&request.root) {
|
||||
index
|
||||
.index_paths_cancellable(
|
||||
&request.root,
|
||||
&[scope],
|
||||
&options,
|
||||
&request.cancelled,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let _ = app.emit("search-index-changed", ());
|
||||
}
|
||||
}
|
||||
let report = match request.paths {
|
||||
Some(paths) => index.index_paths_cancellable(
|
||||
&request.root,
|
||||
@@ -613,6 +663,9 @@ fn main() {
|
||||
"searchi-extract-worker"
|
||||
}));
|
||||
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?));
|
||||
// Install semantic schema before automatic scans acquire the writer.
|
||||
// Query/model preparation can then open current stores as readers.
|
||||
searchi_service::HybridSearch::prepare_database(&database)?;
|
||||
let warm_database = database.clone();
|
||||
let warm_target = lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -676,20 +729,21 @@ fn main() {
|
||||
}
|
||||
});
|
||||
let mut ignored = vec![directory.clone(), model_directory.clone()];
|
||||
ignored.extend(automatic_indexing::exclusions());
|
||||
if let Some(parent) = speech_worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); }
|
||||
if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) {
|
||||
ignored.push(parent.to_path_buf());
|
||||
}
|
||||
let index_options = searchi_core::IndexOptions {
|
||||
excluded_directory_names: automatic_indexing::excluded_names(),
|
||||
excluded_paths: ignored.iter()
|
||||
.map(|path| data_directory::canonical_exclusion(path))
|
||||
.collect::<std::io::Result<Vec<_>>>()?,
|
||||
..Default::default()
|
||||
};
|
||||
let indexing = Arc::new(AtomicBool::new(false));
|
||||
let watching = Arc::new(start_folder_watching(
|
||||
app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(),
|
||||
settings.value.roots.clone(), index_options.clone(), document_worker.clone(),
|
||||
automatic_indexing::roots(&settings.value.roots, settings.value.automatic_indexing), index_options.clone(), document_worker.clone(),
|
||||
).map_err(std::io::Error::other)?);
|
||||
let semantic = Arc::new(
|
||||
SemanticState::new(model_directory, worker, media_tools)
|
||||
@@ -715,6 +769,7 @@ speech_worker, directory.join("SpeechModels")),
|
||||
let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?;
|
||||
let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false);
|
||||
app.manage(AppState {
|
||||
applications: applications::ApplicationCatalog::discover(),
|
||||
appearance: Arc::new(Mutex::new(appearance)),
|
||||
ai: Arc::new(AiSession::default()),
|
||||
database,
|
||||
@@ -749,6 +804,11 @@ speech_worker, directory.join("SpeechModels")),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
instance::hide_search,
|
||||
instance::set_search_layout,
|
||||
instance::drag_search,
|
||||
search_applications,
|
||||
launch_application,
|
||||
set_automatic_indexing,
|
||||
prepare_ai_source,
|
||||
generate_ai,
|
||||
search,
|
||||
|
||||
@@ -23,10 +23,16 @@ fn default_background_opacity() -> u8 {
|
||||
const SETTINGS_VERSION: u32 = 2;
|
||||
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
||||
|
||||
fn default_automatic_indexing() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub version: u32,
|
||||
#[serde(default = "default_automatic_indexing")]
|
||||
pub automatic_indexing: bool,
|
||||
#[serde(default)]
|
||||
pub background_material: BackgroundMaterial,
|
||||
#[serde(default = "default_background_opacity")]
|
||||
@@ -43,6 +49,7 @@ impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SETTINGS_VERSION,
|
||||
automatic_indexing: true,
|
||||
background_material: BackgroundMaterial::default(),
|
||||
background_opacity: default_background_opacity(),
|
||||
roots: Vec::new(),
|
||||
@@ -57,8 +64,8 @@ impl Settings {
|
||||
if self.version != SETTINGS_VERSION {
|
||||
return Err(format!("Unsupported settings version {}", self.version));
|
||||
}
|
||||
if !(70..=100).contains(&self.background_opacity) {
|
||||
return Err("Background opacity must be between 70 and 100 percent.".into());
|
||||
if !(40..=100).contains(&self.background_opacity) {
|
||||
return Err("Background opacity must be between 40 and 100 percent.".into());
|
||||
}
|
||||
if !(1..=8).contains(&self.cpu_threads) {
|
||||
return Err("CPU threads must be between 1 and 8.".into());
|
||||
@@ -265,11 +272,11 @@ mod appearance_tests {
|
||||
assert_eq!(store.value.background_opacity, 90);
|
||||
let mut next = store.value.clone();
|
||||
next.background_material = BackgroundMaterial::Glass;
|
||||
next.background_opacity = 70;
|
||||
next.background_opacity = 40;
|
||||
store.save(next.clone()).unwrap();
|
||||
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
for opacity in [0, 69, 101, 255] {
|
||||
for opacity in [0, 39, 101, 255] {
|
||||
let mut invalid = next.clone();
|
||||
invalid.background_opacity = opacity;
|
||||
assert!(store.save(invalid).is_err());
|
||||
|
||||
@@ -243,10 +243,9 @@ fn validate(roots: &[PathBuf]) -> Result<(), String> {
|
||||
fn excluded(path: &Path, shared: &Shared) -> bool {
|
||||
shared.ignored.iter().any(|p| path.starts_with(p))
|
||||
|| path.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some(".git" | "node_modules" | "target")
|
||||
)
|
||||
super::automatic_indexing::EXCLUDED_DIRECTORY_NAMES
|
||||
.iter()
|
||||
.any(|name| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
|
||||
})
|
||||
}
|
||||
fn hint(shared: &Shared, event: notify::Result<Event>) {
|
||||
@@ -465,6 +464,51 @@ fn run(shared: Arc<Shared>, callback: impl Fn(ScanRequest) -> ScanOutcome) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
#[test]
|
||||
fn excluded_cache_events_do_not_dirty_root_but_documents_do() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let mut root = Root::new(Instant::now());
|
||||
root.full = false;
|
||||
root.first = None;
|
||||
let shared = Shared {
|
||||
state: Mutex::new(State {
|
||||
roots: BTreeMap::from([(path.clone(), root)]),
|
||||
stop: false,
|
||||
}),
|
||||
changed: Condvar::new(),
|
||||
ignored: Vec::new(),
|
||||
timing: Timing::default(),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
for relative in [
|
||||
".cargo/registry/change.bin",
|
||||
"Cache/change.bin",
|
||||
"cAcHe/change.bin",
|
||||
] {
|
||||
hint(
|
||||
&shared,
|
||||
Ok(Event::new(EventKind::Any).add_path(path.join(relative))),
|
||||
);
|
||||
let state = shared.state.lock().unwrap();
|
||||
let root = &state.roots[&path];
|
||||
assert!(root.first.is_none());
|
||||
assert!(root.paths.is_empty());
|
||||
assert!(!root.media_hint);
|
||||
assert!(!root.full);
|
||||
}
|
||||
let document = path.join("Documents/change.txt");
|
||||
hint(
|
||||
&shared,
|
||||
Ok(Event::new(EventKind::Any).add_path(document.clone())),
|
||||
);
|
||||
let state = shared.state.lock().unwrap();
|
||||
let root = &state.roots[&path];
|
||||
assert!(root.first.is_some());
|
||||
assert_eq!(root.paths, BTreeSet::from([document]));
|
||||
assert!(root.media_hint);
|
||||
assert!(!root.full);
|
||||
}
|
||||
fn fast() -> Timing {
|
||||
Timing {
|
||||
debounce: Duration::from_millis(40),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "searchi",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"identifier": "dev.searchi.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,10 +13,15 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "searchi",
|
||||
"width": 1080,
|
||||
"height": 760,
|
||||
"minWidth": 680,
|
||||
"minHeight": 540
|
||||
"width": 720,
|
||||
"height": 110,
|
||||
"center": true,
|
||||
"decorations": false,
|
||||
"resizable": false,
|
||||
"transparent": true,
|
||||
"shadow": true,
|
||||
"skipTaskbar": true,
|
||||
"alwaysOnTop": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function appearanceView(
|
||||
const material: BackgroundMaterial =
|
||||
!highContrast && valid ? status.effective_material : "solid";
|
||||
const opacity = Number.isFinite(status?.opacity)
|
||||
? Math.max(70, Math.min(100, status!.opacity))
|
||||
? Math.max(40, Math.min(100, status!.opacity))
|
||||
: 90;
|
||||
const message = highContrast
|
||||
? "High contrast uses a solid background. Your appearance preference is retained."
|
||||
|
||||
+271
-13
@@ -88,6 +88,7 @@ interface Settings {
|
||||
version: number;
|
||||
roots: string[];
|
||||
cpu_threads: number;
|
||||
automatic_indexing?: boolean;
|
||||
background_material?: BackgroundMaterial;
|
||||
background_opacity?: number;
|
||||
}
|
||||
@@ -160,19 +161,86 @@ interface SearchRequest {
|
||||
}
|
||||
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
||||
<header id="launcher-header"><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background opacity</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="40" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><label class="performance-toggle"><input id="automatic-indexing" type="checkbox" checked>Search my device automatically</label><p class="hint">Your local drives are indexed in the background. Existing results remain searchable when this is off.</p><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details><button id="setup-details" class="icon-button" type="button" aria-label="Local search setup" title="Local search setup">${icon("sparkles")}</button><button id="hide-launcher" class="icon-button" type="button" aria-label="Hide searchi" title="Hide searchi">${icon("dismiss")}</button></div></header>
|
||||
<main>
|
||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
||||
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><p id="speech-status" class="hint" role="status" hidden></p><progress id="speech-progress" aria-label="Local speech model setup progress" hidden></progress><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
||||
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
|
||||
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
|
||||
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
|
||||
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="application-results" aria-label="Applications" hidden></ul><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Your device is indexed in the background. Search an application, filename, phrase, or what you remember.</p></div></section><aside id="application-preview" aria-label="Application preview" hidden><div class="application-detail"><img src="/searchi-symbol.png" alt="" width="48" height="48"><h2 id="application-name"></h2><p class="hint">Installed application</p><button id="launch-application" type="button">${icon("open")} Launch application</button></div></aside><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
|
||||
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>${isNativeDesktop() ? "Hide" : "Clear"}</span></span></footer>`;
|
||||
|
||||
const element = <T extends HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
const query = element<HTMLInputElement>("query");
|
||||
const list = element<HTMLUListElement>("results");
|
||||
const notice = element<HTMLParagraphElement>("notice");
|
||||
// Window sizing never delays a search request. Serialize changes so rapid typing
|
||||
// cannot leave the native window in an older compact/expanded state.
|
||||
let requestedExpansion = false;
|
||||
let nativeExpansion: boolean | undefined;
|
||||
let resizingLauncher = false;
|
||||
function syncLauncher() {
|
||||
let filtered = false;
|
||||
try {
|
||||
filtered = hasSearchInput(query.value, captureFilters());
|
||||
} catch {
|
||||
/* Invalid filters stay editable. */
|
||||
}
|
||||
requestedExpansion =
|
||||
filtered ||
|
||||
document.querySelector(".popover[open]") !== null ||
|
||||
element<HTMLDetailsElement>("semantic-panel").open ||
|
||||
!element("folder-form").hidden;
|
||||
document.documentElement.dataset.launcherLayout = requestedExpansion
|
||||
? "expanded"
|
||||
: "compact";
|
||||
if (isNativeDesktop() && !resizingLauncher) void resizeLauncher();
|
||||
}
|
||||
async function resizeLauncher() {
|
||||
resizingLauncher = true;
|
||||
try {
|
||||
while (nativeExpansion !== requestedExpansion) {
|
||||
const expanded = requestedExpansion;
|
||||
await invoke("set_search_layout", { expanded });
|
||||
nativeExpansion = expanded;
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
resizingLauncher = false;
|
||||
}
|
||||
}
|
||||
element("launcher-header").addEventListener("pointerdown", (event) => {
|
||||
if (
|
||||
isNativeDesktop() &&
|
||||
event.button === 0 &&
|
||||
!(event.target as Element).closest("button,summary,input,select,details")
|
||||
) {
|
||||
void invoke("drag_search").catch(showError);
|
||||
}
|
||||
});
|
||||
element("hide-launcher").addEventListener("click", () => {
|
||||
if (isNativeDesktop()) void invoke("hide_search").catch(showError);
|
||||
else {
|
||||
query.value = "";
|
||||
void search();
|
||||
query.focus();
|
||||
}
|
||||
});
|
||||
element("setup-details").addEventListener("click", () => {
|
||||
const panel = element<HTMLDetailsElement>("semantic-panel");
|
||||
panel.open = !panel.open;
|
||||
});
|
||||
document
|
||||
.querySelectorAll<HTMLDetailsElement>(".popover, #semantic-panel")
|
||||
.forEach((panel) => {
|
||||
panel.addEventListener("toggle", syncLauncher);
|
||||
});
|
||||
new MutationObserver(syncLauncher).observe(element("folder-form"), {
|
||||
attributes: true,
|
||||
attributeFilter: ["hidden"],
|
||||
});
|
||||
|
||||
try {
|
||||
element<HTMLInputElement>("performance-toggle").checked =
|
||||
localStorage.getItem("searchi-performance-details") === "true";
|
||||
@@ -180,6 +248,130 @@ try {
|
||||
/* Diagnostics remain opt-in when storage is unavailable. */
|
||||
}
|
||||
let results: SearchResult[] = [];
|
||||
interface ApplicationResult {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
let applications: ApplicationResult[] = [];
|
||||
let selectedApplication = -1;
|
||||
let applicationRevision = 0;
|
||||
let settledApplicationRevision = -1;
|
||||
let applicationQuery = "";
|
||||
function selectApplication(index: number, focus = false) {
|
||||
if (!applications.length) return;
|
||||
selectedApplication = Math.max(0, Math.min(index, applications.length - 1));
|
||||
const app = applications[selectedApplication];
|
||||
clearMedia();
|
||||
element("preview").hidden = true;
|
||||
element("application-preview").hidden = false;
|
||||
element("application-name").textContent = app.name;
|
||||
list.querySelectorAll("button").forEach((button) => {
|
||||
button.classList.remove("selected");
|
||||
button.setAttribute("aria-current", "false");
|
||||
});
|
||||
element("application-results")
|
||||
.querySelectorAll<HTMLButtonElement>("button")
|
||||
.forEach((button, i) => {
|
||||
button.classList.toggle("selected", i === selectedApplication);
|
||||
button.setAttribute("aria-current", String(i === selectedApplication));
|
||||
button.tabIndex = i === selectedApplication ? 0 : -1;
|
||||
if (focus && i === selectedApplication) button.focus();
|
||||
});
|
||||
}
|
||||
async function launchApplication() {
|
||||
const app = applications[selectedApplication];
|
||||
if (
|
||||
!app ||
|
||||
!isNativeDesktop() ||
|
||||
applicationQuery !== query.value.trim() ||
|
||||
settledApplicationRevision !== applicationRevision
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await invoke("launch_application", { id: app.id });
|
||||
await invoke("hide_search");
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
async function refreshApplications() {
|
||||
if (!isNativeDesktop()) return;
|
||||
const value = query.value.trim();
|
||||
const revision = ++applicationRevision;
|
||||
const changed = value !== applicationQuery;
|
||||
applicationQuery = value;
|
||||
if (changed) selectedApplication = -1;
|
||||
let filtersActive = false;
|
||||
try {
|
||||
const filters = captureFilters();
|
||||
filtersActive = Boolean(filters.code_only) || hasSearchInput("", filters);
|
||||
} catch {
|
||||
filtersActive = true;
|
||||
}
|
||||
const found =
|
||||
value && !filtersActive
|
||||
? await invoke<ApplicationResult[]>("search_applications", {
|
||||
query: value,
|
||||
})
|
||||
: [];
|
||||
if (revision !== applicationRevision || query.value.trim() !== value) return;
|
||||
settledApplicationRevision = revision;
|
||||
applications = found;
|
||||
const appList = element("application-results");
|
||||
appList.replaceChildren();
|
||||
appList.hidden = !found.length;
|
||||
found.forEach((app, i) => {
|
||||
const row = document.createElement("li");
|
||||
const button = document.createElement("button");
|
||||
button.className = "application-result";
|
||||
button.type = "button";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = app.name;
|
||||
const kind = document.createElement("span");
|
||||
kind.className = "kind";
|
||||
kind.textContent = "Application";
|
||||
button.append(label, kind);
|
||||
button.addEventListener("click", () => selectApplication(i));
|
||||
button.addEventListener("dblclick", () => {
|
||||
selectApplication(i);
|
||||
void launchApplication();
|
||||
});
|
||||
row.append(button);
|
||||
appList.append(row);
|
||||
});
|
||||
if (found.length && (changed || selectedApplication >= 0))
|
||||
selectApplication(selectedApplication < 0 ? 0 : selectedApplication);
|
||||
else if (!found.length) {
|
||||
selectedApplication = -1;
|
||||
element("application-preview").hidden = true;
|
||||
}
|
||||
element("empty").hidden = Boolean(found.length || results.length);
|
||||
const summary = element("result-summary");
|
||||
const fileSummary = (summary.textContent ?? "").replace(
|
||||
/^\d+ APPLICATIONS? � /,
|
||||
"",
|
||||
);
|
||||
summary.textContent = found.length
|
||||
? `${found.length} APPLICATION${found.length === 1 ? "" : "S"} � ${fileSummary}`
|
||||
: fileSummary;
|
||||
}
|
||||
element("launch-application").addEventListener(
|
||||
"click",
|
||||
() => void launchApplication(),
|
||||
);
|
||||
element("automatic-indexing").addEventListener("change", async () => {
|
||||
const checkbox = element<HTMLInputElement>("automatic-indexing");
|
||||
checkbox.disabled = true;
|
||||
try {
|
||||
await invoke("set_automatic_indexing", { enabled: checkbox.checked });
|
||||
await refreshDesktopStatus();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
checkbox.disabled = !isNativeDesktop();
|
||||
}
|
||||
});
|
||||
|
||||
let displayedLiteralQuery = literalQuery("");
|
||||
let displayedCodeScope = false;
|
||||
let explainedSelection = "";
|
||||
@@ -318,7 +510,13 @@ const resultRows = new Map<
|
||||
const automaticIndex = new AutomaticIndexGate();
|
||||
|
||||
function refreshEmptySearchState() {
|
||||
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
|
||||
if (
|
||||
!latestSearch ||
|
||||
latestSearch.id !== requestId ||
|
||||
results.length ||
|
||||
applications.length
|
||||
)
|
||||
return;
|
||||
if (!latestSearch.query.trim()) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"No matching music tags.";
|
||||
@@ -439,7 +637,8 @@ async function refreshSemanticStatus() {
|
||||
else if (semanticAvailable(status) && status.chunks > 0)
|
||||
message = `Semantic search ready · ${status.chunks.toLocaleString()} content segments${status.stale ? " · Updates pending" : ""}${status.media_failures?.length ? ` · ${status.media_failures.length} files could not be indexed` : ""}`;
|
||||
else if (status.ready)
|
||||
message = "Google model ready. Index a folder to search by meaning.";
|
||||
message =
|
||||
"Google model ready. Your files become searchable by meaning as background indexing completes.";
|
||||
else
|
||||
message =
|
||||
"Preparing local semantic search… Keyword search remains available.";
|
||||
@@ -592,8 +791,15 @@ element("background-material").addEventListener("change", () => {
|
||||
void saveAppearance();
|
||||
});
|
||||
element("background-opacity").addEventListener("input", () => {
|
||||
element("background-opacity-value").textContent =
|
||||
`${element<HTMLInputElement>("background-opacity").value}%`;
|
||||
const opacity = element<HTMLInputElement>("background-opacity").value;
|
||||
element("background-opacity-value").textContent = `${opacity}%`;
|
||||
// Preview only an already confirmed compositor material. Persist on release.
|
||||
if (!highContrast.matches && lastAppearance?.native_effect) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--window-opacity",
|
||||
String(Number(opacity) / 100),
|
||||
);
|
||||
}
|
||||
});
|
||||
element("background-opacity").addEventListener("change", () => {
|
||||
void saveAppearance();
|
||||
@@ -616,6 +822,9 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
restoreAppearanceControls,
|
||||
);
|
||||
indexingActive = status.indexing;
|
||||
element<HTMLInputElement>("automatic-indexing").checked =
|
||||
status.settings.automatic_indexing ?? true;
|
||||
element<HTMLInputElement>("automatic-indexing").disabled = !isNativeDesktop();
|
||||
element("shortcut-status").textContent = status.shortcut_status;
|
||||
element("focus-shortcut").textContent = status.shortcut_status.includes(
|
||||
"Super",
|
||||
@@ -671,7 +880,7 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
}
|
||||
if (status.indexing)
|
||||
element("index-status").textContent =
|
||||
"Indexing active · Search remains available";
|
||||
"Searching your device · Ready to search";
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -885,6 +1094,14 @@ element("ai-citation").addEventListener("click", () => {
|
||||
});
|
||||
function select(index: number, focus = false, userChoice = true) {
|
||||
if (!results.length) return;
|
||||
selectedApplication = -1;
|
||||
element("application-preview").hidden = true;
|
||||
element("application-results")
|
||||
.querySelectorAll("button")
|
||||
.forEach((button) => {
|
||||
button.classList.remove("selected");
|
||||
button.setAttribute("aria-current", "false");
|
||||
});
|
||||
selected = Math.max(0, Math.min(index, results.length - 1));
|
||||
const result = results[selected];
|
||||
if (userChoice) resultSelection.choose(result.path);
|
||||
@@ -992,7 +1209,7 @@ function render() {
|
||||
resultRows.delete(key);
|
||||
}
|
||||
}
|
||||
element("empty").hidden = results.length > 0;
|
||||
element("empty").hidden = results.length > 0 || applications.length > 0;
|
||||
if (!results.length) {
|
||||
element("preview").hidden = true;
|
||||
previewPath = null;
|
||||
@@ -1120,7 +1337,8 @@ function applyResults(
|
||||
const preserved = resultSelection.resolve(
|
||||
results.map((result) => result.path),
|
||||
);
|
||||
if (preserved >= 0) select(preserved, resultsHadFocus, false);
|
||||
if (preserved >= 0 && selectedApplication < 0)
|
||||
select(preserved, resultsHadFocus, false);
|
||||
if (!found.length) refreshEmptySearchState();
|
||||
request.measurement.displayed(
|
||||
lane,
|
||||
@@ -1330,6 +1548,8 @@ function invalidateSearch() {
|
||||
}
|
||||
|
||||
async function search(inputAt = performance.now()) {
|
||||
syncLauncher();
|
||||
void refreshApplications().catch(showError);
|
||||
invalidateSearch();
|
||||
querySemanticFailed = false;
|
||||
shownLane = "lexical";
|
||||
@@ -1348,7 +1568,7 @@ async function search(inputAt = performance.now()) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"Find what matters. Locally.";
|
||||
element("empty").querySelector("p")!.textContent =
|
||||
"Index a folder, then search a filename, phrase, or what you remember.";
|
||||
"Your device is indexed in the background. Search an application, filename, phrase, or what you remember.";
|
||||
return;
|
||||
}
|
||||
resultSelection.begin(JSON.stringify([value, filters]));
|
||||
@@ -1384,6 +1604,13 @@ async function refreshStats() {
|
||||
}
|
||||
|
||||
query.addEventListener("input", () => {
|
||||
++applicationRevision;
|
||||
selectedApplication = -1;
|
||||
applications = [];
|
||||
element("application-results").replaceChildren();
|
||||
element("application-results").hidden = true;
|
||||
element("application-preview").hidden = true;
|
||||
syncLauncher();
|
||||
const inputAt = performance.now();
|
||||
invalidateSearch();
|
||||
timer = setTimeout(() => void search(inputAt), 60);
|
||||
@@ -1579,7 +1806,35 @@ document.addEventListener("keydown", (event) => {
|
||||
void search();
|
||||
query.focus();
|
||||
}
|
||||
if (event.target === query || list.contains(event.target as Node)) {
|
||||
if (
|
||||
event.target === query ||
|
||||
list.contains(event.target as Node) ||
|
||||
element("application-results").contains(event.target as Node)
|
||||
) {
|
||||
if (event.key === "Enter" && selectedApplication >= 0) {
|
||||
event.preventDefault();
|
||||
void launchApplication();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.key === "ArrowDown" || event.key === "ArrowUp") &&
|
||||
applications.length
|
||||
) {
|
||||
event.preventDefault();
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
const position =
|
||||
selectedApplication >= 0
|
||||
? selectedApplication
|
||||
: applications.length + selected;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(position + step, applications.length + results.length - 1),
|
||||
);
|
||||
if (next < applications.length) selectApplication(next);
|
||||
else select(next - applications.length);
|
||||
query.focus();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
results[selected] &&
|
||||
@@ -1590,12 +1845,15 @@ document.addEventListener("keydown", (event) => {
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
|
||||
select(selected + (event.key === "ArrowDown" ? 1 : -1));
|
||||
query.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
async function initializeSurface() {
|
||||
await initializeDesktopSurface();
|
||||
document.documentElement.dataset.nativeLauncher = String(isNativeDesktop());
|
||||
syncLauncher();
|
||||
renderAppearance();
|
||||
if (isNativeDesktop()) {
|
||||
appearanceUpdates.submit(highContrast.matches);
|
||||
|
||||
+186
-13
@@ -37,6 +37,7 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* The resident launcher starts as a search bar and expands for useful content. */
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -401,7 +402,7 @@ summary::-webkit-details-marker {
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
.workspace:has(aside[hidden]) {
|
||||
.workspace:has(#preview[hidden]):has(#application-preview[hidden]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.results-pane {
|
||||
@@ -960,12 +961,7 @@ aside pre.code-passage {
|
||||
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
|
||||
}
|
||||
:root[data-background-material="frosted"] #app {
|
||||
background: rgba(
|
||||
248,
|
||||
250,
|
||||
252,
|
||||
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||
);
|
||||
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
|
||||
}
|
||||
.appearance-settings {
|
||||
padding-bottom: 16px;
|
||||
@@ -1008,12 +1004,7 @@ aside pre.code-passage {
|
||||
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
|
||||
}
|
||||
:root[data-background-material="frosted"] #app {
|
||||
background: rgba(
|
||||
11,
|
||||
15,
|
||||
20,
|
||||
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||
);
|
||||
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
@@ -1032,3 +1023,185 @@ aside pre.code-passage {
|
||||
> summary {
|
||||
--muted: var(--ink);
|
||||
}
|
||||
:root {
|
||||
background: transparent;
|
||||
}
|
||||
#app {
|
||||
min-height: 110px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
padding: 6px 15px 4px;
|
||||
min-height: 38px;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand-symbol {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.index-status {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.header-actions {
|
||||
gap: 3px;
|
||||
}
|
||||
.header-actions summary,
|
||||
.header-actions .icon-button {
|
||||
padding: 5px;
|
||||
}
|
||||
.header-actions .icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
main {
|
||||
padding: 0 10px;
|
||||
}
|
||||
#search-form {
|
||||
min-height: 60px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
box-shadow: none;
|
||||
padding: 10px 13px;
|
||||
}
|
||||
#search-form:focus-within {
|
||||
box-shadow: none;
|
||||
}
|
||||
#query {
|
||||
font-size: 21px;
|
||||
}
|
||||
#focus-shortcut {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.workspace {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.workspace:has(#preview[hidden]):has(#application-preview[hidden]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workspace:has(#application-preview:not([hidden])) {
|
||||
grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr);
|
||||
}
|
||||
footer {
|
||||
min-height: 38px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.popover-panel {
|
||||
max-height: min(560px, 75dvh);
|
||||
overflow: auto;
|
||||
}
|
||||
#application-results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
max-height: 45%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.application-result {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.application-result.selected {
|
||||
background: var(--selected);
|
||||
}
|
||||
.application-detail {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.application-detail h2 {
|
||||
font-size: 25px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
:root[data-launcher-layout="compact"] .workspace,
|
||||
:root[data-launcher-layout="compact"] footer,
|
||||
:root[data-launcher-layout="compact"] #semantic-panel,
|
||||
:root[data-launcher-layout="compact"] #notice,
|
||||
:root[data-launcher-layout="compact"] #performance-panel {
|
||||
display: none;
|
||||
}
|
||||
:root[data-launcher-layout="expanded"] .workspace {
|
||||
animation: launcher-reveal 140ms ease-out;
|
||||
}
|
||||
@keyframes launcher-reveal {
|
||||
from {
|
||||
opacity: 0.6;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
:root[data-native-launcher="false"] #app {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
:root[data-native-launcher="false"][data-launcher-layout="compact"] #app {
|
||||
max-width: 720px;
|
||||
height: 110px;
|
||||
margin-top: min(20dvh, 120px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root[data-launcher-layout] .workspace {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
#app {
|
||||
border: 1px solid CanvasText;
|
||||
background: Canvas;
|
||||
}
|
||||
}
|
||||
|
||||
/* At the lowest shell opacity, local reading tints still protect text against
|
||||
an opposite-color desktop. The outer shell retains its selected opacity. */
|
||||
:root[data-background-material="glass"] #search-form,
|
||||
:root[data-background-material="frosted"] #search-form,
|
||||
:root[data-background-material="glass"] .header-actions,
|
||||
:root[data-background-material="frosted"] .header-actions,
|
||||
:root[data-background-material="glass"] .brand,
|
||||
:root[data-background-material="frosted"] .brand,
|
||||
:root[data-background-material="glass"] .index-status,
|
||||
:root[data-background-material="frosted"] .index-status,
|
||||
:root[data-background-material="glass"] footer,
|
||||
:root[data-background-material="frosted"] footer {
|
||||
background: rgba(248, 250, 252, 0.6);
|
||||
border-radius: 8px;
|
||||
--muted: var(--ink);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-background-material="glass"] #search-form,
|
||||
:root[data-background-material="frosted"] #search-form,
|
||||
:root[data-background-material="glass"] .header-actions,
|
||||
:root[data-background-material="frosted"] .header-actions,
|
||||
:root[data-background-material="glass"] .brand,
|
||||
:root[data-background-material="frosted"] .brand,
|
||||
:root[data-background-material="glass"] .index-status,
|
||||
:root[data-background-material="frosted"] .index-status,
|
||||
:root[data-background-material="glass"] footer,
|
||||
:root[data-background-material="frosted"] footer {
|
||||
background: rgba(11, 15, 20, 0.6);
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:root[data-background-material] #search-form {
|
||||
background: Canvas;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ test('high contrast stays opaque without losing the preference', () => {
|
||||
test('fallback is honest and shell tint is bounded', () => {
|
||||
const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'};
|
||||
assert.match(appearanceView(fallback, false).message, /Solid background/);
|
||||
assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 70);
|
||||
assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 40);
|
||||
assert.equal(appearanceView({...glass(), opacity:110}, false).opacity, 100);
|
||||
assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user