diff --git a/README.md b/README.md index b64a33d..10dfc69 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,17 @@ To connect to the database for development, you may optionally install the [Dies Diesel is the Rust ORM used to create and run database migrations. It requires a separate C library called `libpq` to be installed as well. ```sh -# Mac only +# Mac brew install libpq - -# Ubuntu only +# or Ubuntu/Debian apt-get install libpq5 # Install diesel CLI cargo install diesel_cli --no-default-features --features postgres +# Install cargo-binstall +cargo install cargo-binstall + # On macOS-arm64, you may need additional rust flags: RUSTFLAGS='-L /opt/homebrew/opt/libpq/lib' cargo install diesel_cli --no-default-features --features postgres ``` diff --git a/app/src/App.tsx b/app/src/App.tsx index 060a2c1..a0f7053 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,7 +3,6 @@ import React, { ReactNode, Suspense } from "react"; import { AppBar, Toolbar, Box, useTheme } from "@mui/material"; import UserButton from "./features/toolbar/components/UserButton"; -import { useIsMobile } from "./features/toolbar/hooks/useIsMobile"; import SearchBar from "./features/toolbar/components/SearchBar"; interface AppProps { @@ -11,7 +10,6 @@ interface AppProps { } function App({ children }: AppProps) { - const isMobile = useIsMobile(); const theme = useTheme(); return ( @@ -32,19 +30,23 @@ function App({ children }: AppProps) { sx={{ backgroundColor: "#181818", boxShadow: "0 2px 8px rgba(0, 0, 0, 0.3)", + padding: { xs: "8px 16px", sm: "6px 24px" }, + gap: { xs: 1, sm: 2 }, + flexWrap: { xs: "wrap", sm: "nowrap" }, + alignItems: "center", }} > (window.location.href = "/")} sx={{ - flexGrow: 1, - display: "block", color: theme.palette.primary.main, fontSize: "24px", fontFamily: "monospace", cursor: "pointer", fontWeight: "bold", transition: "color 0.2s ease-in-out", + flexShrink: 0, + order: { xs: 1, sm: 1 }, "&:hover": { color: theme.palette.primary.light, }, @@ -52,12 +54,29 @@ function App({ children }: AppProps) { > forc.pub - {!isMobile && } - Loading...}> - - + + }> + + + + + Loading...}> + + + - {isMobile && }
-

{"The Sway community's package registry"}

- + {query ? ( + + ) : ( + <> +

{"The Sway community's package registry"}

+ + + )}
); } + +export default function HomePageWrapper() { + return ( + Loading...}> + + + ); +} diff --git a/app/src/app/search/page.tsx b/app/src/app/search/page.tsx deleted file mode 100644 index 0ef9120..0000000 --- a/app/src/app/search/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { Suspense } from "react"; -import App from "../../App"; -import SearchResults from "../../pages/SearchResults"; - -export default function SearchPage() { - return ( - - Loading...}> - - - - ); -} diff --git a/app/src/features/toolbar/components/SearchBar.tsx b/app/src/features/toolbar/components/SearchBar.tsx index d58754f..f65a22d 100644 --- a/app/src/features/toolbar/components/SearchBar.tsx +++ b/app/src/features/toolbar/components/SearchBar.tsx @@ -1,93 +1,121 @@ "use client"; -import React, { Suspense, useCallback, useEffect } from "react"; -import { usePathname } from "next/navigation"; -import { useIsMobile } from "../hooks/useIsMobile"; +import React, { useCallback, useState, useRef, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import InputAdornment from "@mui/material/InputAdornment"; -import { styled, useTheme } from "@mui/material"; +import { styled, useTheme, useMediaQuery } from "@mui/material"; import Input from "@mui/material/Input"; import SearchIcon from "@mui/icons-material/Search"; -import dynamic from "next/dynamic"; import "./SearchBar.css"; -function SearchBarComponent() { - const isMobile = useIsMobile(); - const pathname = usePathname(); +const StyledInput = styled(Input)(({ theme }) => ({ + "& input::placeholder": { + color: theme.palette.text.secondary, + opacity: 1, + }, + "& input": { + color: theme.palette.text.primary, + }, +})); + +function SearchBar() { + const router = useRouter(); + const searchParams = useSearchParams(); const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const [searchValue, setSearchValue] = useState( + () => searchParams.get("query") || "", + ); + const debounceTimeoutRef = useRef(); + + const updateURL = useCallback( + (value: string) => { + const trimmed = value.trim(); + const url = trimmed + ? `/?query=${encodeURIComponent(trimmed)}&page=1` + : "/"; + router.replace(url, { scroll: false }); + }, + [router], + ); + + const debouncedUpdateURL = useCallback( + (value: string) => { + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = setTimeout(() => updateURL(value), 400); + }, + [updateURL], + ); useEffect(() => { - const handlePopState = () => { - const params = new URLSearchParams(window.location.search); - const input = document.querySelector( - ".search-input input", - ); - if (input) { - input.value = params.get("q") || ""; - } - }; + const query = searchParams.get("query") || ""; + setSearchValue(query); + }, [searchParams]); - window.addEventListener("popstate", handlePopState); - return () => window.removeEventListener("popstate", handlePopState); + useEffect(() => { + return () => clearTimeout(debounceTimeoutRef.current); }, []); - // Create a styled version of Input with placeholder color override - const StyledInput = styled(Input)({ - "& input::placeholder": { - color: theme.palette.text.secondary, - opacity: 1, - }, - "& input": { - color: theme.palette.text.primary, - }, - }); - const handleChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; - const params = new URLSearchParams(window.location.search); + setSearchValue(newValue); + debouncedUpdateURL(newValue); + }, + [debouncedUpdateURL], + ); - if (newValue) { - params.set("q", newValue); - const newUrl = - pathname === "/search" - ? `${pathname}?${params.toString()}` - : `/search?${params.toString()}`; - window.history.pushState({ path: newUrl }, "", newUrl); - } else if (pathname === "/search") { - window.history.pushState({ path: "/" }, "", "/"); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setSearchValue(""); + clearTimeout(debounceTimeoutRef.current); + updateURL(""); + } else if (e.key === "Enter") { + clearTimeout(debounceTimeoutRef.current); + updateURL(searchValue); } }, - [pathname], + [searchValue, updateURL], ); return ( -
- Loading...
}> - - - - } - /> - +
+ + + + } + />
); } -const SearchBar = dynamic(() => Promise.resolve(SearchBarComponent), { - ssr: false, -}); - export default SearchBar; diff --git a/app/src/pages/SearchResults.tsx b/app/src/pages/SearchResults.tsx index 8e12fe9..1234a5b 100644 --- a/app/src/pages/SearchResults.tsx +++ b/app/src/pages/SearchResults.tsx @@ -1,93 +1,259 @@ -import React, { Suspense } from "react"; -import { useSearchParams } from "next/navigation"; - -interface SearchResult { - name: string; - version: string; - homepage: string; - documentation: string; - repository: string; - updated: string; - downloads: number; -} +"use client"; -const dummySearchResults: SearchResult[] = [ - // { - // name: 'std', - // version: '0.1.0', - // homepage: 'www.google.com', - // documentation: 'www.google.com', - // repository: 'www.google.com', - // updated: '2024-02-02', - // downloads: 100, - // }, - // { - // name: 'core', - // version: '0.1.0', - // homepage: 'www.google.com', - // documentation: 'www.google.com', - // repository: 'www.google.com', - // updated: '2024-01-01', - // downloads: 200, - // }, -]; - -interface SearchResultsProps { - searchParams: ReturnType; -} +import React, { Suspense, useEffect, useState, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Box, + Card, + CardContent, + Typography, + CircularProgress, + Pagination, +} from "@mui/material"; +import HTTP, { PackagePreview } from "../utils/http"; +import { formatDate } from "../utils/date"; +import NextLink from "next/link"; + +const PER_PAGE = 10; + +function SearchResults() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const abortControllerRef = useRef(); + const searchTimeoutRef = useRef(); + + const query = searchParams.get("query")?.trim() || ""; + const currentPage = parseInt(searchParams.get("page") || "1", 10); + + useEffect(() => { + // Clear any pending search timeout + clearTimeout(searchTimeoutRef.current); + + if (!query) { + setResults([]); + setTotalPages(1); + setTotalCount(0); + setError(null); + setLoading(false); + abortControllerRef.current?.abort(); + return; + } + + // Cancel previous request + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + + setLoading(true); + setError(null); + + // Add small delay to reduce cancelled requests when typing fast + searchTimeoutRef.current = setTimeout(() => { + HTTP.get("/search", { + params: { + query, + page: currentPage.toString(), + per_page: PER_PAGE.toString(), + }, + signal: abortControllerRef.current?.signal, + }) + .then((response) => { + setResults(response.data.data); + setTotalPages(response.data.totalPages); + setTotalCount(response.data.totalCount); + }) + .catch((err) => { + if (err.name !== "AbortError") { + setError("Failed to fetch search results"); + console.error("Search error:", err); + } + }) + .finally(() => { + setLoading(false); + }); + }, 100); // 100ms delay to reduce cancelled requests + + return () => { + clearTimeout(searchTimeoutRef.current); + abortControllerRef.current?.abort(); + }; + }, [query, currentPage]); -function SearchResults({ searchParams }: SearchResultsProps) { - const matchingResults = dummySearchResults.filter((result) => { - return result.name - .toLowerCase() - .includes(searchParams.get("q")?.toLowerCase() || ""); - }); + const handlePageChange = (_: React.ChangeEvent, page: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set("page", page.toString()); + router.replace(`/?${newParams.toString()}`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const containerStyles = { + maxWidth: "1200px", + mx: "auto", + px: 3, + width: "100%", + }; + + if (loading) { + return ( + + + + + Searching for "{query}"... + + + + ); + } + + if (error) { + return ( + + + Search Error + + {error} + + Please try again or contact support if the problem persists. + + + ); + } + + if (results.length === 0) { + return ( + + + No packages found + + + No results found for "{query}". Try different keywords or + check your spelling. + + + ); + } return ( -
-
-

{"Search Results"}

-
-
{"Under construction"}
- - {matchingResults.map((result) => ( -
-
{result.name}
-
{result.version}
-
{result.homepage}
-
{result.documentation}
-
{result.repository}
-
{result.updated}
-
{result.downloads}
-
- ))} -
- ); -} + + + + Search Results + + + Found {totalCount} package{totalCount === 1 ? "" : "s"} for " + {query}" + + -function SearchResultsWithParams() { - const searchParams = useSearchParams(); - return ; + + {results.map((result) => ( + + + + + + + {result.name} + + + v{result.version} + + + + + {result.description || "No description available"} + + + + Updated {formatDate(result.updatedAt)} + + + + + + ))} + + + {totalPages > 1 && ( + + + + )} + + ); } -function SearchResultsWrapper() { +export default function SearchResultsWrapper() { return ( Loading search results...}> - + ); } - -export default SearchResultsWrapper; diff --git a/app/src/utils/date.ts b/app/src/utils/date.ts index 7e4c8ba..cfe7e28 100644 --- a/app/src/utils/date.ts +++ b/app/src/utils/date.ts @@ -1,3 +1,12 @@ +export function formatDate(date: string | Date): string { + const d = new Date(date); + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + export function formatTimeAgo(date: string | Date): string { const now = new Date(); const past = new Date(date); diff --git a/app/src/utils/http.ts b/app/src/utils/http.ts index 64a9b5d..c2905a4 100644 --- a/app/src/utils/http.ts +++ b/app/src/utils/http.ts @@ -4,6 +4,22 @@ import { SERVER_URI } from "../constants"; import { RecentPackagesResponse } from "../features/dashboard/hooks/useFetchRecentPackages"; import { FullPackage } from "../features/detail/hooks/usePackageDetail"; +export interface SearchResponse { + data: PackagePreview[]; + totalCount: number; + totalPages: number; + currentPage: number; + perPage: number; +} + +export interface PackagePreview { + name: string; + version: string; + description: string | null; + createdAt: string; + updatedAt: string; +} + export interface AuthenticatedUser { fullName: string; email?: string; @@ -102,6 +118,16 @@ type Routes = [ method: "GET"; jsonResponse: RecentPackagesResponse; }, + { + route: "/search"; + method: "GET"; + queryParams: { + query: string; + page?: string; + per_page?: string; + }; + jsonResponse: SearchResponse; + }, { route: "/package"; method: "GET"; diff --git a/migrations/2025-04-02-120000_add_search_indexes/down.sql b/migrations/2025-04-02-120000_add_search_indexes/down.sql new file mode 100644 index 0000000..a566691 --- /dev/null +++ b/migrations/2025-04-02-120000_add_search_indexes/down.sql @@ -0,0 +1,8 @@ +-- Drop all search-related indexes +DROP INDEX IF EXISTS idx_packages_created_at; +DROP INDEX IF EXISTS idx_package_versions_package_id_created_at; +DROP INDEX IF EXISTS idx_package_versions_desc_trgm; +DROP INDEX IF EXISTS idx_packages_name_trgm; +DROP INDEX IF EXISTS idx_packages_name_lower; + +-- Note: We don't drop the pg_trgm extension as other parts of the system might be using it \ No newline at end of file diff --git a/migrations/2025-04-02-120000_add_search_indexes/up.sql b/migrations/2025-04-02-120000_add_search_indexes/up.sql new file mode 100644 index 0000000..2fd0fd1 --- /dev/null +++ b/migrations/2025-04-02-120000_add_search_indexes/up.sql @@ -0,0 +1,21 @@ +-- Enable the pg_trgm extension for fuzzy search +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Functional index for fast case-insensitive package name searches +CREATE INDEX idx_packages_name_lower + ON packages USING btree (LOWER(package_name)); + +-- Trigram indexes for fuzzy search +CREATE INDEX idx_packages_name_trgm + ON packages USING gin (LOWER(package_name) gin_trgm_ops); + +CREATE INDEX idx_package_versions_desc_trgm + ON package_versions USING gin (LOWER(package_description) gin_trgm_ops); + +-- Composite index for the join and filtering +CREATE INDEX idx_package_versions_package_id_created_at + ON package_versions (package_id, created_at DESC); + +-- Index for pagination +CREATE INDEX idx_packages_created_at + ON packages (created_at DESC); \ No newline at end of file diff --git a/src/db/package_version.rs b/src/db/package_version.rs index 0a01f19..4eaa6a6 100644 --- a/src/db/package_version.rs +++ b/src/db/package_version.rs @@ -367,4 +367,84 @@ impl DbConn<'_> { ) .collect()) } + + /// Search for packages by name or description using fuzzy search. + pub fn search_packages( + &mut self, + query: String, + pagination: Pagination, + ) -> Result, DatabaseError> { + let query_lower = query.to_lowercase(); + + let packages = diesel::sql_query( + r#"WITH ranked_versions AS ( + SELECT + p.id AS package_id, + p.package_name AS name, + pv.num AS version, + pv.package_description AS description, + p.created_at AS created_at, + pv.created_at AS updated_at, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank, + -- Combined relevance scoring + GREATEST( + similarity($1, LOWER(p.package_name)), + CASE + WHEN LOWER(p.package_name) ILIKE '%' || $1 || '%' THEN 0.7 + ELSE 0.0 + END, + similarity($1, LOWER(COALESCE(pv.package_description, ''))) * 0.3 + ) AS relevance_score + FROM package_versions pv + JOIN packages p ON pv.package_id = p.id + WHERE + LOWER(p.package_name) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(p.package_name)) > 0.2 OR + similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 + ) + SELECT + name, + version, + description, + created_at, + updated_at + FROM ranked_versions + WHERE rank = 1 AND relevance_score > 0.1 + ORDER BY relevance_score DESC, created_at DESC + OFFSET $2 + LIMIT $3; + "#, + ) + .bind::(query_lower.clone()) + .bind::(pagination.offset()) + .bind::(pagination.limit()) + .load::(self.inner()) + .map_err(|err: diesel::result::Error| { + DatabaseError::QueryFailed("search packages".to_string(), err) + })?; + + // Count total matches + let total = diesel::sql_query( + r#"SELECT COUNT(DISTINCT p.id) AS count + FROM packages p + JOIN package_versions pv ON pv.package_id = p.id + WHERE + LOWER(p.package_name) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(p.package_name)) > 0.2 OR + similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 + "#, + ) + .bind::(query_lower) + .get_result::(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("search count".to_string(), err))? + .count; + + Ok(PaginatedResponse { + data: packages, + total_count: total, + total_pages: ((total as f64) / (pagination.limit() as f64)).ceil() as i64, + current_page: pagination.page(), + per_page: pagination.limit(), + }) + } } diff --git a/src/main.rs b/src/main.rs index 85961fa..09f2af5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ use forc_pub::handlers::upload::{handle_project_upload, install_forc_at_path, Up use forc_pub::middleware::cors::Cors; use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; use forc_pub::middleware::token_auth::TokenAuth; -use forc_pub::models::PackageVersionInfo; +use forc_pub::models::{PackagePreview, PackageVersionInfo}; use forc_pub::util::{load_env, validate_or_format_semver}; use rocket::http::Status; use rocket::tokio::task; @@ -340,6 +340,23 @@ fn default_catcher(status: Status, _req: &Request<'_>) -> response::status::Cust } // Indicates the service is running +#[get("/search?&")] +fn search( + db: &State, + query: String, + pagination: Pagination, +) -> ApiResult> { + if query.trim().is_empty() || query.len() > 100 { + return Err(ApiError::Generic( + "Invalid query parameter".into(), + Status::BadRequest, + )); + } + + let result = db.transaction(|conn| conn.search_packages(query, pagination))?; + Ok(Json(result)) +} + #[get("/health")] fn health() -> String { "true".to_string() @@ -369,13 +386,14 @@ async fn rocket() -> _ { user, new_token, delete_token, + tokens, publish, upload_project, - tokens, packages, package, package_versions, recent_packages, + search, all_options, health ],