feat: SHIFT-CMD-F zoek functionaliteit toegevoegd
This commit is contained in:
@@ -0,0 +1,212 @@
|
|||||||
|
# Search v1
|
||||||
|
|
||||||
|
## 1. Doel
|
||||||
|
|
||||||
|
Search voegt nu waarde toe omdat de app inmiddels bruikbaar is als dual-pane file manager, maar nog volledig afhankelijk is van handmatige navigatie. Bij grotere directory trees kost dat te veel stappen.
|
||||||
|
|
||||||
|
Search moet passen binnen de bestaande dual-pane workflow:
|
||||||
|
- zoeken gebeurt vanuit het actieve paneel
|
||||||
|
- resultaten helpen navigatie versnellen
|
||||||
|
- de browse-workspace blijft het primaire werkvlak
|
||||||
|
- search is ondersteunend, geen nieuw hoofdscherm
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
Search v1 ondersteunt:
|
||||||
|
- zoeken op bestandsnaam
|
||||||
|
- zoeken op mapnaam
|
||||||
|
- case-insensitive substring matching
|
||||||
|
|
||||||
|
Search v1 ondersteunt niet:
|
||||||
|
- full-text content search
|
||||||
|
- indexer of achtergrondindexering
|
||||||
|
- regex
|
||||||
|
- geavanceerde querytaal
|
||||||
|
- saved searches
|
||||||
|
- fuzzy ranking
|
||||||
|
|
||||||
|
Aanbevolen zoekmodel voor v1:
|
||||||
|
- eenvoudige naamzoeking met substring match
|
||||||
|
- optioneel later uit te breiden naar glob of prefix filters, maar niet in deze slice
|
||||||
|
|
||||||
|
## 3. Zoekbereik
|
||||||
|
|
||||||
|
Mogelijke richtingen:
|
||||||
|
- zoeken vanaf `current path`
|
||||||
|
- zoeken vanaf de root van het actieve paneel
|
||||||
|
|
||||||
|
Aanbeveling voor v1:
|
||||||
|
- standaard zoeken vanaf `current path` van het actieve paneel
|
||||||
|
|
||||||
|
Reden:
|
||||||
|
- laag regressierisico
|
||||||
|
- voorspelbaar voor de gebruiker
|
||||||
|
- technisch goedkoper
|
||||||
|
- minder kans op trage scans van volledige opslagvolumes
|
||||||
|
- sluit goed aan op de huidige paneel-context
|
||||||
|
|
||||||
|
Gedrag:
|
||||||
|
- `activePane` bepaalt de zoekcontext
|
||||||
|
- de query zoekt recursief onder `currentPath` van dat paneel
|
||||||
|
- resultaten horen dus altijd bij de context van het actieve paneel op het moment van starten
|
||||||
|
|
||||||
|
Later uitbreidbaar:
|
||||||
|
- een simpele scope-toggle `Current folder tree` versus `Current root`
|
||||||
|
|
||||||
|
## 4. UI-richting
|
||||||
|
|
||||||
|
Aanbevolen v1-richting:
|
||||||
|
- aparte compacte search-modal
|
||||||
|
- geen verstoring van de dual-pane layout
|
||||||
|
|
||||||
|
Startmechanisme:
|
||||||
|
- alleen keyboard shortcut
|
||||||
|
- geen zichtbare knop in topbar
|
||||||
|
- geen zichtbare knop in functiebalk
|
||||||
|
|
||||||
|
Aanbevolen shortcut voor v1:
|
||||||
|
- Mac: `Cmd+Shift+F`
|
||||||
|
- Windows/Linux: `Ctrl+Shift+F`
|
||||||
|
|
||||||
|
Reden:
|
||||||
|
- sluit goed aan op bestaande zoekverwachtingen
|
||||||
|
- vermijdt extra visuele drukte in de UI
|
||||||
|
- vermijdt conflict met bestaande F1-F8 functiebalklogica
|
||||||
|
|
||||||
|
Modal-inhoud:
|
||||||
|
- titel `Search`
|
||||||
|
- actieve paneelcontext tonen
|
||||||
|
- zoekveld
|
||||||
|
- korte contextregel: `Searching under: <current path>`
|
||||||
|
- resultatenlijst onder het invoerveld
|
||||||
|
|
||||||
|
Gedrag bij resultaatklik:
|
||||||
|
- klik op resultaat opent de parentlocatie in het actieve paneel
|
||||||
|
- en selecteert het gevonden item
|
||||||
|
|
||||||
|
Voor directories:
|
||||||
|
- resultaatklik navigeert naar die directory in het actieve paneel
|
||||||
|
|
||||||
|
Voor files:
|
||||||
|
- resultaatklik navigeert naar de parent directory in het actieve paneel
|
||||||
|
- en selecteert de file
|
||||||
|
|
||||||
|
Dit sluit aan op de bestaande browse- en selectieflow zonder extra viewer/open-semantiek.
|
||||||
|
|
||||||
|
## 5. Resultaatsemantiek
|
||||||
|
|
||||||
|
Minimale velden per resultaat:
|
||||||
|
- `name`
|
||||||
|
- `path`
|
||||||
|
- `type` (`file` of `directory`)
|
||||||
|
- `parent_path`
|
||||||
|
- `root` of equivalent contextveld
|
||||||
|
|
||||||
|
Weergave in v1:
|
||||||
|
- primaire regel: naam
|
||||||
|
- secundaire regel: parent path of volledig path
|
||||||
|
- compacte type-indicatie (`file` / `dir`)
|
||||||
|
|
||||||
|
Aanbevolen presentatie:
|
||||||
|
- naam prominent
|
||||||
|
- parent path als muted secundaire regel
|
||||||
|
- type als kleine badge of label
|
||||||
|
|
||||||
|
## 6. Backend-impact
|
||||||
|
|
||||||
|
Aanbevolen endpoint:
|
||||||
|
- `GET /api/search?path=...&query=...`
|
||||||
|
|
||||||
|
Waarom een nieuw endpoint nodig is:
|
||||||
|
- browse-endpoint is contractueel directory listing
|
||||||
|
- search vereist recursieve traversal en resultaatlimieten
|
||||||
|
- aparte fout- en limietsemantiek is logisch
|
||||||
|
|
||||||
|
Securitymodel:
|
||||||
|
- `path` moet via bestaande `path_guard`
|
||||||
|
- zoekroot moet binnen whitelist vallen
|
||||||
|
- traversal en invalid root alias blijven via bestaande validatie lopen
|
||||||
|
- geen vrije scan buiten geconfigureerde roots
|
||||||
|
|
||||||
|
Implementatierichting:
|
||||||
|
- backend scant recursief vanaf gevalideerde startdirectory
|
||||||
|
- vergelijkt naam case-insensitive met query
|
||||||
|
- retourneert gemaximeerde lijst van matches
|
||||||
|
|
||||||
|
## 7. Performance en risico
|
||||||
|
|
||||||
|
Grootste risico:
|
||||||
|
- diepe directory trees op grote volumes
|
||||||
|
|
||||||
|
Aanbevolen v1-beheersing:
|
||||||
|
- result limit, bijvoorbeeld `100`
|
||||||
|
- optionele harde scan limit op aantal bezochte entries
|
||||||
|
- geen parallelle scanner
|
||||||
|
- geen indexer
|
||||||
|
|
||||||
|
Aanbevolen gedrag bij limiet:
|
||||||
|
- response bevat `truncated: true/false`
|
||||||
|
- als limiet bereikt is, toon alleen eerste resultaten
|
||||||
|
|
||||||
|
Timeouts:
|
||||||
|
- geen aparte timeoutsemantiek nodig in eerste slice als result-limiet en current-path scope klein blijven
|
||||||
|
|
||||||
|
## 8. Regressierisico
|
||||||
|
|
||||||
|
Belangrijkste risico’s:
|
||||||
|
- browse-flow mag niet veranderen
|
||||||
|
- selectieflow mag niet onverwacht resetten buiten het actieve paneel
|
||||||
|
- keyboardflow mag niet botsen met bestaande shortcuts
|
||||||
|
- modal focus moet bestaande paneelnavigatie blokkeren zolang search open is
|
||||||
|
|
||||||
|
Beperkende keuzes die risico laag houden:
|
||||||
|
- aparte modal
|
||||||
|
- zoeken alleen op naam
|
||||||
|
- starten vanuit actief paneel
|
||||||
|
- resultaten openen in bestaand paneelgedrag
|
||||||
|
|
||||||
|
## 9. Teststrategie
|
||||||
|
|
||||||
|
Backend golden tests:
|
||||||
|
- lege resultaatlijst
|
||||||
|
- simpele file match
|
||||||
|
- simpele directory match
|
||||||
|
- search onder current path
|
||||||
|
- traversal geblokkeerd
|
||||||
|
- path not found
|
||||||
|
- invalid root alias
|
||||||
|
- result limit / truncated gedrag
|
||||||
|
|
||||||
|
UI smoke/regressietests:
|
||||||
|
- search-modal container aanwezig
|
||||||
|
- zoekveld aanwezig
|
||||||
|
- resultaatlijst container aanwezig
|
||||||
|
- keyboard wiring voor `Cmd/Ctrl+Shift+F`
|
||||||
|
|
||||||
|
Handmatige validatie:
|
||||||
|
- zoeken vanuit links paneel raakt alleen linkse context
|
||||||
|
- zoeken vanuit rechts paneel raakt alleen rechtse context
|
||||||
|
- resultaatklik navigeert correct
|
||||||
|
- resultaatklik op directory opent directory
|
||||||
|
- resultaatklik op file opent parent + selecteert file
|
||||||
|
- `Escape` sluit modal
|
||||||
|
|
||||||
|
## 10. Aanbeveling
|
||||||
|
|
||||||
|
Aanbevolen v1-richting met laag regressierisico:
|
||||||
|
- nieuw backend-endpoint `GET /api/search`
|
||||||
|
- recursieve naamzoeking
|
||||||
|
- scope = `currentPath` van actief paneel
|
||||||
|
- case-insensitive substring match
|
||||||
|
- harde result limit, bijvoorbeeld `100`
|
||||||
|
- aparte compacte search-modal
|
||||||
|
- resultaatklik hergebruikt bestaande paneelnavigatie en selectie
|
||||||
|
|
||||||
|
Niet aanbevelen voor v1:
|
||||||
|
- root-brede zoeking als default
|
||||||
|
- content search
|
||||||
|
- indexer
|
||||||
|
- regex
|
||||||
|
- aparte zoekpagina
|
||||||
|
|
||||||
|
Dit levert een bruikbare eerste search op zonder de huidige dual-pane file manager semantiek te verstoren.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from backend.app.api.schemas import SearchResponse
|
||||||
|
from backend.app.dependencies import get_search_service
|
||||||
|
from backend.app.services.search_service import SearchService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/search")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SearchResponse)
|
||||||
|
async def search(
|
||||||
|
path: str,
|
||||||
|
query: str,
|
||||||
|
service: SearchService = Depends(get_search_service),
|
||||||
|
) -> SearchResponse:
|
||||||
|
return service.search(path=path, query=query)
|
||||||
@@ -166,3 +166,16 @@ class HistoryItem(BaseModel):
|
|||||||
|
|
||||||
class HistoryListResponse(BaseModel):
|
class HistoryListResponse(BaseModel):
|
||||||
items: list[HistoryItem]
|
items: list[HistoryItem]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
type: str
|
||||||
|
parent_path: str
|
||||||
|
root: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
items: list[SearchResultItem]
|
||||||
|
truncated: bool
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from backend.app.services.copy_task_service import CopyTaskService
|
|||||||
from backend.app.services.file_ops_service import FileOpsService
|
from backend.app.services.file_ops_service import FileOpsService
|
||||||
from backend.app.services.history_service import HistoryService
|
from backend.app.services.history_service import HistoryService
|
||||||
from backend.app.services.move_task_service import MoveTaskService
|
from backend.app.services.move_task_service import MoveTaskService
|
||||||
|
from backend.app.services.search_service import SearchService
|
||||||
from backend.app.services.task_service import TaskService
|
from backend.app.services.task_service import TaskService
|
||||||
from backend.app.tasks_runner import TaskRunner
|
from backend.app.tasks_runner import TaskRunner
|
||||||
|
|
||||||
@@ -95,3 +96,7 @@ async def get_bookmark_service() -> BookmarkService:
|
|||||||
|
|
||||||
async def get_history_service() -> HistoryService:
|
async def get_history_service() -> HistoryService:
|
||||||
return HistoryService(repository=get_history_repository())
|
return HistoryService(repository=get_history_repository())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_search_service() -> SearchService:
|
||||||
|
return SearchService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
||||||
|
|||||||
Binary file not shown.
@@ -29,6 +29,29 @@ class FilesystemAdapter:
|
|||||||
|
|
||||||
return directories, files
|
return directories, files
|
||||||
|
|
||||||
|
def search_names(self, directory: Path, query: str, limit: int) -> tuple[list[dict], bool]:
|
||||||
|
normalized_query = query.lower()
|
||||||
|
results: list[dict] = []
|
||||||
|
|
||||||
|
for root, dirnames, filenames in __import__("os").walk(directory):
|
||||||
|
dirnames[:] = sorted([name for name in dirnames if not name.startswith(".")], key=str.lower)
|
||||||
|
filenames = sorted([name for name in filenames if not name.startswith(".")], key=str.lower)
|
||||||
|
|
||||||
|
root_path = Path(root)
|
||||||
|
for name in dirnames:
|
||||||
|
if normalized_query in name.lower():
|
||||||
|
results.append({"name": name, "kind": "directory", "absolute": root_path / name})
|
||||||
|
if len(results) >= limit:
|
||||||
|
return results, True
|
||||||
|
|
||||||
|
for name in filenames:
|
||||||
|
if normalized_query in name.lower():
|
||||||
|
results.append({"name": name, "kind": "file", "absolute": root_path / name})
|
||||||
|
if len(results) >= limit:
|
||||||
|
return results, True
|
||||||
|
|
||||||
|
return results, False
|
||||||
|
|
||||||
def make_directory(self, path: Path) -> None:
|
def make_directory(self, path: Path) -> None:
|
||||||
path.mkdir(parents=False, exist_ok=False)
|
path.mkdir(parents=False, exist_ok=False)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from backend.app.api.routes_copy import router as copy_router
|
|||||||
from backend.app.api.routes_files import router as files_router
|
from backend.app.api.routes_files import router as files_router
|
||||||
from backend.app.api.routes_history import router as history_router
|
from backend.app.api.routes_history import router as history_router
|
||||||
from backend.app.api.routes_move import router as move_router
|
from backend.app.api.routes_move import router as move_router
|
||||||
|
from backend.app.api.routes_search import router as search_router
|
||||||
from backend.app.api.routes_tasks import router as tasks_router
|
from backend.app.api.routes_tasks import router as tasks_router
|
||||||
from backend.app.logging import configure_logging
|
from backend.app.logging import configure_logging
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ app.include_router(browse_router, prefix="/api")
|
|||||||
app.include_router(files_router, prefix="/api")
|
app.include_router(files_router, prefix="/api")
|
||||||
app.include_router(copy_router, prefix="/api")
|
app.include_router(copy_router, prefix="/api")
|
||||||
app.include_router(move_router, prefix="/api")
|
app.include_router(move_router, prefix="/api")
|
||||||
|
app.include_router(search_router, prefix="/api")
|
||||||
app.include_router(bookmarks_router, prefix="/api")
|
app.include_router(bookmarks_router, prefix="/api")
|
||||||
app.include_router(history_router, prefix="/api")
|
app.include_router(history_router, prefix="/api")
|
||||||
app.include_router(tasks_router, prefix="/api")
|
app.include_router(tasks_router, prefix="/api")
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend.app.api.errors import AppError
|
||||||
|
from backend.app.api.schemas import SearchResponse, SearchResultItem
|
||||||
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
|
from backend.app.security.path_guard import PathGuard
|
||||||
|
|
||||||
|
SEARCH_RESULT_LIMIT = 100
|
||||||
|
SEARCH_MIN_QUERY_LENGTH = 3
|
||||||
|
|
||||||
|
|
||||||
|
class SearchService:
|
||||||
|
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
|
||||||
|
self._path_guard = path_guard
|
||||||
|
self._filesystem = filesystem
|
||||||
|
|
||||||
|
def search(self, path: str, query: str) -> SearchResponse:
|
||||||
|
normalized_query = (query or "").strip()
|
||||||
|
if len(normalized_query) < SEARCH_MIN_QUERY_LENGTH:
|
||||||
|
raise AppError(
|
||||||
|
code="invalid_request",
|
||||||
|
message=f"Query must be at least {SEARCH_MIN_QUERY_LENGTH} characters",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
items: list[SearchResultItem] = []
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
if self._path_guard.is_virtual_volumes_path(path):
|
||||||
|
for entry in self._path_guard.virtual_volumes_entries():
|
||||||
|
resolved = self._path_guard.resolve_directory_path(entry["path"])
|
||||||
|
remaining = SEARCH_RESULT_LIMIT - len(items)
|
||||||
|
if remaining <= 0:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
matches, chunk_truncated = self._filesystem.search_names(
|
||||||
|
resolved.absolute,
|
||||||
|
normalized_query,
|
||||||
|
remaining,
|
||||||
|
)
|
||||||
|
items.extend(self._map_results(matches, resolved.display_style))
|
||||||
|
if chunk_truncated or len(items) >= SEARCH_RESULT_LIMIT:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
return SearchResponse(items=items, truncated=truncated)
|
||||||
|
|
||||||
|
resolved = self._path_guard.resolve_directory_path(path)
|
||||||
|
matches, truncated = self._filesystem.search_names(
|
||||||
|
resolved.absolute,
|
||||||
|
normalized_query,
|
||||||
|
SEARCH_RESULT_LIMIT,
|
||||||
|
)
|
||||||
|
return SearchResponse(items=self._map_results(matches, resolved.display_style), truncated=truncated)
|
||||||
|
|
||||||
|
def _map_results(self, matches: list[dict], display_style: str) -> list[SearchResultItem]:
|
||||||
|
items: list[SearchResultItem] = []
|
||||||
|
for match in matches:
|
||||||
|
absolute = match["absolute"]
|
||||||
|
alias = self._match_alias_for_path(absolute)
|
||||||
|
path = self._path_guard.entry_relative_path(alias, absolute, display_style=display_style)
|
||||||
|
parent_path = self._path_guard.entry_relative_path(alias, absolute.parent, display_style=display_style)
|
||||||
|
items.append(
|
||||||
|
SearchResultItem(
|
||||||
|
name=match["name"],
|
||||||
|
path=path,
|
||||||
|
type=match["kind"],
|
||||||
|
parent_path=parent_path,
|
||||||
|
root=alias,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def _match_alias_for_path(self, absolute) -> str:
|
||||||
|
resolved = absolute.resolve(strict=False)
|
||||||
|
for alias, root in self._path_guard._roots.items(): # internal mapping, shared security source
|
||||||
|
if self._path_guard._is_under_root(resolved, root):
|
||||||
|
return alias
|
||||||
|
raise AppError(
|
||||||
|
code="path_outside_whitelist",
|
||||||
|
message="Requested path is outside allowed roots",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
from backend.app.dependencies import get_search_service
|
||||||
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
|
from backend.app.main import app
|
||||||
|
from backend.app.security.path_guard import PathGuard
|
||||||
|
from backend.app.services.search_service import SearchService
|
||||||
|
|
||||||
|
|
||||||
|
class SearchApiGoldenTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name) / "root"
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.service = SearchService(path_guard=PathGuard({"storage1": str(self.root)}), filesystem=FilesystemAdapter())
|
||||||
|
|
||||||
|
async def _override_search_service() -> SearchService:
|
||||||
|
return self.service
|
||||||
|
|
||||||
|
app.dependency_overrides[get_search_service] = _override_search_service
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def _request(self, path: str, query: str) -> httpx.Response:
|
||||||
|
async def _run() -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
return await client.get("/api/search", params={"path": path, "query": query})
|
||||||
|
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
def test_search_empty_result_list(self) -> None:
|
||||||
|
(self.root / "docs").mkdir()
|
||||||
|
(self.root / "docs" / "alpha.txt").write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._request("storage1/docs", "zzz")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"items": [], "truncated": False})
|
||||||
|
|
||||||
|
def test_search_file_match(self) -> None:
|
||||||
|
(self.root / "docs").mkdir()
|
||||||
|
(self.root / "docs" / "holiday-video.mp4").write_bytes(b"x")
|
||||||
|
|
||||||
|
response = self._request("storage1/docs", "video")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["truncated"], False)
|
||||||
|
self.assertEqual(len(payload["items"]), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["items"][0],
|
||||||
|
{
|
||||||
|
"name": "holiday-video.mp4",
|
||||||
|
"path": "storage1/docs/holiday-video.mp4",
|
||||||
|
"type": "file",
|
||||||
|
"parent_path": "storage1/docs",
|
||||||
|
"root": "storage1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_directory_match(self) -> None:
|
||||||
|
(self.root / "Projects").mkdir()
|
||||||
|
(self.root / "Projects" / "DemoFolder").mkdir()
|
||||||
|
|
||||||
|
response = self._request("storage1/Projects", "demo")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["items"][0]["type"], "directory")
|
||||||
|
self.assertEqual(payload["items"][0]["path"], "storage1/Projects/DemoFolder")
|
||||||
|
|
||||||
|
def test_search_traversal_blocked(self) -> None:
|
||||||
|
response = self._request("storage1/../etc", "passwd")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
|
||||||
|
|
||||||
|
def test_search_path_not_found(self) -> None:
|
||||||
|
response = self._request("storage1/missing", "abc")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||||
|
|
||||||
|
def test_search_invalid_root_alias(self) -> None:
|
||||||
|
response = self._request("unknown/path", "abc")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "invalid_root_alias")
|
||||||
|
|
||||||
|
def test_search_result_limit_sets_truncated(self) -> None:
|
||||||
|
(self.root / "many").mkdir()
|
||||||
|
for idx in range(120):
|
||||||
|
(self.root / "many" / f"match-{idx:03d}.txt").write_text("x", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._request("storage1/many", "match")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(len(payload["items"]), 100)
|
||||||
|
self.assertEqual(payload["truncated"], True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -54,6 +54,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="video-player"', body)
|
self.assertIn('id="video-player"', body)
|
||||||
self.assertIn('id="video-close-btn"', body)
|
self.assertIn('id="video-close-btn"', body)
|
||||||
self.assertIn('id="settings-modal"', body)
|
self.assertIn('id="settings-modal"', body)
|
||||||
|
self.assertIn('id="search-modal"', body)
|
||||||
|
self.assertIn('id="search-input"', body)
|
||||||
|
self.assertIn('id="search-results"', body)
|
||||||
self.assertIn('id="rename-popup"', body)
|
self.assertIn('id="rename-popup"', body)
|
||||||
self.assertIn('id="rename-input"', body)
|
self.assertIn('id="rename-input"', body)
|
||||||
self.assertIn('id="rename-apply-btn"', body)
|
self.assertIn('id="rename-apply-btn"', body)
|
||||||
@@ -80,6 +83,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="right-breadcrumbs"', body)
|
self.assertIn('id="right-breadcrumbs"', body)
|
||||||
self.assertIn('id="wildcard-popup"', body)
|
self.assertIn('id="wildcard-popup"', body)
|
||||||
self.assertIn('id="wildcard-pattern-input"', body)
|
self.assertIn('id="wildcard-pattern-input"', body)
|
||||||
|
self.assertNotIn('id="search-btn"', body)
|
||||||
self.assertNotIn('id="bookmarks-panel"', body)
|
self.assertNotIn('id="bookmarks-panel"', body)
|
||||||
self.assertNotIn('id="tasks-panel"', body)
|
self.assertNotIn('id="tasks-panel"', body)
|
||||||
|
|
||||||
@@ -107,6 +111,12 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn("document.documentElement.dataset.theme", app_js)
|
self.assertIn("document.documentElement.dataset.theme", app_js)
|
||||||
self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js)
|
self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js)
|
||||||
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
|
self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js)
|
||||||
|
self.assertIn('function openSearch()', app_js)
|
||||||
|
self.assertIn('async function submitSearch()', app_js)
|
||||||
|
self.assertIn('document.getElementById("search-input")', app_js)
|
||||||
|
self.assertIn("`/api/search?", app_js)
|
||||||
|
self.assertIn('event.key.toLowerCase() === "f"', app_js)
|
||||||
|
self.assertIn('(event.metaKey || event.ctrlKey)', app_js)
|
||||||
self.assertIn('if (event.key === "F1") {', app_js)
|
self.assertIn('if (event.key === "F1") {', app_js)
|
||||||
self.assertIn('if (event.key === "F2") {', app_js)
|
self.assertIn('if (event.key === "F2") {', app_js)
|
||||||
self.assertIn('function openSettings(tab = "general")', app_js)
|
self.assertIn('function openSettings(tab = "general")', app_js)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ let state = {
|
|||||||
visibleItems: [],
|
visibleItems: [],
|
||||||
currentRowIndex: -1,
|
currentRowIndex: -1,
|
||||||
selectionAnchorIndex: null,
|
selectionAnchorIndex: null,
|
||||||
|
pendingSelectionPath: null,
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
currentPath: "/Volumes",
|
currentPath: "/Volumes",
|
||||||
@@ -17,6 +18,7 @@ let state = {
|
|||||||
visibleItems: [],
|
visibleItems: [],
|
||||||
currentRowIndex: -1,
|
currentRowIndex: -1,
|
||||||
selectionAnchorIndex: null,
|
selectionAnchorIndex: null,
|
||||||
|
pendingSelectionPath: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
activePane: "left",
|
activePane: "left",
|
||||||
@@ -46,6 +48,11 @@ let settingsState = {
|
|||||||
activeTab: "general",
|
activeTab: "general",
|
||||||
logsLoaded: false,
|
logsLoaded: false,
|
||||||
};
|
};
|
||||||
|
let searchState = {
|
||||||
|
pane: "left",
|
||||||
|
path: "/Volumes",
|
||||||
|
query: "",
|
||||||
|
};
|
||||||
const THEME_STORAGE_KEY = "webmanager-theme";
|
const THEME_STORAGE_KEY = "webmanager-theme";
|
||||||
|
|
||||||
function preferredTheme() {
|
function preferredTheme() {
|
||||||
@@ -199,6 +206,17 @@ function settingsElements() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchElements() {
|
||||||
|
return {
|
||||||
|
overlay: document.getElementById("search-modal"),
|
||||||
|
closeButton: document.getElementById("search-close-btn"),
|
||||||
|
context: document.getElementById("search-context"),
|
||||||
|
input: document.getElementById("search-input"),
|
||||||
|
error: document.getElementById("search-error"),
|
||||||
|
results: document.getElementById("search-results"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function apiRequest(method, url, body) {
|
async function apiRequest(method, url, body) {
|
||||||
const options = { method, headers: {} };
|
const options = { method, headers: {} };
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
@@ -724,6 +742,16 @@ async function loadBrowsePane(pane) {
|
|||||||
model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null;
|
model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.pendingSelectionPath) {
|
||||||
|
const pendingIndex = visibleItems.findIndex((item) => !item.isParent && item.path === model.pendingSelectionPath);
|
||||||
|
if (pendingIndex >= 0) {
|
||||||
|
const pendingItem = visibleItems[pendingIndex];
|
||||||
|
model.currentRowIndex = pendingIndex;
|
||||||
|
setSingleSelectionAtIndex(pane, selectedEntryFromItem(pendingItem), pendingIndex);
|
||||||
|
}
|
||||||
|
model.pendingSelectionPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
renderPaneItems(pane);
|
renderPaneItems(pane);
|
||||||
scrollCurrentRowIntoView(pane);
|
scrollCurrentRowIntoView(pane);
|
||||||
setStatus(`Loaded ${pane}: ${data.path}`);
|
setStatus(`Loaded ${pane}: ${data.path}`);
|
||||||
@@ -1382,6 +1410,96 @@ function closeVideoViewer() {
|
|||||||
video.player.load();
|
video.player.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSearchOpen() {
|
||||||
|
return !searchElements().overlay.classList.contains("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
const elements = searchElements();
|
||||||
|
elements.overlay.classList.add("hidden");
|
||||||
|
elements.error.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearchResults(items) {
|
||||||
|
const elements = searchElements();
|
||||||
|
elements.results.innerHTML = "";
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "popup-meta";
|
||||||
|
empty.textContent = "No matches found.";
|
||||||
|
elements.results.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "search-result";
|
||||||
|
row.onclick = () => activateSearchResult(item);
|
||||||
|
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "search-result-name";
|
||||||
|
name.textContent = item.name;
|
||||||
|
|
||||||
|
const path = document.createElement("div");
|
||||||
|
path.className = "search-result-path";
|
||||||
|
path.textContent = item.parent_path;
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "search-result-meta";
|
||||||
|
meta.textContent = `${item.type} · ${item.root}`;
|
||||||
|
|
||||||
|
row.append(name, path, meta);
|
||||||
|
elements.results.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSearchResult(item) {
|
||||||
|
const pane = state.activePane;
|
||||||
|
closeSearch();
|
||||||
|
if (item.type === "directory") {
|
||||||
|
navigateTo(pane, item.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paneState(pane).pendingSelectionPath = item.path;
|
||||||
|
navigateTo(pane, item.parent_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
const pane = state.activePane;
|
||||||
|
const elements = searchElements();
|
||||||
|
searchState.pane = pane;
|
||||||
|
searchState.path = paneState(pane).currentPath;
|
||||||
|
searchState.query = "";
|
||||||
|
elements.context.textContent = `Searching under: ${searchState.path}`;
|
||||||
|
elements.input.value = "";
|
||||||
|
elements.error.textContent = "";
|
||||||
|
elements.results.innerHTML = "";
|
||||||
|
elements.overlay.classList.remove("hidden");
|
||||||
|
elements.input.focus();
|
||||||
|
elements.input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSearch() {
|
||||||
|
const elements = searchElements();
|
||||||
|
const query = elements.input.value.trim();
|
||||||
|
searchState.query = query;
|
||||||
|
elements.error.textContent = "";
|
||||||
|
elements.results.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const data = await apiRequest("GET", `/api/search?${new URLSearchParams({
|
||||||
|
path: searchState.path,
|
||||||
|
query,
|
||||||
|
}).toString()}`);
|
||||||
|
renderSearchResults(data.items);
|
||||||
|
if (data.truncated) {
|
||||||
|
elements.error.textContent = "Result limit reached. Showing first matches.";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
elements.error.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setSettingsTab(tab) {
|
function setSettingsTab(tab) {
|
||||||
const elements = settingsElements();
|
const elements = settingsElements();
|
||||||
settingsState.activeTab = tab === "logs" ? "logs" : "general";
|
settingsState.activeTab = tab === "logs" ? "logs" : "general";
|
||||||
@@ -1686,6 +1804,19 @@ function clearSelectionForActivePane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyboardShortcuts(event) {
|
function handleKeyboardShortcuts(event) {
|
||||||
|
if (isSearchOpen()) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
submitSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isRenamePopupOpen()) {
|
if (isRenamePopupOpen()) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1760,6 +1891,13 @@ function handleKeyboardShortcuts(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSearchShortcut = event.key.toLowerCase() === "f" && event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey);
|
||||||
|
if (isSearchShortcut) {
|
||||||
|
event.preventDefault();
|
||||||
|
openSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (actionShortcutHandled(event)) {
|
if (actionShortcutHandled(event)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -1893,6 +2031,14 @@ function setupEvents() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const search = searchElements();
|
||||||
|
search.closeButton.onclick = closeSearch;
|
||||||
|
search.overlay.onclick = (event) => {
|
||||||
|
if (event.target === search.overlay) {
|
||||||
|
closeSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const wildcard = wildcardPopupElements();
|
const wildcard = wildcardPopupElements();
|
||||||
wildcard.cancelButton.onclick = closeWildcardPopup;
|
wildcard.cancelButton.onclick = closeWildcardPopup;
|
||||||
wildcard.applyButton.onclick = submitWildcardPopup;
|
wildcard.applyButton.onclick = submitWildcardPopup;
|
||||||
|
|||||||
@@ -99,6 +99,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="search-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="search-title">
|
||||||
|
<div class="popup-card search-card">
|
||||||
|
<button id="search-close-btn" class="viewer-close" type="button" aria-label="Close search">X</button>
|
||||||
|
<h3 id="search-title">Search</h3>
|
||||||
|
<div id="search-context" class="popup-meta"></div>
|
||||||
|
<label for="search-input" class="popup-label">Query</label>
|
||||||
|
<input id="search-input" class="search-input" type="text" autocomplete="off" placeholder="Enter at least 3 characters">
|
||||||
|
<div id="search-error" class="error"></div>
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="wildcard-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="wildcard-popup-title">
|
<div id="wildcard-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="wildcard-popup-title">
|
||||||
<div class="popup-card">
|
<div class="popup-card">
|
||||||
<h3 id="wildcard-popup-title">Wildcard Select</h3>
|
<h3 id="wildcard-popup-title">Wildcard Select</h3>
|
||||||
|
|||||||
@@ -524,6 +524,54 @@ button:disabled {
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-card {
|
||||||
|
width: min(680px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result:hover {
|
||||||
|
border-color: var(--color-active-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-path {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-meta {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-card {
|
.settings-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(760px, calc(100vw - 32px));
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
|||||||
Reference in New Issue
Block a user