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 @@ [![CodeQL](https://img.shields.io/github/actions/workflow/status/filippostanghellini/DocFinder/codeql.yml?branch=main&label=CodeQL&logo=github)](https://github.com/filippostanghellini/DocFinder/actions/workflows/codeql.yml) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)](https://www.python.org/downloads/) -[![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -[![Stars](https://img.shields.io/github/stars/filippostanghellini/DocFinder?style=social)](https://github.com/filippostanghellini/DocFinder/stargazers) [![Release](https://img.shields.io/github/v/release/filippostanghellini/DocFinder?logo=github)](https://github.com/filippostanghellini/DocFinder/releases) [![Downloads](https://img.shields.io/github/downloads/filippostanghellini/DocFinder/total?logo=github)](https://github.com/filippostanghellini/DocFinder/releases)

- DocFinder Logo + DocFinder Logo

- 🔍 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 + + + + + +
SearchIndex
-- **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** -![Search](images/search.png) - -**Index Documents** -![Index Documents](images/index-documents.png) - -**Database** -![Database](images/database-documents.png) +**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; } }
-
+
-
- - - -
+
-
- -
+
+ +
-
- 🔍 - - + +
+ + + + + +
-
-
    -
    + +
      - -
      + +
      -

      📁 Index Documents

      +

      + + Index Documents +

      - +
      -
      -

      💡 Tips

      -
      -
        -
      • Use absolute paths (e.g. /Users/name/Documents)
      • -
      • You can index entire folders or individual PDF files
      • -
      • Already indexed documents are automatically updated if modified
      • -
      • Unchanged files are skipped to speed up the process
      • +

        + + Tips +

        +
          +
        • Use absolute paths — e.g. /Users/name/Documents
        • +
        • Index entire folders: all PDFs are discovered recursively
        • +
        • Use the Browse… button to open a native folder picker
        • +
        • Or drag & drop a folder from Finder / Explorer onto the input field
        • +
        • Already-indexed documents are automatically updated if modified
        • +
        • Unchanged files are skipped — re-indexing is fast
      - -
      + +
      -

      📚 Indexed Documents

      -
      +

      + + Indexed Library +

      +
      - +
      -
      + + +
      +
      +
      +

      + + Settings +

      +
      + +

      + Global Search Shortcut +

      +

      + Press this shortcut from anywhere on your desktop to bring DocFinder to the front and jump directly to search. +

      + +
      +
      + Enable global shortcut + +
      + +
      + +
      +
      + Shortcut +
      +
      + + +
      +
      + + + +
      + +
      +
      +
      - -