diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ad6257..d1c7af4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.2.0] - 2026-03-10
+
+### Fixed
+- **Windows: Fixed crash on startup** — `create_window()` raised `TypeError: unexpected keyword argument 'icon'` on pywebview builds that don't expose the `icon` parameter. The app now checks for parameter support at runtime via `inspect.signature` and falls back gracefully, so the window opens without an icon instead of crashing entirely.
+- **macOS: Fixed dock icon showing Python logo** — When running from source the dock now displays the DocFinder logo instead of the generic Python 3.x icon, via AppKit `NSApplication.setApplicationIconImage_()`.
+
+### Added
+- **Global hotkey** — bring DocFinder to the front from any app with a configurable system-wide keyboard shortcut (default: `⌘+Shift+F` on macOS, `Ctrl+Shift+F` on Windows/Linux); implemented via pynput `GlobalHotKeys`
+- **Settings tab** — new gear-icon tab lets you enable/disable the global hotkey and change the key combination via an interactive capture modal (press the desired keys, confirm)
+- **Native folder picker** — "Browse…" button in the Index tab opens the system file dialog (Finder on macOS, Explorer on Windows) via `window.pywebview.api.pick_folder()`; button is shown only when running inside the desktop app
+
+### Performance
+- **Indexing 2–4× faster** through several compounding improvements:
+ - `insert_chunks()` now uses `executemany()` for batch SQLite inserts (was one `execute()` per row)
+ - `EmbeddingModel.embed()` uses SentenceTransformer's native batching directly (batch size 32, up from 8); removed the artificial inner mini-batch loop of 4
+ - Chunk batch size per document increased from 32 to 64
+ - Removed `gc.collect()` calls from inside the per-chunk loop; one call per document is sufficient
+ - Removed the artificial 2-files-at-a-time outer loop during indexing
+- **First request instant** — `EmbeddingModel` is now a singleton loaded once at startup; previously a new model instance was created for every `/search`, `/documents`, `/index`, and `/cleanup` request
+
+### UI
+- **Real-time indexing progress** — animated progress bar with file counter and current filename, updated every 600 ms via polling
+- **macOS-native design** — header uses `backdrop-filter: saturate(180%) blur(20px)` for the system frosted-glass effect; improved shadows and depth
+- **⌘K / Ctrl+K** shortcut to jump to search from any tab; search input auto-focused on load
+- **Drag & drop** — drag a folder from Finder/Explorer directly onto the path input in the Index tab
+- Relevance score shown as a **percentage** (e.g. `87%`) instead of a raw float
+- Search result **count** displayed above the results list
+
## [1.1.2] - 2025-12-15
### Fixed
@@ -152,7 +180,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed linting issues for consistent code style
- Updated ruff configuration to use non-deprecated settings
-[Unreleased]: https://github.com/filippostanghellini/DocFinder/compare/v1.1.2...HEAD
+[Unreleased]: https://github.com/filippostanghellini/DocFinder/compare/v1.2.0...HEAD
+[1.2.0]: https://github.com/filippostanghellini/DocFinder/compare/v1.1.2...v1.2.0
[1.1.2]: https://github.com/filippostanghellini/DocFinder/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/filippostanghellini/DocFinder/compare/v1.0.1...v1.1.1
[1.0.1]: https://github.com/filippostanghellini/DocFinder/compare/v1.0.0...v1.0.1
diff --git a/Makefile b/Makefile
index d07c239..7b7defa 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,31 @@
-.PHONY: lint format test check-all install clean build-macos build-windows build-linux
+.PHONY: setup run run-web lint format format-check test check-all install install-gui clean build-macos build-windows build-linux
-# Install dependencies
+# ── First-time setup ──────────────────────────────────────────────────────────
+# Creates a virtual environment and installs all dependencies in one command.
+# Run this once after cloning the repository.
+setup:
+ python -m venv .venv
+ .venv/bin/pip install --upgrade pip --quiet
+ .venv/bin/pip install -e ".[dev,web,gui]"
+ @echo ""
+ @echo "✅ Setup complete!"
+ @echo " Launch the desktop app : make run"
+ @echo " Launch the web UI : make run-web"
+ @echo " Run tests : make test"
+
+# ── Run ───────────────────────────────────────────────────────────────────────
+
+# Launch the native desktop GUI
+run:
+ .venv/bin/docfinder-gui
+
+# Launch the web interface (opens in browser at http://127.0.0.1:8000)
+run-web:
+ .venv/bin/docfinder web
+
+# ── Install (legacy targets, prefer 'make setup') ─────────────────────────────
+
+# Install dependencies (no GUI)
install:
.venv/bin/pip install -e ".[dev,web]"
diff --git a/README.md b/README.md
index 810893a..99b164a 100644
--- a/README.md
+++ b/README.md
@@ -4,227 +4,68 @@
[](https://github.com/filippostanghellini/DocFinder/actions/workflows/codeql.yml)
[](LICENSE)
[](https://www.python.org/downloads/)
-[](https://github.com/astral-sh/ruff)
-[](https://github.com/filippostanghellini/DocFinder/stargazers)
[](https://github.com/filippostanghellini/DocFinder/releases)
[](https://github.com/filippostanghellini/DocFinder/releases)
-
+
- 🔍 Local-first semantic search for your PDF documents
+ Local-first semantic search for your PDF documents.
+ Everything runs on your machine — no cloud, no accounts, complete privacy.
-
- Index and search your PDFs using AI powered semantic embeddings.
- Everything runs locally on your machine no cloud, no external services, complete privacy.
-
-
----
-
-## ✨ Features
+
+
+  |
+  |
+
+
-- **100% Local**: Your documents never leave your machine
-- **Fast Semantic Search**: Find documents by meaning, not just keywords
-- **Cross-Platform**: Native apps for macOS, Windows, and Linux
-- **GPU Accelerated**: Auto-detects Apple Silicon, NVIDIA, or AMD GPUs
-- **PDF Optimized**: Powered by PyMuPDF for reliable text extraction
-- **Web Interface**: UI for indexing and searching
+## Features
----
+- **Semantic search** — find documents by meaning, not just keywords
+- **100% local** — your files never leave your machine
+- **GPU accelerated** — auto-detects Apple Silicon (Metal), NVIDIA (CUDA), AMD (ROCm)
+- **Cross-platform** — native apps for macOS, Windows, and Linux
+- **Global shortcut** — bring DocFinder to front from anywhere with a configurable hotkey
-## 🚀 Quick Start
+## Download
-### 1. Install
-
-Download the app for your platform from [**GitHub Releases**](https://github.com/filippostanghellini/DocFinder/releases):
-
-| Platform | Download |
-|----------|----------|
+| Platform | Installer |
+|----------|-----------|
| **macOS** | [DocFinder-macOS.dmg](https://github.com/filippostanghellini/DocFinder/releases/latest) |
| **Windows** | [DocFinder-Windows-Setup.exe](https://github.com/filippostanghellini/DocFinder/releases/latest) |
| **Linux** | [DocFinder-Linux-x86_64.AppImage](https://github.com/filippostanghellini/DocFinder/releases/latest) |
-### 2. Index Your Documents
-
-1. Open DocFinder
-2. Enter the path to your PDF folder (e.g., `~/Documents/Papers`)
-3. Click **Index** and wait for completion
-
-### 3. Search
-
-Type a natural language query like:
-- *"contract about property sale"*
-- *"machine learning introduction"*
-- *"invoice from December 2024"*
-
-DocFinder finds relevant documents by **meaning**, not just exact keywords.
-
----
-
-## 📸 Screenshots
-
-
-Click to expand
-
-**Search**
-
-
-**Index Documents**
-
-
-**Database**
-
+**macOS** — open the DMG, drag DocFinder to Applications, then right-click → **Open** on first launch (Gatekeeper warning — normal for unsigned open-source apps).
-
-
----
-
-## 💻 System Requirements
-
-| Component | Minimum | Recommended |
-|-----------|---------|-------------|
-| **RAM** | 4 GB | 8 GB+ |
-| **Disk Space** | 500 MB | 1 GB+ |
-| **macOS** | 11.0 (Big Sur) | 13.0+ (Ventura) |
-| **Windows** | 10 | 11 |
-| **Linux** | Ubuntu 20.04+ | Ubuntu 22.04+ |
-
-### GPU Support (Optional)
-
-DocFinder **automatically detects** your hardware and uses the best available option:
-
-| Hardware | Support | Notes |
-|----------|---------|-------|
-| **Apple Silicon** (M1/M2/M3/M4) | ✅ Automatic | Uses Metal Performance Shaders |
-| **NVIDIA GPU** | ✅ With `[gpu]` extra | Requires CUDA drivers |
-| **AMD GPU** | ✅ Automatic | Uses ROCm on Linux |
-| **CPU** | ✅ Always works | Fallback option |
-
----
-
-## 📦 Installation
-
-### Desktop App (Recommended)
-
-#### macOS
-
-1. Download `DocFinder-macOS.dmg`
-2. Open the DMG and drag **DocFinder** to **Applications**
-3. **First launch**: Right-click → **Open** → Click **Open** again
-
-> ⚠️ macOS shows a warning because the app isn't signed with an Apple Developer ID. This is normal for open-source software.
-
-#### Windows
-
-1. Download `DocFinder-Windows-Setup.exe`
-2. Run the installer
-3. If SmartScreen warns you: Click **More info** → **Run anyway**
-
-#### Linux
+**Windows** — run the installer; if SmartScreen appears choose **More info → Run anyway**.
+**Linux**
```bash
-wget https://github.com/filippostanghellini/DocFinder/releases/latest/download/DocFinder-Linux-x86_64.AppImage
-chmod +x DocFinder-Linux-x86_64.AppImage
-./DocFinder-Linux-x86_64.AppImage
+chmod +x DocFinder-Linux-x86_64.AppImage && ./DocFinder-Linux-x86_64.AppImage
```
----
-
-### Python Package
+## Run from Source
-For developers or advanced users:
+Requires Python 3.10+ and `make`.
```bash
-# Create virtual environment
-python -m venv .venv
-source .venv/bin/activate # On Windows: .venv\Scripts\activate
-
-# Install DocFinder
-pip install .
-
-# With GPU support (NVIDIA)
-pip install '.[gpu]'
-
-# With all extras (development + web + GUI)
-pip install '.[dev,web,gui]'
-```
-
----
-
-## 🔧 Usage
-
-### Desktop App
-
-Just launch **DocFinder** from your Applications folder, Start Menu, or run the AppImage.
-
-### Command Line
-
-```bash
-# Index a folder of PDFs
-docfinder index ~/Documents/PDFs
-
-# Search your documents
-docfinder search "quarterly financial report"
-
-# Launch web interface
-docfinder web
-
-# Launch desktop GUI (from source)
-docfinder-gui
-```
-
-### Where is my data stored?
-
-| Mode | Database Location |
-|------|-------------------|
-| Desktop App | `~/Documents/DocFinder/docfinder.db` |
-| Development | `data/docfinder.db` |
-
----
-
-## 🛠️ Build from Source
-
-```bash
-# Clone the repository
git clone https://github.com/filippostanghellini/DocFinder.git
cd DocFinder
-
-# Install dependencies
-make install-gui
-
-# Run the GUI
-docfinder-gui
-
-# Build native app (macOS)
-make build-macos
+make setup # create .venv and install all dependencies
+make run # desktop GUI
+make run-web # web interface at http://127.0.0.1:8000
```
----
-
-## 📁 Project Structure
-
-```
-src/docfinder/
-├── ingestion/ # PDF parsing and text chunking
-├── embedding/ # AI model wrappers (sentence-transformers, ONNX)
-├── index/ # SQLite vector storage and search
-├── utils/ # File handling and text utilities
-└── web/ # FastAPI web interface
-```
-
----
-
-## 🤝 Contributing
-
-Contributions are welcome! Please feel free to submit a Pull Request.
+## Contributing
----
+Contributions are welcome, feel free to open an issue or submit a pull request.
-## 📄 License
+## License
-This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**.
+Licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**.
-> **Note**: DocFinder was originally released under the MIT License. Starting from version 1.1.1, the license was changed to AGPL-3.0 to comply with [PyMuPDF](https://pymupdf.readthedocs.io/) licensing requirements.
+> DocFinder was originally released under the MIT License. Starting from version 1.1.1 the license was changed to AGPL-3.0 to comply with the [PyMuPDF](https://pymupdf.readthedocs.io/) licensing requirements, as PyMuPDF itself is AGPL-3.0 licensed.
diff --git a/images/database-documents.png b/images/database-documents.png
deleted file mode 100644
index 3b67713..0000000
Binary files a/images/database-documents.png and /dev/null differ
diff --git a/images/index-documents.png b/images/index-documents.png
deleted file mode 100644
index c2e1055..0000000
Binary files a/images/index-documents.png and /dev/null differ
diff --git a/images/index.png b/images/index.png
new file mode 100644
index 0000000..82ff4db
Binary files /dev/null and b/images/index.png differ
diff --git a/images/search.png b/images/search.png
index 1cae1e8..16c12ab 100644
Binary files a/images/search.png and b/images/search.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 1cfeaaf..af3bbda 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "docfinder"
-version = "1.1.2"
+version = "1.2.0"
license = "AGPL-3.0-or-later"
description = "Local-first semantic search CLI for PDF documents."
authors = [
@@ -45,7 +45,9 @@ gui = [
"pywebview>=5.0.0",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
- "pydantic>=2.9.0"
+ "pydantic>=2.9.0",
+ "pynput>=1.7.0",
+ "pyobjc-framework-Cocoa>=9.0; sys_platform == 'darwin'"
]
gpu = [
"onnxruntime-gpu>=1.17.0"
diff --git a/src/docfinder/embedding/encoder.py b/src/docfinder/embedding/encoder.py
index 981d753..2ffb91e 100644
--- a/src/docfinder/embedding/encoder.py
+++ b/src/docfinder/embedding/encoder.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-import gc
import logging
import platform
import sys
@@ -131,7 +130,7 @@ def detect_optimal_backend() -> tuple[Literal["torch", "onnx"], str | None]:
@dataclass(slots=True)
class EmbeddingConfig:
model_name: str = DEFAULT_MODEL
- batch_size: int = 8
+ batch_size: int = 32
normalize: bool = True
backend: Literal["torch", "onnx", "openvino"] | None = None
onnx_model_file: str | None = None
@@ -214,34 +213,15 @@ def _log_backend_info(self) -> None:
def embed(self, texts: Sequence[str] | Iterable[str]) -> np.ndarray:
"""Return float32 embeddings for input texts."""
-
sentences = list(texts)
-
- # Processa in mini-batch per ridurre memoria
- all_embeddings = []
- mini_batch_size = 4 # Processa solo 4 testi alla volta
-
- for i in range(0, len(sentences), mini_batch_size):
- batch = sentences[i : i + mini_batch_size]
- embeddings = self._model.encode(
- batch,
- batch_size=self.config.batch_size,
- show_progress_bar=False,
- convert_to_numpy=True,
- normalize_embeddings=self.config.normalize,
- )
- all_embeddings.append(embeddings)
-
- # Libera memoria dopo ogni mini-batch
- gc.collect()
-
- # Concatena tutti i risultati
- result = np.vstack(all_embeddings).astype("float32", copy=False)
-
- # Pulizia finale
- gc.collect()
-
- return result
+ embeddings = self._model.encode(
+ sentences,
+ batch_size=self.config.batch_size,
+ show_progress_bar=False,
+ convert_to_numpy=True,
+ normalize_embeddings=self.config.normalize,
+ )
+ return np.asarray(embeddings, dtype="float32")
def embed_query(self, text: str) -> np.ndarray:
"""Convenience wrapper for single-query embedding."""
diff --git a/src/docfinder/gui.py b/src/docfinder/gui.py
index d227f4f..021ee40 100644
--- a/src/docfinder/gui.py
+++ b/src/docfinder/gui.py
@@ -137,6 +137,99 @@ def _wait_for_server(host: str, port: int, timeout: float = 30.0) -> bool:
return False
+class GlobalHotkeyManager:
+ """Registers and manages a system-wide keyboard shortcut via pynput.
+
+ When the hotkey fires, the DocFinder window is brought to the front
+ and the search input is focused.
+ """
+
+ def __init__(self) -> None:
+ self.window: object | None = None # set after create_window()
+ self._listener = None
+
+ def start(self, hotkey: str, enabled: bool = True) -> None:
+ """Register the global hotkey. Replaces any previously registered one."""
+ self.stop()
+ if not enabled or not hotkey:
+ return
+ try:
+ from pynput import keyboard # type: ignore[import-untyped]
+
+ self._listener = keyboard.GlobalHotKeys({hotkey: self._on_activate})
+ self._listener.daemon = True
+ self._listener.start()
+ logger.info("Global hotkey registered: %s", hotkey)
+ except ImportError:
+ logger.warning("pynput is not installed — global hotkey unavailable")
+ except Exception as exc:
+ logger.warning("Could not register global hotkey '%s': %s", hotkey, exc)
+
+ def _on_activate(self) -> None:
+ logger.debug("Global hotkey activated — bringing DocFinder to front")
+ try:
+ if sys.platform == "darwin":
+ try:
+ from AppKit import NSApplication # type: ignore[import-untyped]
+
+ NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
+ except ImportError:
+ pass
+ if self.window:
+ self.window.show()
+ self.window.evaluate_js(
+ "document.querySelector('[data-tab=\"search\"]').click();"
+ "setTimeout(()=>document.getElementById('query').focus(),60);"
+ )
+ except Exception as exc:
+ logger.warning("Failed to bring window to front: %s", exc)
+
+ def stop(self) -> None:
+ if self._listener:
+ try:
+ self._listener.stop()
+ except Exception:
+ pass
+ self._listener = None
+
+ def reload(self, hotkey: str, enabled: bool = True) -> None:
+ """Stop the current listener and register a new hotkey."""
+ self.start(hotkey, enabled)
+
+
+class DesktopApi:
+ """Exposes native OS capabilities to the webview JS frontend.
+
+ Available from JavaScript as window.pywebview.api.*
+ """
+
+ def __init__(self) -> None:
+ self.window: object | None = None
+ self._hotkey_manager: GlobalHotkeyManager | None = None
+
+ def pick_folder(self) -> str | None:
+ """Open the native folder picker and return the selected absolute path, or None."""
+ if self.window is None:
+ return None
+ try:
+ import webview
+
+ result = self.window.create_file_dialog(webview.FOLDER_DIALOG, allow_multiple=False)
+ return result[0] if result else None
+ except Exception as exc:
+ logger.warning("Folder picker dialog failed: %s", exc)
+ return None
+
+ def reload_hotkey(self) -> None:
+ """Re-read settings from disk and apply the hotkey immediately."""
+ if self._hotkey_manager is None:
+ return
+ from docfinder.settings import load_settings
+
+ s = load_settings()
+ self._hotkey_manager.reload(s.get("hotkey", ""), s.get("hotkey_enabled", True))
+
+
class ServerThread(threading.Thread):
"""Thread that runs the uvicorn server."""
@@ -184,6 +277,8 @@ def main() -> None:
raise SystemExit(1) from exc
try:
+ from docfinder.settings import load_settings
+
# Find a free port
host = "127.0.0.1"
port = _find_free_port()
@@ -202,13 +297,32 @@ def main() -> None:
logger.info("Server ready, launching window...")
- # Get icon path
icon_path = _get_icon_path()
- # Create and start the webview window
- # Note: pywebview doesn't support setting window icon on all platforms
- # macOS uses the app bundle icon, Windows can use the icon parameter
- # Linux pywebview doesn't support icon parameter
+ # ── macOS dock icon (source runs only) ───────────────────────────────
+ # When frozen (PyInstaller), the bundle already carries the .icns icon.
+ # When running from source, set the dock icon via AppKit so the user
+ # doesn't see the generic Python icon.
+ if sys.platform == "darwin" and not getattr(sys, "frozen", False) and icon_path:
+ try:
+ from AppKit import NSApplication, NSImage # type: ignore[import-untyped]
+
+ ns_app = NSApplication.sharedApplication()
+ ns_image = NSImage.alloc().initWithContentsOfFile_(icon_path)
+ if ns_image:
+ ns_app.setApplicationIconImage_(ns_image)
+ logger.debug("macOS dock icon set via AppKit")
+ except ImportError:
+ logger.debug("pyobjc not available — run: pip install 'docfinder[gui]'")
+ except Exception as exc:
+ logger.debug("Could not set macOS dock icon: %s", exc)
+
+ # ── Desktop API (folder picker + hotkey reload) ──────────────────────
+ desktop_api = DesktopApi()
+ hotkey_manager = GlobalHotkeyManager()
+ desktop_api._hotkey_manager = hotkey_manager
+
+ # ── Window creation ──────────────────────────────────────────────────
window_kwargs: dict = {
"title": "DocFinder",
"url": url,
@@ -217,33 +331,47 @@ def main() -> None:
"min_size": (800, 600),
"resizable": True,
"text_select": True,
+ "js_api": desktop_api,
}
- # Add icon only on Windows (macOS uses app bundle, Linux doesn't support it)
+ # Add icon on Windows — guard against older pywebview builds that
+ # don't expose the `icon` parameter (would raise TypeError otherwise)
if icon_path and sys.platform == "win32":
- window_kwargs["icon"] = icon_path
+ import inspect
+
+ if "icon" in inspect.signature(webview.create_window).parameters:
+ window_kwargs["icon"] = icon_path
+ else:
+ logger.debug("pywebview does not support 'icon' parameter, skipping")
- window = webview.create_window(**window_kwargs)
+ try:
+ window = webview.create_window(**window_kwargs)
+ except TypeError as exc:
+ logger.warning("create_window() failed (%s), retrying without icon", exc)
+ window_kwargs.pop("icon", None)
+ window = webview.create_window(**window_kwargs)
+
+ # Wire window references
+ desktop_api.window = window
+ hotkey_manager.window = window
def on_closed() -> None:
- """Handle window close event."""
- logger.info("Window closed, shutting down server...")
+ logger.info("Window closed, shutting down...")
+ hotkey_manager.stop()
server_thread.stop()
window.events.closed += on_closed
- # Start the webview (this blocks until window is closed)
- # Use different backends based on platform for best compatibility
+ # ── Start global hotkey after webview is ready ───────────────────────
+ def _on_webview_started() -> None:
+ settings = load_settings()
+ hotkey_manager.start(
+ settings.get("hotkey", ""),
+ settings.get("hotkey_enabled", True),
+ )
+
logger.info("Starting webview window...")
- if sys.platform == "darwin":
- # macOS: use native WebKit
- webview.start(private_mode=False)
- elif sys.platform == "win32":
- # Windows: prefer EdgeChromium, fall back to others
- webview.start(private_mode=False)
- else:
- # Linux: use GTK WebKit
- webview.start(private_mode=False)
+ webview.start(private_mode=False, func=_on_webview_started)
logger.info("DocFinder closed normally.")
diff --git a/src/docfinder/index/indexer.py b/src/docfinder/index/indexer.py
index b9024ba..bee397f 100644
--- a/src/docfinder/index/indexer.py
+++ b/src/docfinder/index/indexer.py
@@ -6,7 +6,7 @@
import logging
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Sequence
+from typing import Callable, Sequence
from docfinder.embedding.encoder import EmbeddingModel
from docfinder.index.storage import SQLiteVectorStore
@@ -52,11 +52,13 @@ def __init__(
*,
chunk_chars: int = 1200,
overlap: int = 200,
+ progress_callback: Callable[[int, int, str], None] | None = None,
) -> None:
self.embedder = embedder
self.store = store
self.chunk_chars = chunk_chars
self.overlap = overlap
+ self.progress_callback = progress_callback
def index(self, paths: Sequence[Path]) -> IndexStats:
"""Index all PDFs found under the given paths."""
@@ -65,28 +67,25 @@ def index(self, paths: Sequence[Path]) -> IndexStats:
LOGGER.warning("No PDF files found")
return IndexStats()
+ total = len(pdf_files)
stats = IndexStats()
- # Processa solo 2 file alla volta per ridurre memoria
- batch_size = 2
-
- for i in range(0, len(pdf_files), batch_size):
- batch = pdf_files[i : i + batch_size]
-
- for path in batch:
- try:
- LOGGER.info(f"Processing: {path}")
- status = self._index_single(path)
- stats.increment(status, path)
-
- except Exception as e:
- LOGGER.error(f"Failed to process {path}: {e}")
- stats.failed += 1
- stats.processed_files.append(path)
-
- # Libera memoria dopo ogni batch
+ for i, path in enumerate(pdf_files):
+ if self.progress_callback:
+ self.progress_callback(i, total, str(path))
+ try:
+ LOGGER.info(f"Processing: {path}")
+ status = self._index_single(path)
+ stats.increment(status, path)
+ except Exception as e:
+ LOGGER.error(f"Failed to process {path}: {e}")
+ stats.failed += 1
+ stats.processed_files.append(path)
gc.collect()
+ if self.progress_callback:
+ self.progress_callback(total, total, "")
+
return stats
def _index_single(self, path: Path) -> str:
@@ -120,7 +119,7 @@ def _index_single(self, path: Path) -> str:
return status
# Process chunks in batches
- batch_size = 32
+ batch_size = 64
current_batch = []
# Chain the first chunk back with the rest
@@ -131,12 +130,10 @@ def _index_single(self, path: Path) -> str:
embeddings = self.embedder.embed([c.text for c in current_batch])
self.store.insert_chunks(doc_id, current_batch, embeddings)
current_batch = []
- gc.collect()
# Process remaining chunks
if current_batch:
embeddings = self.embedder.embed([c.text for c in current_batch])
self.store.insert_chunks(doc_id, current_batch, embeddings)
- gc.collect()
return status
diff --git a/src/docfinder/index/storage.py b/src/docfinder/index/storage.py
index 5e78593..34f3d41 100644
--- a/src/docfinder/index/storage.py
+++ b/src/docfinder/index/storage.py
@@ -139,21 +139,22 @@ def insert_chunks(
if embeddings.shape[0] != len(chunks):
raise ValueError("Embeddings and chunks length mismatch")
- conn = self._conn
- for chunk, vector in zip(chunks, embeddings):
- conn.execute(
- """
- INSERT INTO chunks(document_id, chunk_index, text, metadata, embedding)
- VALUES (?, ?, ?, ?, ?)
- """,
- (
- doc_id,
- chunk.index,
- chunk.text,
- json.dumps(chunk.metadata, ensure_ascii=True),
- sqlite3.Binary(np.asarray(vector, dtype="float32").tobytes()),
- ),
+ data = [
+ (
+ doc_id,
+ chunk.index,
+ chunk.text,
+ json.dumps(chunk.metadata, ensure_ascii=True),
+ sqlite3.Binary(np.asarray(vector, dtype="float32").tobytes()),
)
+ for chunk, vector in zip(chunks, embeddings)
+ ]
+ sql = (
+ "INSERT INTO chunks"
+ "(document_id, chunk_index, text, metadata, embedding)"
+ " VALUES (?, ?, ?, ?, ?)"
+ )
+ self._conn.executemany(sql, data)
def upsert_document(
self,
diff --git a/src/docfinder/settings.py b/src/docfinder/settings.py
new file mode 100644
index 0000000..8551fea
--- /dev/null
+++ b/src/docfinder/settings.py
@@ -0,0 +1,56 @@
+"""Persistent user settings for DocFinder."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sys
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _settings_dir() -> Path:
+ if sys.platform == "win32":
+ base = Path(os.environ.get("APPDATA", str(Path.home())))
+ return base / "DocFinder"
+ if sys.platform == "darwin":
+ return Path.home() / "Library" / "Application Support" / "DocFinder"
+ xdg = os.environ.get("XDG_CONFIG_HOME", "")
+ base = Path(xdg) if xdg else Path.home() / ".config"
+ return base / "docfinder"
+
+
+def get_settings_path() -> Path:
+ return _settings_dir() / "settings.json"
+
+
+def _default_hotkey() -> str:
+ """Return a platform-appropriate default global hotkey string (pynput format)."""
+ return "++f" if sys.platform == "darwin" else "++f"
+
+
+_DEFAULTS: dict = {
+ "hotkey_enabled": True,
+}
+
+
+def load_settings() -> dict:
+ defaults = {**_DEFAULTS, "hotkey": _default_hotkey()}
+ path = get_settings_path()
+ if not path.exists():
+ return defaults
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return {**defaults, **data}
+ except Exception as exc:
+ logger.warning("Failed to read settings from %s: %s", path, exc)
+ return defaults
+
+
+def save_settings(data: dict) -> None:
+ path = get_settings_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
+ logger.debug("Settings saved to %s", path)
diff --git a/src/docfinder/web/app.py b/src/docfinder/web/app.py
index 2df2580..dbfd3ea 100644
--- a/src/docfinder/web/app.py
+++ b/src/docfinder/web/app.py
@@ -7,6 +7,9 @@
import os
import subprocess
import sys
+import threading
+import uuid
+from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, List
@@ -19,11 +22,41 @@
from docfinder.index.indexer import Indexer
from docfinder.index.search import Searcher, SearchResult
from docfinder.index.storage import SQLiteVectorStore
+from docfinder.settings import load_settings
+from docfinder.settings import save_settings as _save_settings
from docfinder.web.frontend import router as frontend_router
LOGGER = logging.getLogger(__name__)
-app = FastAPI(title="DocFinder Web", version="1.1.1")
+# ── Singleton EmbeddingModel ─────────────────────────────────────────────────
+_embedder: EmbeddingModel | None = None
+_embedder_lock = threading.Lock()
+
+
+def _get_embedder() -> EmbeddingModel:
+ """Return a cached EmbeddingModel, creating it on first call."""
+ global _embedder
+ if _embedder is None:
+ with _embedder_lock:
+ if _embedder is None:
+ config = AppConfig()
+ _embedder = EmbeddingModel(EmbeddingConfig(model_name=config.model_name))
+ return _embedder
+
+
+# ── Async indexing job registry ───────────────────────────────────────────────
+_index_jobs: dict[str, dict] = {}
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
+ # Pre-load the embedding model at startup so the first request is instant
+ await asyncio.to_thread(_get_embedder)
+ yield
+
+
+app = FastAPI(title="DocFinder Web", version="1.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -56,6 +89,11 @@ class IndexPayload(BaseModel):
overlap: int | None = None
+class SettingsPayload(BaseModel):
+ hotkey: str | None = None
+ hotkey_enabled: bool | None = None
+
+
def _resolve_db_path(db: Path | None) -> Path:
config = AppConfig(db_path=db if db is not None else AppConfig().db_path)
return config.resolve_db_path(Path.cwd())
@@ -65,11 +103,6 @@ def _ensure_db_parent(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
-@app.on_event("startup")
-async def startup_event() -> None:
- logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
-
-
@app.post("/search")
async def search_documents(payload: SearchPayload) -> dict[str, List[SearchResult]]:
query = payload.query.strip()
@@ -86,7 +119,7 @@ async def search_documents(payload: SearchPayload) -> dict[str, List[SearchResul
"Please index some documents first using the 'Index folder or PDF' section above.",
)
- embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name))
+ embedder = _get_embedder()
store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension)
searcher = Searcher(embedder, store)
results = searcher.search(query, top_k=top_k)
@@ -122,7 +155,7 @@ async def list_documents(db: Path | None = None) -> dict[str, Any]:
"stats": {"document_count": 0, "chunk_count": 0, "total_size_bytes": 0},
}
- embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name))
+ embedder = _get_embedder()
store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension)
try:
documents = store.list_documents()
@@ -140,7 +173,7 @@ async def cleanup_missing_files(db: Path | None = None) -> dict[str, Any]:
if not resolved_db.exists():
raise HTTPException(status_code=404, detail="Database not found")
- embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name))
+ embedder = _get_embedder()
store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension)
try:
removed_count = store.remove_missing_files()
@@ -157,7 +190,7 @@ async def delete_document_by_id(doc_id: int, db: Path | None = None) -> dict[str
if not resolved_db.exists():
raise HTTPException(status_code=404, detail="Database not found")
- embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name))
+ embedder = _get_embedder()
store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension)
try:
deleted = store.delete_document(doc_id)
@@ -180,7 +213,7 @@ async def delete_document(payload: DeleteDocumentRequest, db: Path | None = None
if not resolved_db.exists():
raise HTTPException(status_code=404, detail="Database not found")
- embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name))
+ embedder = _get_embedder()
store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension)
try:
if payload.doc_id is not None:
@@ -196,14 +229,45 @@ async def delete_document(payload: DeleteDocumentRequest, db: Path | None = None
return {"status": "ok"}
-def _run_index_job(paths: List[Path], config: AppConfig, resolved_db: Path) -> dict[str, Any]:
- embedder = EmbeddingModel(EmbeddingConfig(model_name=config.model_name))
+@app.get("/settings")
+async def get_settings() -> dict:
+ """Return current user settings."""
+ return load_settings()
+
+
+@app.post("/settings")
+async def update_settings(payload: SettingsPayload) -> dict:
+ """Persist updated settings and return the full settings dict."""
+ current = load_settings()
+ if payload.hotkey is not None:
+ current["hotkey"] = payload.hotkey
+ if payload.hotkey_enabled is not None:
+ current["hotkey_enabled"] = payload.hotkey_enabled
+ _save_settings(current)
+ return current
+
+
+def _run_index_job(
+ paths: List[Path],
+ config: AppConfig,
+ resolved_db: Path,
+ job: dict | None = None,
+) -> dict[str, Any]:
+ embedder = _get_embedder()
+
+ def _progress(processed: int, total: int, current_file: str) -> None:
+ if job is not None:
+ job["processed"] = processed
+ job["total"] = total
+ job["current_file"] = current_file
+
store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension)
indexer = Indexer(
embedder,
store,
chunk_chars=config.chunk_chars,
overlap=config.overlap,
+ progress_callback=_progress,
)
try:
stats = indexer.index(paths)
@@ -219,98 +283,100 @@ def _run_index_job(paths: List[Path], config: AppConfig, resolved_db: Path) -> d
}
-@app.post("/index")
-async def index_documents(payload: IndexPayload) -> dict[str, Any]:
+def _validate_index_paths(payload: "IndexPayload") -> List[Path]:
+ """Validate and resolve paths from an IndexPayload. Raises HTTPException on error."""
logger = logging.getLogger(__name__)
- sanitized_paths = [p.replace("\r", "").replace("\n", "") for p in payload.paths]
- logger.info("DEBUG: Received paths = %s", sanitized_paths)
- logger.info("DEBUG: Path type = %s", type(payload.paths))
-
- if not payload.paths:
- raise HTTPException(status_code=400, detail="No path provided")
-
- config_defaults = AppConfig()
- config = AppConfig(
- db_path=Path(payload.db) if payload.db is not None else config_defaults.db_path,
- model_name=payload.model or config_defaults.model_name,
- chunk_chars=payload.chunk_chars or config_defaults.chunk_chars,
- overlap=payload.overlap or config_defaults.overlap,
- )
-
- resolved_db = config.resolve_db_path(Path.cwd())
- _ensure_db_parent(resolved_db)
-
- # Security: Define safe base directory for path traversal protection
- # User can only access directories within their home directory or an explicitly allowed path
- # For now, we allow access to the entire filesystem as the user is expected to be trusted
- # In production, you might want to restrict this to specific directories
- # IMPORTANT: Use canonical (real) path to prevent symlink-based bypasses
safe_base_dir = Path(os.path.realpath(str(Path.home())))
+ resolved_paths: List[Path] = []
- # Validate and resolve paths safely
- resolved_paths = []
for p in payload.paths:
- # Sanitize input: remove newlines and carriage returns
clean_path = p.strip().replace("\r", "").replace("\n", "")
if not clean_path:
continue
-
- # Security: Reject paths with null bytes or other dangerous characters
if "\0" in clean_path:
raise HTTPException(status_code=400, detail="Invalid path: contains null byte")
try:
- # Step 1: Expand user directory first
expanded_path = os.path.expanduser(clean_path)
-
- # Step 2: Use os.path.realpath for secure path resolution (prevents symlink attacks)
- # This also resolves relative paths and removes .. components
real_path = os.path.realpath(expanded_path)
- # Step 3: Additional security check - verify it's an absolute path
if not os.path.isabs(real_path):
raise HTTPException(status_code=400, detail="Invalid path: must be absolute")
- # Step 4: CRITICAL SECURITY CHECK - Verify path is within safe base directory
- # We use canonical string prefix comparison for maximum robustness:
- # - Both paths are already fully resolved via os.path.realpath
- # - String prefix check works across all Python versions
- # - Avoids edge cases with is_relative_to() and symlinked parents
- # - Ensures path cannot escape the allowed directory (e.g., /etc/passwd)
- # Add path separator to prevent partial matches (e.g., /home/user vs /home/user2)
safe_base_str = str(safe_base_dir) + os.sep
real_path_str = real_path + os.sep
-
if not real_path_str.startswith(safe_base_str):
raise HTTPException(
status_code=403,
detail="Access denied: path is outside allowed directory",
)
- # Step 5: Create Path object from the validated canonical path
- # This breaks the taint chain for CodeQL static analysis
validated_path = Path(real_path)
-
- # Step 6: Now that path is validated, perform filesystem operations
if not validated_path.exists():
raise HTTPException(status_code=404, detail="Path not found: %s" % clean_path)
-
- # Step 7: Verify it's a directory (not a file)
if not validated_path.is_dir():
raise HTTPException(
status_code=400, detail="Path must be a directory: %s" % clean_path
)
-
resolved_paths.append(validated_path)
except (ValueError, OSError) as e:
logger.error("Invalid path '%s': %s", clean_path, e)
raise HTTPException(status_code=400, detail="Invalid path: %s" % clean_path)
- try:
- stats = await asyncio.to_thread(_run_index_job, resolved_paths, config, resolved_db)
- except Exception as exc: # pragma: no cover - defensive
- LOGGER.exception("Indexing failed: %s", exc)
- raise HTTPException(status_code=500, detail=str(exc)) from exc
+ return resolved_paths
+
+
+@app.post("/index")
+async def index_documents(payload: IndexPayload) -> dict[str, Any]:
+ """Start an indexing job and return its ID immediately for progress polling."""
+ if not payload.paths:
+ raise HTTPException(status_code=400, detail="No path provided")
+
+ config_defaults = AppConfig()
+ config = AppConfig(
+ db_path=Path(payload.db) if payload.db is not None else config_defaults.db_path,
+ model_name=payload.model or config_defaults.model_name,
+ chunk_chars=payload.chunk_chars or config_defaults.chunk_chars,
+ overlap=payload.overlap or config_defaults.overlap,
+ )
+ resolved_db = config.resolve_db_path(Path.cwd())
+ _ensure_db_parent(resolved_db)
+
+ resolved_paths = _validate_index_paths(payload)
+
+ job_id = str(uuid.uuid4())
+ job: dict[str, Any] = {
+ "id": job_id,
+ "status": "running",
+ "processed": 0,
+ "total": 0,
+ "current_file": "",
+ "stats": None,
+ "error": None,
+ }
+ _index_jobs[job_id] = job
- return {"status": "ok", "db": str(resolved_db), "stats": stats}
+ async def _run() -> None:
+ try:
+ result = await asyncio.to_thread(
+ _run_index_job, resolved_paths, config, resolved_db, job
+ )
+ job["status"] = "complete"
+ job["stats"] = result
+ except Exception as exc:
+ LOGGER.exception("Indexing job %s failed: %s", job_id, exc)
+ job["status"] = "error"
+ job["error"] = str(exc)
+
+ asyncio.create_task(_run())
+ return {"status": "ok", "job_id": job_id}
+
+
+@app.get("/index/status/{job_id}")
+async def get_index_status(job_id: str) -> dict[str, Any]:
+ """Poll the status of a running or completed indexing job."""
+ job = _index_jobs.get(job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail="Job not found")
+ return job
diff --git a/src/docfinder/web/templates/index.html b/src/docfinder/web/templates/index.html
index 34da2f6..58a8d91 100644
--- a/src/docfinder/web/templates/index.html
+++ b/src/docfinder/web/templates/index.html
@@ -8,67 +8,86 @@
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
- --primary-light: rgba(37, 99, 235, 0.1);
+ --primary-light: rgba(37, 99, 235, 0.12);
--danger: #dc2626;
--danger-hover: #b91c1c;
--danger-light: rgba(220, 38, 38, 0.1);
--success: #16a34a;
--success-light: rgba(22, 163, 74, 0.1);
--warning: #d97706;
- --bg: #f8fafc;
+ --bg: #f1f5f9;
--bg-card: #ffffff;
- --text: #1e293b;
+ --bg-input: #f8fafc;
+ --text: #0f172a;
--text-muted: #64748b;
- --border: rgba(148, 163, 184, 0.3);
- --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
- --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ --text-subtle: #94a3b8;
+ --border: rgba(148, 163, 184, 0.25);
+ --border-focus: rgba(37, 99, 235, 0.5);
+ --shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
+ --shadow: 0 1px 3px rgba(0,0,0,0.07), 0 4px 12px rgba(0,0,0,0.04);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.10), 0 2px 6px rgba(0,0,0,0.06);
+ --shadow-xl: 0 20px 48px rgba(0,0,0,0.14);
--radius: 12px;
--radius-sm: 8px;
+ --radius-xs: 6px;
+ --header-h: 60px;
color-scheme: light dark;
}
-
+
@media (prefers-color-scheme: dark) {
:root {
- --bg: #0f172a;
- --bg-card: #1e293b;
- --text: #f1f5f9;
- --text-muted: #94a3b8;
- --border: rgba(148, 163, 184, 0.2);
- --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
- --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
+ --bg: #0c1120;
+ --bg-card: #151f32;
+ --bg-input: #1a2540;
+ --text: #e8edf5;
+ --text-muted: #8a9bbf;
+ --text-subtle: #536180;
+ --border: rgba(148, 163, 184, 0.12);
+ --border-focus: rgba(96, 165, 250, 0.5);
+ --shadow-xs: 0 1px 2px rgba(0,0,0,0.3);
+ --shadow: 0 1px 3px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.25);
+ --shadow-lg: 0 8px 24px rgba(0,0,0,0.45), 0 2px 6px rgba(0,0,0,0.3);
+ --shadow-xl: 0 20px 48px rgba(0,0,0,0.6);
+ --primary: #3b82f6;
+ --primary-hover: #2563eb;
+ --primary-light: rgba(59, 130, 246, 0.15);
}
}
- * {
- box-sizing: border-box;
- }
+ * { box-sizing: border-box; margin: 0; padding: 0; }
body {
- margin: 0;
- padding: 0;
background: var(--bg);
color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- line-height: 1.6;
- }
-
- .container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 0 1.5rem;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ font-size: 15px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
}
+ /* ── Header ─────────────────────────────────────────────────────────── */
header {
- background: var(--bg-card);
+ height: var(--header-h);
+ background: rgba(255,255,255,0.75);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border);
- padding: 1rem 0;
position: sticky;
top: 0;
- z-index: 100;
- box-shadow: var(--shadow);
+ z-index: 200;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ header {
+ background: rgba(21,31,50,0.8);
+ }
}
.header-content {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+ height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
@@ -78,79 +97,89 @@
.logo {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: 0.6rem;
+ text-decoration: none;
+ flex-shrink: 0;
}
.logo-icon {
- width: 40px;
- height: 40px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- border-radius: var(--radius-sm);
+ width: 34px;
+ height: 34px;
+ background: linear-gradient(135deg, #667eea, #764ba2);
+ border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
- padding: 4px;
+ box-shadow: 0 2px 8px rgba(118,75,162,0.35);
}
- h1 {
- font-size: 1.5rem;
+ .logo h1 {
+ font-size: 1.15rem;
font-weight: 700;
- margin: 0;
background: linear-gradient(135deg, var(--primary), #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
+ letter-spacing: -0.02em;
}
+ /* ── Tabs ─────────────────────────────────────────────────────────────── */
.tabs {
display: flex;
- gap: 0.5rem;
+ gap: 2px;
background: var(--bg);
- padding: 0.25rem;
- border-radius: var(--radius);
+ border: 1px solid var(--border);
+ padding: 3px;
+ border-radius: calc(var(--radius-sm) + 3px);
}
.tab {
- padding: 0.5rem 1rem;
+ padding: 0.4rem 1rem;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
+ font-size: 0.875rem;
font-weight: 500;
- transition: all 0.2s;
+ transition: all 0.18s ease;
+ white-space: nowrap;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
}
- .tab:hover {
- color: var(--text);
- background: var(--bg-card);
- }
+ .tab:hover { color: var(--text); background: var(--bg-card); }
.tab.active {
background: var(--bg-card);
color: var(--primary);
- box-shadow: var(--shadow);
+ box-shadow: var(--shadow-xs);
}
+ /* ── Main layout ──────────────────────────────────────────────────────── */
main {
- padding: 2rem 0;
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 1.75rem 1.5rem 3rem;
}
- .section {
- display: none;
- }
+ .section { display: none; }
+ .section.active { display: block; animation: fadeUp 0.2s ease both; }
- .section.active {
- display: block;
+ @keyframes fadeUp {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
}
+ /* ── Card ─────────────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border-radius: var(--radius);
- padding: 1.5rem;
+ padding: 1.4rem 1.5rem;
box-shadow: var(--shadow);
border: 1px solid var(--border);
- margin-bottom: 1.5rem;
+ margin-bottom: 1.25rem;
}
.card-header {
@@ -161,190 +190,220 @@
}
.card-title {
- font-size: 1.1rem;
+ font-size: 0.95rem;
font-weight: 600;
- margin: 0;
+ color: var(--text);
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: 0.45rem;
}
- .card-title .icon {
- font-size: 1.25rem;
+ /* ── Search ───────────────────────────────────────────────────────────── */
+ .search-wrap {
+ position: relative;
+ display: flex;
+ align-items: center;
}
- .search-container {
- position: relative;
+ .search-icon {
+ position: absolute;
+ left: 1rem;
+ color: var(--text-subtle);
+ font-size: 1.1rem;
+ pointer-events: none;
+ display: flex;
}
.search-input {
width: 100%;
- padding: 1rem 1.25rem 1rem 3rem;
- border: 2px solid var(--border);
+ padding: 0.875rem 5.5rem 0.875rem 3rem;
+ border: 1.5px solid var(--border);
border-radius: var(--radius);
- font-size: 1.1rem;
+ font-size: 1.05rem;
background: var(--bg-card);
color: var(--text);
- transition: all 0.2s;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ outline: none;
}
+ .search-input::placeholder { color: var(--text-subtle); }
+
.search-input:focus {
- outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
- .search-icon {
- position: absolute;
- left: 1rem;
- top: 50%;
- transform: translateY(-50%);
- font-size: 1.25rem;
- color: var(--text-muted);
- }
-
.search-btn {
position: absolute;
right: 0.5rem;
- top: 50%;
- transform: translateY(-50%);
- }
-
- .form-row {
- display: flex;
- gap: 0.75rem;
- flex-wrap: wrap;
- }
-
- .form-input {
- flex: 1;
- min-width: 200px;
- padding: 0.75rem 1rem;
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- font-size: 1rem;
- background: var(--bg);
- color: var(--text);
- transition: all 0.2s;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
}
- .form-input:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px var(--primary-light);
+ /* Results count */
+ .results-meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 1rem;
+ padding: 0 0.25rem;
}
+ /* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 1.25rem;
+ gap: 0.4rem;
+ padding: 0.6rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
- font-size: 0.95rem;
+ font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
- transition: all 0.2s;
+ transition: all 0.15s ease;
white-space: nowrap;
+ text-decoration: none;
}
.btn-primary {
- background: linear-gradient(135deg, var(--primary), var(--primary-hover));
- color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
+ color: #fff;
+ box-shadow: 0 1px 3px rgba(37,99,235,0.3);
}
- .btn-primary:hover:not(.search-btn) {
+ .btn-primary:hover:not(:disabled) {
+ filter: brightness(1.08);
+ box-shadow: 0 3px 10px rgba(37,99,235,0.4);
transform: translateY(-1px);
- box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
- }
-
- .search-btn:hover {
- box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
- filter: brightness(1.1);
}
.btn-secondary {
background: transparent;
color: var(--primary);
- border: 1px solid var(--primary);
+ border: 1.5px solid var(--border);
}
- .btn-secondary:hover {
- background: var(--primary-light);
- }
+ .btn-secondary:hover:not(:disabled) { background: var(--primary-light); border-color: var(--primary); }
- .btn-danger {
- background: var(--danger);
- color: white;
- }
-
- .btn-danger:hover {
- background: var(--danger-hover);
- }
+ .btn-danger { background: var(--danger); color: #fff; }
+ .btn-danger:hover:not(:disabled) { background: var(--danger-hover); }
.btn-ghost {
background: transparent;
color: var(--text-muted);
- padding: 0.5rem;
+ padding: 0.4rem 0.6rem;
+ }
+
+ .btn-ghost:hover:not(:disabled) { color: var(--danger); background: var(--danger-light); }
+
+ .btn-sm { padding: 0.4rem 0.75rem; font-size: 0.8rem; }
+
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; filter: none !important; }
+
+ /* ── Forms ────────────────────────────────────────────────────────────── */
+ .form-row {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
}
- .btn-ghost:hover {
- color: var(--danger);
- background: var(--danger-light);
+ .form-input {
+ flex: 1;
+ min-width: 220px;
+ padding: 0.65rem 0.9rem;
+ border: 1.5px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 0.95rem;
+ background: var(--bg-input);
+ color: var(--text);
+ transition: border-color 0.15s, box-shadow 0.15s;
+ outline: none;
}
- .btn-sm {
- padding: 0.5rem 0.75rem;
- font-size: 0.85rem;
+ .form-input::placeholder { color: var(--text-subtle); }
+
+ .form-input:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px var(--primary-light);
}
- .btn:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- transform: none !important;
+ .form-input.drag-over {
+ border-color: var(--primary);
+ background: var(--primary-light);
+ box-shadow: 0 0 0 3px var(--primary-light);
}
- .status-badge {
+ /* ── Status badges ────────────────────────────────────────────────────── */
+ .badge {
display: inline-flex;
align-items: center;
- gap: 0.35rem;
- padding: 0.35rem 0.75rem;
+ gap: 0.3rem;
+ padding: 0.3rem 0.7rem;
border-radius: 9999px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ }
+
+ .badge-success { background: var(--success-light); color: var(--success); }
+ .badge-error { background: var(--danger-light); color: var(--danger); }
+ .badge-info { background: var(--primary-light); color: var(--primary); }
+
+ /* ── Progress bar ─────────────────────────────────────────────────────── */
+ .progress-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ padding: 1rem 0 0.25rem;
+ }
+
+ .progress-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
font-size: 0.85rem;
- font-weight: 500;
}
- .status-success {
- background: var(--success-light);
- color: var(--success);
+ .progress-label { font-weight: 500; }
+ .progress-count { color: var(--text-muted); }
+
+ .progress-bar-bg {
+ height: 6px;
+ background: var(--border);
+ border-radius: 9999px;
+ overflow: hidden;
}
- .status-error {
- background: var(--danger-light);
- color: var(--danger);
+ .progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--primary), #7c3aed);
+ border-radius: 9999px;
+ transition: width 0.4s ease;
+ min-width: 4px;
}
- .status-info {
- background: var(--primary-light);
- color: var(--primary);
+ .progress-file {
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ font-family: "SF Mono", "Monaco", "Roboto Mono", monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
+ /* ── Results ──────────────────────────────────────────────────────────── */
.results-list {
list-style: none;
- padding: 0;
- margin: 0;
display: flex;
flex-direction: column;
- gap: 1rem;
+ gap: 0.85rem;
}
.result-card {
background: var(--bg-card);
border-radius: var(--radius);
- padding: 1.25rem;
+ padding: 1.15rem 1.25rem;
box-shadow: var(--shadow);
border: 1px solid var(--border);
- transition: all 0.2s;
+ transition: box-shadow 0.18s, transform 0.18s;
}
.result-card:hover {
@@ -357,40 +416,39 @@
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
- margin-bottom: 0.75rem;
+ margin-bottom: 0.5rem;
}
.result-title {
- font-size: 1.1rem;
+ font-size: 1rem;
font-weight: 600;
- margin: 0;
color: var(--text);
}
.result-score {
background: linear-gradient(135deg, var(--primary), #7c3aed);
- color: white;
- padding: 0.25rem 0.75rem;
+ color: #fff;
+ padding: 0.2rem 0.65rem;
border-radius: 9999px;
- font-size: 0.85rem;
- font-weight: 600;
- white-space: nowrap;
+ font-size: 0.78rem;
+ font-weight: 700;
+ flex-shrink: 0;
}
.result-path {
- font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace;
- font-size: 0.8rem;
+ font-family: "SF Mono", "Monaco", "Roboto Mono", monospace;
+ font-size: 0.75rem;
color: var(--text-muted);
- margin-bottom: 0.75rem;
+ margin-bottom: 0.6rem;
word-break: break-all;
}
.result-snippet {
- font-size: 0.95rem;
+ font-size: 0.9rem;
color: var(--text);
line-height: 1.6;
background: var(--bg);
- padding: 0.75rem 1rem;
+ padding: 0.65rem 0.9rem;
border-radius: var(--radius-sm);
border-left: 3px solid var(--primary);
}
@@ -398,58 +456,56 @@
.result-actions {
display: flex;
gap: 0.5rem;
- margin-top: 1rem;
+ margin-top: 0.85rem;
}
+ /* ── Documents table ──────────────────────────────────────────────────── */
.documents-table {
width: 100%;
border-collapse: collapse;
+ font-size: 0.875rem;
}
.documents-table th,
.documents-table td {
- padding: 0.75rem 1rem;
+ padding: 0.65rem 0.9rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.documents-table th {
font-weight: 600;
- color: var(--text-muted);
- font-size: 0.85rem;
+ color: var(--text-subtle);
+ font-size: 0.75rem;
text-transform: uppercase;
- letter-spacing: 0.05em;
- }
-
- .documents-table tr:hover td {
+ letter-spacing: 0.06em;
background: var(--bg);
}
- .doc-title {
- font-weight: 500;
- color: var(--text);
- }
+ .documents-table tr:last-child td { border-bottom: none; }
+
+ .documents-table tbody tr:hover td { background: var(--bg); }
+
+ .doc-title { font-weight: 500; }
.doc-path {
- font-family: monospace;
- font-size: 0.8rem;
+ font-family: "SF Mono", "Monaco", monospace;
+ font-size: 0.75rem;
color: var(--text-muted);
- max-width: 300px;
+ max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
- .doc-meta {
- font-size: 0.85rem;
- color: var(--text-muted);
- }
+ .doc-meta { color: var(--text-muted); }
+ /* ── Stats ────────────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 1rem;
- margin-bottom: 1.5rem;
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ gap: 0.85rem;
+ margin-bottom: 1.25rem;
}
.stat-card {
@@ -457,314 +513,518 @@
border-radius: var(--radius-sm);
padding: 1rem;
text-align: center;
+ border: 1px solid var(--border);
}
.stat-value {
- font-size: 1.75rem;
+ font-size: 1.6rem;
font-weight: 700;
color: var(--primary);
+ letter-spacing: -0.02em;
}
.stat-label {
- font-size: 0.85rem;
+ font-size: 0.78rem;
color: var(--text-muted);
- margin-top: 0.25rem;
+ margin-top: 0.2rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
+ /* ── Empty / loading states ───────────────────────────────────────────── */
.empty-state {
text-align: center;
- padding: 3rem;
+ padding: 3.5rem 1rem;
color: var(--text-muted);
}
- .empty-state .icon {
- font-size: 3rem;
- margin-bottom: 1rem;
- opacity: 0.5;
- }
+ .empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.45; }
.loading {
display: flex;
align-items: center;
justify-content: center;
- gap: 0.75rem;
- padding: 2rem;
+ gap: 0.65rem;
+ padding: 2.5rem;
color: var(--text-muted);
+ font-size: 0.9rem;
}
.spinner {
- width: 24px;
- height: 24px;
- border: 3px solid var(--border);
+ width: 20px;
+ height: 20px;
+ border: 2.5px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
- animation: spin 1s linear infinite;
+ animation: spin 0.8s linear infinite;
+ flex-shrink: 0;
}
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
+ @keyframes spin { to { transform: rotate(360deg); } }
+ /* ── Toast ────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
- bottom: 2rem;
- right: 2rem;
- padding: 1rem 1.5rem;
+ bottom: 1.75rem;
+ right: 1.75rem;
+ padding: 0.8rem 1.25rem;
border-radius: var(--radius);
- box-shadow: var(--shadow-lg);
- z-index: 1000;
- animation: slideIn 0.3s ease-out;
+ box-shadow: var(--shadow-xl);
+ z-index: 9999;
+ font-size: 0.875rem;
+ font-weight: 500;
+ animation: toastIn 0.25s cubic-bezier(0.34,1.56,0.64,1) both;
+ max-width: 320px;
}
- .toast.success {
- background: var(--success);
- color: white;
- }
+ .toast.success { background: #166534; color: #dcfce7; }
+ .toast.error { background: #7f1d1d; color: #fee2e2; }
+ .toast.info { background: #1e3a5f; color: #dbeafe; }
- .toast.error {
- background: var(--danger);
- color: white;
+ @media (prefers-color-scheme: dark) {
+ .toast.success { background: #14532d; }
+ .toast.error { background: #7f1d1d; }
}
- @keyframes slideIn {
- from {
- transform: translateX(100%);
- opacity: 0;
- }
- to {
- transform: translateX(0);
- opacity: 1;
- }
+ @keyframes toastIn {
+ from { opacity: 0; transform: translateY(12px) scale(0.95); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
}
+ /* ── Modal ────────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.5);
+ background: rgba(0,0,0,0.45);
+ backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
- transition: all 0.2s;
+ transition: opacity 0.2s, visibility 0.2s;
}
- .modal-overlay.active {
- opacity: 1;
- visibility: visible;
- }
+ .modal-overlay.active { opacity: 1; visibility: visible; }
.modal {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.5rem;
- max-width: 400px;
- width: 90%;
- box-shadow: var(--shadow-lg);
- transform: scale(0.9);
- transition: transform 0.2s;
+ max-width: 380px;
+ width: 92%;
+ box-shadow: var(--shadow-xl);
+ border: 1px solid var(--border);
+ transform: scale(0.92);
+ transition: transform 0.2s cubic-bezier(0.34,1.56,0.64,1);
}
- .modal-overlay.active .modal {
- transform: scale(1);
+ .modal-overlay.active .modal { transform: scale(1); }
+
+ .modal-title { font-size: 1.05rem; font-weight: 600; margin-bottom: 0.6rem; }
+ .modal-text { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
+
+ .modal-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
+
+ /* ── Bulk actions ─────────────────────────────────────────────────────── */
+ .select-all-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.6rem 0.75rem;
+ background: var(--bg);
+ border-radius: var(--radius-sm);
+ margin-bottom: 0.85rem;
+ border: 1px solid var(--border);
}
- .modal-title {
- font-size: 1.1rem;
- font-weight: 600;
- margin: 0 0 0.75rem;
+ .checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.875rem;
+ cursor: pointer;
}
- .modal-text {
+ /* ── Tips list ────────────────────────────────────────────────────────── */
+ .tips-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
color: var(--text-muted);
- margin-bottom: 1.5rem;
+ font-size: 0.875rem;
}
- .modal-actions {
+ .tips-list li {
display: flex;
- gap: 0.75rem;
- justify-content: flex-end;
+ gap: 0.5rem;
}
- .checkbox-row {
+ .tips-list li::before { content: "→"; color: var(--primary); flex-shrink: 0; }
+
+ /* ── Stats row ────────────────────────────────────────────────────────── */
+ .index-stats-row {
+ display: flex;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+ margin-top: 0.85rem;
+ }
+
+ .stat-item {
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 0;
+ gap: 0.35rem;
+ font-size: 0.875rem;
+ color: var(--text-muted);
}
- .select-all-container {
+ .stat-item strong { color: var(--text); }
+
+ /* ── Settings ────────────────────────────────────────────────────────── */
+ .setting-row {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 0.75rem 1rem;
+ gap: 1rem;
+ padding: 0.9rem 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .setting-row:last-of-type { border-bottom: none; }
+
+ .setting-info { display: flex; flex-direction: column; gap: 0.2rem; }
+
+ .setting-label { font-weight: 500; font-size: 0.95rem; }
+
+ .setting-sub { font-size: 0.8rem; color: var(--text-muted); }
+
+ .hotkey-display-wrap { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; }
+
+ .hotkey-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.3rem 0.75rem;
background: var(--bg);
+ border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
- margin-bottom: 1rem;
+ font-family: "SF Mono", "Monaco", "Roboto Mono", monospace;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text);
+ letter-spacing: 0.03em;
+ min-width: 80px;
+ text-align: center;
+ justify-content: center;
}
- @media (max-width: 768px) {
- .header-content {
- flex-direction: column;
- align-items: stretch;
- }
+ /* iOS-style toggle */
+ .toggle {
+ position: relative;
+ display: inline-block;
+ width: 44px;
+ height: 26px;
+ flex-shrink: 0;
+ }
- .tabs {
- justify-content: center;
- }
+ .toggle input { opacity: 0; width: 0; height: 0; }
- .documents-table {
- display: block;
- overflow-x: auto;
- }
+ .toggle-slider {
+ position: absolute;
+ inset: 0;
+ background: var(--border);
+ border-radius: 9999px;
+ cursor: pointer;
+ transition: background 0.2s;
+ }
- .form-row {
- flex-direction: column;
- }
+ .toggle-slider::before {
+ content: '';
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ left: 3px;
+ bottom: 3px;
+ background: white;
+ border-radius: 50%;
+ transition: transform 0.2s;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.25);
+ }
- .result-header {
- flex-direction: column;
- gap: 0.5rem;
- }
+ .toggle input:checked + .toggle-slider { background: var(--primary); }
+ .toggle input:checked + .toggle-slider::before { transform: translateX(18px); }
+
+ /* Hotkey capture area */
+ .hotkey-capture-area {
+ min-height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px dashed var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 0.95rem;
+ color: var(--text-muted);
+ margin: 1rem 0;
+ transition: all 0.18s;
+ user-select: none;
+ }
+
+ .hotkey-capture-area.waiting {
+ border-color: var(--primary);
+ background: var(--primary-light);
+ animation: blink 1.4s ease infinite;
+ }
+
+ .hotkey-capture-area.captured {
+ border-style: solid;
+ border-color: var(--success);
+ background: var(--success-light);
+ color: var(--text);
+ font-family: "SF Mono", "Monaco", monospace;
+ font-weight: 700;
+ font-size: 1.1rem;
+ }
+
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.55} }
+
+ .info-box {
+ margin-top: 1rem;
+ padding: 0.75rem 1rem;
+ background: var(--primary-light);
+ border-left: 3px solid var(--primary);
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ line-height: 1.5;
+ }
+
+ /* ── Responsive ───────────────────────────────────────────────────────── */
+ @media (max-width: 640px) {
+ .header-content { flex-wrap: wrap; height: auto; padding: 0.6rem 1rem; gap: 0.5rem; }
+ header { height: auto; }
+ .tabs { width: 100%; }
+ .tab { flex: 1; justify-content: center; }
+ .form-row { flex-direction: column; }
+ .result-header { flex-direction: column; gap: 0.4rem; }
+ .documents-table { display: block; overflow-x: auto; }
}