csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

search

dawn (May 21, 2026, 4:00 AM +0300) faff020a 2303308d

+1429 -149
+101
src/components/common.tsx
··· 1 1 import clsx from 'clsx'; 2 2 import { 3 3 Ban, 4 + ChevronLeft, 5 + ChevronRight, 4 6 CircleDot, 5 7 GitMerge, 6 8 GitPullRequest, ··· 8 10 LoaderCircle, 9 11 UserRound, 10 12 } from 'lucide-solid'; 13 + import { A } from '@solidjs/router'; 11 14 import { Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 12 15 import { createQuery } from '@tanstack/solid-query'; 13 16 import { resolveAvatarUrl } from '../lib/api'; ··· 36 39 </Show> 37 40 </button> 38 41 ); 42 + 43 + export const PaginationControls: Component<{ 44 + basePath?: string; 45 + offset: number; 46 + limit: number; 47 + totalCount?: number; 48 + hasNext?: boolean; 49 + query?: Record<string, string | undefined>; 50 + hrefForOffset?: (offset: number) => string; 51 + }> = (props) => { 52 + const safeLimit = createMemo(() => Math.max(1, props.limit)); 53 + const hasExactTotal = createMemo(() => typeof props.totalCount === 'number'); 54 + const lastOffset = createMemo(() => Math.floor(Math.max((props.totalCount ?? 0) - 1, 0) / safeLimit()) * safeLimit()); 55 + const previousOffset = createMemo(() => Math.max(0, props.offset - safeLimit())); 56 + const nextOffset = createMemo(() => props.offset + safeLimit()); 57 + const hasNextPage = createMemo(() => 58 + hasExactTotal() ? nextOffset() < (props.totalCount ?? 0) : Boolean(props.hasNext), 59 + ); 60 + const pageNumber = (offset: number) => Math.floor(offset / safeLimit()) + 1; 61 + const hrefForOffset = (offset: number) => { 62 + if (props.hrefForOffset) { 63 + return props.hrefForOffset(Math.max(0, offset)); 64 + } 65 + 66 + const params = new URLSearchParams(); 67 + 68 + for (const [key, value] of Object.entries(props.query ?? {})) { 69 + if (value) { 70 + params.set(key, value); 71 + } 72 + } 73 + 74 + params.set('offset', String(Math.max(0, offset))); 75 + params.set('limit', String(safeLimit())); 76 + return `${props.basePath ?? ''}?${params.toString()}`; 77 + }; 78 + const showNextNumber = createMemo(() => hasExactTotal() ? nextOffset() < lastOffset() : hasNextPage()); 79 + const showGap = createMemo(() => hasExactTotal() && nextOffset() < (props.totalCount ?? 0) - safeLimit() * 2); 80 + 81 + const linkClass = 'flex items-center gap-1 no-underline hover:no-underline dark:text-white text-sm'; 82 + 83 + return ( 84 + <Show when={props.offset > 0 || hasNextPage()}> 85 + <div class="mt-4 flex items-center justify-center gap-5"> 86 + <Show 87 + when={props.offset > 0} 88 + fallback={ 89 + <span class={clsx(linkClass, 'cursor-not-allowed opacity-50')}> 90 + <ChevronLeft class="size-4" /> 91 + prev 92 + </span> 93 + } 94 + > 95 + <A href={hrefForOffset(previousOffset())} class={linkClass}> 96 + <ChevronLeft class="size-4" /> 97 + prev 98 + </A> 99 + </Show> 100 + 101 + <Show when={props.offset > 0}> 102 + <A href={hrefForOffset(0)}>1</A> 103 + </Show> 104 + <Show when={hasExactTotal() && previousOffset() > 0}> 105 + <A href={hrefForOffset(previousOffset())}>{pageNumber(previousOffset())}</A> 106 + </Show> 107 + 108 + <span class="rounded-sm border border-gray-200 bg-white px-2 py-1 dark:border-gray-700 dark:bg-gray-800"> 109 + {pageNumber(props.offset)} 110 + </span> 111 + 112 + <Show when={showNextNumber()}> 113 + <A href={hrefForOffset(nextOffset())}>{pageNumber(nextOffset())}</A> 114 + </Show> 115 + <Show when={showGap()}> 116 + <span class="text-gray-400 dark:text-gray-500">--</span> 117 + </Show> 118 + <Show when={hasExactTotal() && props.offset < lastOffset()}> 119 + <A href={hrefForOffset(lastOffset())}>{pageNumber(lastOffset())}</A> 120 + </Show> 121 + 122 + <Show 123 + when={hasNextPage()} 124 + fallback={ 125 + <span class={clsx(linkClass, 'cursor-not-allowed opacity-50')}> 126 + next 127 + <ChevronRight class="size-4" /> 128 + </span> 129 + } 130 + > 131 + <A href={hrefForOffset(nextOffset())} class={linkClass}> 132 + next 133 + <ChevronRight class="size-4" /> 134 + </A> 135 + </Show> 136 + </div> 137 + </Show> 138 + ); 139 + }; 39 140 40 141 export const LoadingState: Component<{ label: string }> = (props) => ( 41 142 <div class="flex items-center justify-center gap-2 text-gray-500 dark:text-gray-400">
-92
src/components/repo.tsx
··· 14 14 PanelRightClose, 15 15 PanelRightOpen, 16 16 UnfoldVertical, 17 - ChevronLeft, 18 17 } from 'lucide-solid'; 19 18 import { marked } from 'marked'; 20 19 import { A } from '@solidjs/router'; ··· 54 53 </div> 55 54 </A> 56 55 ); 57 - 58 - export const PaginationControls: Component<{ 59 - basePath: string; 60 - offset: number; 61 - limit: number; 62 - totalCount?: number; 63 - hasNext?: boolean; 64 - query?: Record<string, string | undefined>; 65 - }> = (props) => { 66 - const safeLimit = createMemo(() => Math.max(1, props.limit)); 67 - const hasExactTotal = createMemo(() => typeof props.totalCount === 'number'); 68 - const lastOffset = createMemo(() => Math.floor(Math.max((props.totalCount ?? 0) - 1, 0) / safeLimit()) * safeLimit()); 69 - const previousOffset = createMemo(() => Math.max(0, props.offset - safeLimit())); 70 - const nextOffset = createMemo(() => props.offset + safeLimit()); 71 - const hasNextPage = createMemo(() => 72 - hasExactTotal() ? nextOffset() < (props.totalCount ?? 0) : Boolean(props.hasNext), 73 - ); 74 - const pageNumber = (offset: number) => Math.floor(offset / safeLimit()) + 1; 75 - const hrefForOffset = (offset: number) => { 76 - const params = new URLSearchParams(); 77 - 78 - for (const [key, value] of Object.entries(props.query ?? {})) { 79 - if (value) { 80 - params.set(key, value); 81 - } 82 - } 83 - 84 - params.set('offset', String(Math.max(0, offset))); 85 - params.set('limit', String(safeLimit())); 86 - return `${props.basePath}?${params.toString()}`; 87 - }; 88 - 89 - const linkClass = 'flex items-center gap-1 no-underline hover:no-underline dark:text-white text-sm'; 90 - 91 - return ( 92 - <Show when={props.offset > 0 || hasNextPage()}> 93 - <div class="mt-4 flex items-center justify-center gap-5"> 94 - <Show 95 - when={props.offset > 0} 96 - fallback={ 97 - <span class={clsx(linkClass, 'cursor-not-allowed opacity-50')}> 98 - <ChevronLeft class="size-4" /> 99 - prev 100 - </span> 101 - } 102 - > 103 - <A href={hrefForOffset(previousOffset())} class={linkClass}> 104 - <ChevronLeft class="size-4" /> 105 - prev 106 - </A> 107 - </Show> 108 - 109 - <Show when={props.offset > 0}> 110 - <A href={hrefForOffset(0)}>1</A> 111 - </Show> 112 - <Show when={hasExactTotal() && previousOffset() > 0}> 113 - <A href={hrefForOffset(previousOffset())}>{pageNumber(previousOffset())}</A> 114 - </Show> 115 - 116 - <span class="rounded-sm border border-gray-200 bg-white px-2 py-1 dark:border-gray-700 dark:bg-gray-800"> 117 - {pageNumber(props.offset)} 118 - </span> 119 - 120 - <Show when={hasExactTotal() && nextOffset() < lastOffset()}> 121 - <A href={hrefForOffset(nextOffset())}>{pageNumber(nextOffset())}</A> 122 - </Show> 123 - <Show when={hasExactTotal() && nextOffset() < (props.totalCount ?? 0) - safeLimit() * 2}> 124 - <span class="text-gray-400 dark:text-gray-500">-</span> 125 - </Show> 126 - <Show when={hasExactTotal() && props.offset < lastOffset()}> 127 - <A href={hrefForOffset(lastOffset())}>{pageNumber(lastOffset())}</A> 128 - </Show> 129 - 130 - <Show 131 - when={hasNextPage()} 132 - fallback={ 133 - <span class={clsx(linkClass, 'cursor-not-allowed opacity-50')}> 134 - next 135 - <ChevronRight class="size-4" /> 136 - </span> 137 - } 138 - > 139 - <A href={hrefForOffset(nextOffset())} class={linkClass}> 140 - next 141 - <ChevronRight class="size-4" /> 142 - </A> 143 - </Show> 144 - </div> 145 - </Show> 146 - ); 147 - }; 148 56 149 57 const skeletonRows = Array.from({ length: 7 }); 150 58 const listSkeletonRows = Array.from({ length: 10 });
+270
src/index.css
··· 914 914 min-height: 140px; 915 915 } 916 916 917 + .untangled-topbar-search-box { 918 + position: relative; 919 + display: flex; 920 + max-height: 30px; 921 + width: 20rem; 922 + align-items: center; 923 + gap: 0.5rem; 924 + border: 1px solid rgb(229 231 235); 925 + border-radius: 0.25rem; 926 + background: rgb(255 255 255); 927 + padding: 1rem 0.375rem 1rem 0.5rem; 928 + } 929 + 930 + .untangled-topbar-search-box:focus-within { 931 + z-index: 51; 932 + outline: 2px solid rgb(209 213 219); 933 + outline-offset: 1px; 934 + } 935 + 936 + .untangled-topbar-search-input { 937 + min-width: 0; 938 + flex: 1; 939 + border: 0; 940 + border-color: transparent; 941 + border-radius: 0; 942 + background: transparent; 943 + padding: 0; 944 + color: rgb(31 41 55); 945 + font-size: 0.875rem; 946 + line-height: 1.25rem; 947 + outline: none; 948 + } 949 + 950 + .untangled-topbar-search-input:focus { 951 + border: 0; 952 + border-color: transparent; 953 + box-shadow: none; 954 + outline: none; 955 + } 956 + 957 + .untangled-topbar-search-input::placeholder { 958 + color: rgb(156 163 175); 959 + } 960 + 961 + .untangled-topbar-search-kbd { 962 + display: flex; 963 + align-items: center; 964 + border: 1px solid rgb(229 231 235); 965 + border-bottom-width: 2px; 966 + border-radius: 0.25rem; 967 + padding: 0 0.25rem; 968 + color: rgb(156 163 175); 969 + font-family: inherit; 970 + font-size: 0.75rem; 971 + line-height: 1.25rem; 972 + pointer-events: none; 973 + } 974 + 975 + .untangled-topbar-search-input:focus + .untangled-topbar-search-kbd, 976 + .untangled-topbar-search-box:focus-within .untangled-topbar-search-kbd, 977 + .untangled-topbar-search-input:not(:placeholder-shown) + .untangled-topbar-search-kbd { 978 + display: none; 979 + } 980 + 917 981 .untangled-file-row { 918 982 display: grid; 919 983 grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); ··· 937 1001 display: grid; 938 1002 grid-template-columns: auto minmax(0, 1fr) auto; 939 1003 gap: 0.5rem; 1004 + } 1005 + 1006 + .untangled-repo-filter-search { 1007 + position: relative; 1008 + display: flex; 1009 + min-width: 0; 1010 + } 1011 + 1012 + .untangled-repo-filter-input { 1013 + min-width: 0; 1014 + flex: 1; 1015 + border: 1px solid rgb(209 213 219); 1016 + border-radius: 0.25rem 0 0 0.25rem; 1017 + background: rgb(255 255 255); 1018 + padding: 0.25rem 2.5rem 0.25rem 0.5rem; 1019 + color: rgb(17 24 39); 1020 + font-size: 0.875rem; 1021 + line-height: 1.5rem; 1022 + outline: none; 1023 + } 1024 + 1025 + .untangled-repo-filter-input:focus { 1026 + border-color: rgb(156 163 175); 1027 + box-shadow: 0 0 0 1px rgb(156 163 175); 1028 + z-index: 1; 1029 + } 1030 + 1031 + .untangled-repo-filter-input::placeholder { 1032 + color: rgb(156 163 175); 1033 + } 1034 + 1035 + .untangled-repo-filter-reset { 1036 + position: absolute; 1037 + right: 2.75rem; 1038 + top: 50%; 1039 + display: none; 1040 + color: rgb(156 163 175); 1041 + transform: translateY(-50%); 1042 + } 1043 + 1044 + .untangled-repo-filter-input:not(:placeholder-shown) + .untangled-repo-filter-reset { 1045 + display: block; 1046 + } 1047 + 1048 + .untangled-repo-filter-reset:hover { 1049 + color: rgb(75 85 99); 1050 + } 1051 + 1052 + .untangled-repo-filter-submit { 1053 + display: inline-flex; 1054 + align-items: center; 1055 + justify-content: center; 1056 + width: 2.5rem; 1057 + border: 1px solid rgb(209 213 219); 1058 + border-left: 0; 1059 + border-radius: 0 0.25rem 0.25rem 0; 1060 + background: rgb(255 255 255); 1061 + color: rgb(156 163 175); 1062 + } 1063 + 1064 + .untangled-repo-filter-submit:hover { 1065 + color: rgb(31 41 55); 1066 + } 1067 + 1068 + .untangled-search-grid { 1069 + display: grid; 1070 + grid-template-columns: minmax(0, 1fr); 1071 + gap: 1rem; 1072 + } 1073 + 1074 + .untangled-search-main { 1075 + display: flex; 1076 + min-width: 0; 1077 + flex-direction: column; 1078 + gap: 1rem; 1079 + } 1080 + 1081 + .untangled-search-sidebar { 1082 + display: none; 1083 + } 1084 + 1085 + .untangled-search-page-input { 1086 + min-width: 0; 1087 + flex: 1; 1088 + border: 1px solid rgb(209 213 219); 1089 + border-radius: 0.25rem 0 0 0.25rem; 1090 + background: rgb(255 255 255); 1091 + margin-right: -1px; 1092 + padding: 0.25rem 2.5rem 0.25rem 0.5rem; 1093 + color: rgb(17 24 39); 1094 + outline: none; 1095 + } 1096 + 1097 + .untangled-search-page-input::placeholder { 1098 + color: rgb(156 163 175); 1099 + } 1100 + 1101 + .untangled-search-page-input:focus { 1102 + border-color: rgb(156 163 175); 1103 + box-shadow: 0 0 0 1px rgb(156 163 175); 1104 + z-index: 1; 1105 + } 1106 + 1107 + .untangled-search-page-submit { 1108 + display: inline-flex; 1109 + align-items: center; 1110 + justify-content: center; 1111 + border: 1px solid rgb(209 213 219); 1112 + border-radius: 0 0.25rem 0.25rem 0; 1113 + padding: 0.5rem; 1114 + background: rgb(255 255 255); 1115 + color: rgb(156 163 175); 1116 + } 1117 + 1118 + .untangled-search-page-submit:hover { 1119 + color: rgb(75 85 99); 1120 + } 1121 + 1122 + .untangled-search-type-dot { 1123 + width: 0.5rem; 1124 + height: 0.5rem; 1125 + flex-shrink: 0; 1126 + border-radius: 9999px; 1127 + background: radial-gradient(circle at 35% 35%, rgb(156 163 175), rgb(107 114 128) 30%, rgb(75 85 99)); 1128 + } 1129 + 1130 + .untangled-search-description { 1131 + display: -webkit-box; 1132 + overflow: hidden; 1133 + -webkit-box-orient: vertical; 1134 + -webkit-line-clamp: 2; 1135 + } 1136 + 1137 + @media (min-width: 1024px) { 1138 + .untangled-topbar-search-box { 1139 + width: 24rem; 1140 + } 1141 + } 1142 + 1143 + @media (min-width: 768px) { 1144 + .untangled-search-grid { 1145 + grid-template-columns: repeat(4, minmax(0, 1fr)); 1146 + } 1147 + 1148 + .untangled-search-main { 1149 + grid-column: span 3 / span 3; 1150 + } 1151 + 1152 + .untangled-search-sidebar { 1153 + position: sticky; 1154 + top: 0.5rem; 1155 + display: block; 1156 + align-self: start; 1157 + } 940 1158 } 941 1159 942 1160 .untangled-repo-actions { ··· 1601 1819 background: rgb(31 41 55); 1602 1820 } 1603 1821 1822 + .untangled-topbar-search-box, 1823 + .untangled-search-page-input, 1824 + .untangled-search-page-submit, 1825 + .untangled-repo-filter-input, 1826 + .untangled-repo-filter-submit { 1827 + border-color: rgb(55 65 81); 1828 + background: rgb(17 24 39); 1829 + } 1830 + 1831 + .untangled-topbar-search-box:focus-within { 1832 + outline-color: rgb(75 85 99); 1833 + } 1834 + 1835 + .untangled-topbar-search-input, 1836 + .untangled-search-page-input, 1837 + .untangled-repo-filter-input { 1838 + color: rgb(229 231 235); 1839 + } 1840 + 1841 + .untangled-topbar-search-input::placeholder, 1842 + .untangled-search-page-input::placeholder, 1843 + .untangled-repo-filter-input::placeholder { 1844 + color: rgb(75 85 99); 1845 + } 1846 + 1847 + .untangled-topbar-search-kbd { 1848 + border-color: rgb(75 85 99); 1849 + color: rgb(107 114 128); 1850 + } 1851 + 1852 + .untangled-search-page-input:focus, 1853 + .untangled-repo-filter-input:focus { 1854 + border-color: rgb(75 85 99); 1855 + box-shadow: 0 0 0 1px rgb(75 85 99); 1856 + } 1857 + 1858 + .untangled-search-page-submit, 1859 + .untangled-repo-filter-reset, 1860 + .untangled-repo-filter-submit { 1861 + color: rgb(156 163 175); 1862 + } 1863 + 1864 + .untangled-search-page-submit:hover, 1865 + .untangled-repo-filter-reset:hover, 1866 + .untangled-repo-filter-submit:hover { 1867 + color: rgb(229 231 235); 1868 + } 1869 + 1604 1870 .untangled-blob-commit-panel { 1605 1871 border-bottom-color: rgb(55 65 81); 1606 1872 } ··· 1616 1882 .untangled-filter-grid, 1617 1883 .untangled-home-grid { 1618 1884 grid-template-columns: minmax(0, 1fr); 1885 + } 1886 + 1887 + .untangled-repo-filter-search { 1888 + grid-row: 1; 1619 1889 } 1620 1890 1621 1891 .untangled-file-row {
+46 -5
src/layout.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Bell, LogOut, RotateCcw, Save, Settings } from 'lucide-solid'; 3 - import { A } from '@solidjs/router'; 2 + import { Bell, LogOut, RotateCcw, Save, Search, Settings } from 'lucide-solid'; 3 + import { A, useLocation, useNavigate } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 6 6 ··· 242 242 ); 243 243 }; 244 244 245 + const TopbarSearch: Component = () => { 246 + const navigate = useNavigate(); 247 + const location = useLocation(); 248 + const [query, setQuery] = createSignal(''); 249 + 250 + createEffect(() => { 251 + if (location.pathname !== '/search') { 252 + setQuery(''); 253 + return; 254 + } 255 + 256 + const params = new URLSearchParams(location.search); 257 + setQuery(params.get('q') ?? ''); 258 + }); 259 + 260 + const onSubmit = (event: SubmitEvent) => { 261 + event.preventDefault(); 262 + const trimmed = query().trim(); 263 + navigate(trimmed ? `/search?q=${encodeURIComponent(trimmed)}` : '/search'); 264 + }; 265 + 266 + return ( 267 + <form onSubmit={onSubmit} class="relative hidden md:block"> 268 + <div class="untangled-topbar-search-box"> 269 + <Search class="size-4 shrink-0 text-gray-400" /> 270 + <input 271 + value={query()} 272 + onInput={(event) => setQuery(event.currentTarget.value)} 273 + placeholder="search..." 274 + class="untangled-topbar-search-input" 275 + /> 276 + <kbd class="untangled-topbar-search-kbd">/</kbd> 277 + </div> 278 + </form> 279 + ); 280 + }; 281 + 245 282 const Topbar: Component = () => { 246 283 const auth = useAuth(); 247 284 let topbar: HTMLElement | undefined; ··· 265 302 266 303 return ( 267 304 <nav ref={(element) => { topbar = element; }} class="mx-auto space-x-4 px-6 py-2"> 268 - <div class="flex justify-between p-0 items-center"> 269 - <A href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 305 + <div class="flex justify-between gap-4 p-0 items-center"> 306 + <A href="/" class="text-2xl no-underline hover:no-underline flex shrink-0 items-center gap-2"> 270 307 <img src="/static/logos/dolly.svg" alt="" class="size-8" /> 271 308 <span class="font-bold text-xl">untangled</span> 272 309 <span class="font-normal text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> 273 310 </A> 274 311 275 - <div class="flex items-center gap-4"> 312 + <div class="flex shrink-0 items-center gap-4"> 313 + <TopbarSearch /> 314 + <A href="/search" class="md:hidden text-gray-500 dark:text-gray-400" title="Search"> 315 + <Search class="size-5" /> 316 + </A> 276 317 <AppSettingsMenu /> 277 318 <Show 278 319 when={auth.currentDid()}
+13
src/lib/api.ts
··· 24 24 getRepo, 25 25 getRepoBlob, 26 26 getRepoBranches, 27 + getRepoByDid, 27 28 getRepoDefaultBranch, 28 29 getRepoLanguages, 29 30 getRepoLog, ··· 41 42 createIssue, 42 43 createIssueComment, 43 44 getIssue, 45 + getIssueRecord, 44 46 listIssues, 45 47 listIssuesPage, 46 48 setIssueState, ··· 50 52 createPullComment, 51 53 fetchPullRoundPatch, 52 54 getPull, 55 + getPullRecord, 53 56 listPulls, 54 57 listPullsPage, 55 58 setPullStatus, 56 59 } from './api/pulls'; 60 + export { 61 + searchRecords, 62 + } from './api/search'; 57 63 58 64 export type { 59 65 HydratedRecord, ··· 91 97 PullDetail, 92 98 PullSummary, 93 99 } from './api/pulls'; 100 + export type { 101 + SearchCollection, 102 + SearchHit, 103 + SearchRecordValue, 104 + SearchRecordsOptions, 105 + SearchResponse, 106 + } from './api/search';
+174 -12
src/lib/api/core.ts
··· 16 16 import { 17 17 ShTangledActorProfile, 18 18 ShTangledFeedStar, 19 + ShTangledLabelDefinition, 19 20 ShTangledRepo, 20 21 ShTangledRepoIssue, 21 22 ShTangledRepoIssueComment, ··· 23 24 ShTangledRepoPull, 24 25 ShTangledRepoPullComment, 25 26 ShTangledRepoPullStatus, 27 + ShTangledString, 26 28 } from '@atcute/tangled'; 27 29 import { gunzipSync, gzipSync, strFromU8, strToU8 } from 'fflate'; 28 30 ··· 132 134 state: State; 133 135 }; 134 136 137 + type StatefulPageOptions<State extends string> = { 138 + offset: number; 139 + limit: number; 140 + state: State; 141 + author?: Did; 142 + query?: string; 143 + }; 144 + 135 145 export interface IssueComment extends HydratedRecord<ShTangledRepoIssueComment.Main> {} 136 146 export interface PullComment extends HydratedRecord<ShTangledRepoPullComment.Main> {} 137 147 ··· 145 155 comments: PullComment[]; 146 156 } 147 157 158 + export type SearchCollection = 159 + | 'sh.tangled.actor.profile' 160 + | 'sh.tangled.repo' 161 + | 'sh.tangled.repo.issue' 162 + | 'sh.tangled.repo.issue.comment' 163 + | 'sh.tangled.repo.pull' 164 + | 'sh.tangled.repo.pull.comment' 165 + | 'sh.tangled.string' 166 + | 'sh.tangled.label.definition'; 167 + 168 + export type SearchRecordValue = 169 + | ShTangledActorProfile.Main 170 + | ShTangledRepo.Main 171 + | ShTangledRepoIssue.Main 172 + | ShTangledRepoIssueComment.Main 173 + | ShTangledRepoPull.Main 174 + | ShTangledRepoPullComment.Main 175 + | ShTangledString.Main 176 + | ShTangledLabelDefinition.Main; 177 + 178 + export interface SearchHit extends HydratedRecord<SearchRecordValue> { 179 + nsid: SearchCollection; 180 + score: number; 181 + } 182 + 183 + export interface SearchResponse { 184 + hits: SearchHit[]; 185 + cursor?: string; 186 + } 187 + 188 + export interface SearchRecordsOptions { 189 + q: string; 190 + nsid?: SearchCollection; 191 + author?: Did; 192 + repo?: Did; 193 + since?: string; 194 + until?: string; 195 + cursor?: string; 196 + limit?: number; 197 + } 198 + 148 199 export interface PaginatedResult<T> { 149 200 items: T[]; 150 201 totalCount?: number; ··· 358 409 cursor?: string | null; 359 410 } 360 411 412 + interface AppviewSearchHit<T> extends AppviewRecordView<T> { 413 + nsid: SearchCollection; 414 + score: number; 415 + } 416 + 417 + interface AppviewSearchResponse<T> { 418 + hits: Array<AppviewSearchHit<T>>; 419 + cursor?: string | null; 420 + } 421 + 361 422 interface AppviewCountResponse { 362 423 count: number; 363 424 distinctAuthors?: number; ··· 445 506 446 507 const paginateStatefulRecords = <T, State extends string>( 447 508 items: Array<StatefulHydratedRecord<T, State>>, 448 - options: { offset: number; limit: number; state: State }, 509 + options: StatefulPageOptions<State>, 449 510 ): PaginatedResult<StatefulHydratedRecord<T, State>> => { 450 511 const offset = Math.max(options.offset, 0); 451 512 const limit = Math.max(options.limit, 1); 452 - const matching = items.filter((item) => item.state === options.state); 513 + const matching = items.filter((item) => 514 + item.state === options.state && 515 + (!options.author || item.author.did === options.author) && 516 + statefulRecordMatchesQuery(item.value, options.query), 517 + ); 453 518 454 519 return { 455 520 items: matching.slice(offset, offset + limit), ··· 458 523 }; 459 524 }; 460 525 526 + const statefulRecordMatchesQuery = (value: unknown, query?: string): boolean => { 527 + const normalized = query?.trim().toLowerCase(); 528 + if (!normalized) { 529 + return true; 530 + } 531 + 532 + const record = value as { title?: unknown; body?: unknown }; 533 + return [record.title, record.body] 534 + .filter((part): part is string => typeof part === 'string') 535 + .some((part) => part.toLowerCase().includes(normalized)); 536 + }; 537 + 461 538 const normalizeServiceUrl = (input: string): string => { 462 539 if (input.startsWith('http://') || input.startsWith('https://')) { 463 540 return input; ··· 691 768 const listAppviewStatefulRecordsPage = async <T extends { createdAt?: string }, State extends string>( 692 769 nsid: string, 693 770 subject: string, 694 - options: { offset: number; limit: number; state: State }, 771 + options: StatefulPageOptions<State>, 695 772 normalizeState: (value?: string) => State, 696 773 extraParams: Record<string, AppviewParamValue> = {}, 697 774 getUpdatedAt?: ( ··· 780 857 scannedSeen += 1; 781 858 782 859 if (state !== options.state) { 860 + continue; 861 + } 862 + 863 + if (!statefulRecordMatchesQuery(record.value, options.query)) { 783 864 continue; 784 865 } 785 866 ··· 1082 1163 }; 1083 1164 }; 1084 1165 1166 + export const getRepoByDid = async (repoDid: Did): Promise<RepoContext> => { 1167 + const record = await appviewJson<AppviewRecordView<ShTangledRepo.Main>>( 1168 + 'sh.tangled.repo.getRepoByRepoDid', 1169 + { repoDid }, 1170 + ); 1171 + const parsed = parseAtUri(record.uri); 1172 + const owner = await resolveActor(parsed.did); 1173 + const slug = record.value.name?.trim() || parsed.rkey; 1174 + 1175 + if (!record.value.repoDid) { 1176 + throw new Error(`Repository is missing repoDid metadata`); 1177 + } 1178 + 1179 + return { 1180 + owner, 1181 + record: { 1182 + uri: record.uri, 1183 + cid: record.cid ?? '', 1184 + rkey: parsed.rkey, 1185 + value: record.value, 1186 + }, 1187 + repoDid: record.value.repoDid, 1188 + slug, 1189 + rkey: parsed.rkey, 1190 + knot: normalizeServiceUrl(record.value.knot), 1191 + }; 1192 + }; 1193 + 1194 + export const searchRecords = async (options: SearchRecordsOptions): Promise<SearchResponse> => { 1195 + const q = options.q.trim(); 1196 + if (!q) { 1197 + return { hits: [] }; 1198 + } 1199 + 1200 + const response = await appviewJson<AppviewSearchResponse<SearchRecordValue>>( 1201 + 'sh.tangled.search.query', 1202 + { 1203 + q, 1204 + nsid: options.nsid, 1205 + author: options.author, 1206 + repo: options.repo, 1207 + since: options.since, 1208 + until: options.until, 1209 + cursor: options.cursor, 1210 + limit: options.limit, 1211 + }, 1212 + ); 1213 + const hits = await Promise.all( 1214 + response.hits.map(async (hit) => ({ 1215 + ...(await appviewRecordToHydrated(hit)), 1216 + nsid: hit.nsid, 1217 + score: hit.score, 1218 + })), 1219 + ); 1220 + 1221 + return { 1222 + hits, 1223 + cursor: response.cursor ?? undefined, 1224 + }; 1225 + }; 1226 + 1227 + export const getIssueRecord = async (issueUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoIssue.Main>> => { 1228 + const record = await appviewJson<AppviewRecordView<ShTangledRepoIssue.Main>>( 1229 + 'sh.tangled.repo.getIssue', 1230 + { issue: issueUri }, 1231 + ); 1232 + return appviewRecordToHydrated(record); 1233 + }; 1234 + 1235 + export const getPullRecord = async (pullUri: ResourceUri): Promise<HydratedRecord<ShTangledRepoPull.Main>> => { 1236 + const record = await appviewJson<AppviewRecordView<ShTangledRepoPull.Main>>( 1237 + 'sh.tangled.repo.getPull', 1238 + { pull: pullUri }, 1239 + ); 1240 + return appviewRecordToHydrated(record); 1241 + }; 1242 + 1085 1243 const getRepoTreeFromKnot = async ( 1086 1244 repo: RepoContext, 1087 1245 ref: string, ··· 1504 1662 const listBacklinkedRecordsPage = async <T extends { createdAt?: string }, State extends string>( 1505 1663 subject: GenericUri, 1506 1664 source: string | string[], 1507 - options: { offset: number; limit: number; state: State }, 1665 + options: StatefulPageOptions<State>, 1508 1666 getState: (uri: ResourceUri) => Promise<State>, 1509 1667 getUpdatedAt?: (record: StatefulHydratedRecord<T, State>) => Promise<number>, 1510 1668 ): Promise<PaginatedResult<StatefulHydratedRecord<T, State>>> => { ··· 1587 1745 scannedSeen += 1; 1588 1746 1589 1747 if (state !== options.state) { 1748 + continue; 1749 + } 1750 + 1751 + if (!statefulRecordMatchesQuery(record.value, options.query)) { 1590 1752 continue; 1591 1753 } 1592 1754 ··· 1678 1840 1679 1841 const listIssuesPageFromBacklinks = async ( 1680 1842 repo: RepoContext, 1681 - options: { offset: number; limit: number; state: 'open' | 'closed' }, 1843 + options: StatefulPageOptions<'open' | 'closed'>, 1682 1844 ): Promise<PaginatedResult<IssueSummary>> => 1683 1845 listBacklinkedRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 1684 1846 repo.repoDid, ··· 1723 1885 1724 1886 const listPullsPageFromBacklinks = async ( 1725 1887 repo: RepoContext, 1726 - options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 1888 + options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 1727 1889 ): Promise<PaginatedResult<PullSummary>> => 1728 1890 listBacklinkedRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 1729 1891 repo.repoDid, ··· 1794 1956 1795 1957 const listIssuesPageFromAppview = async ( 1796 1958 repo: RepoContext, 1797 - options: { offset: number; limit: number; state: 'open' | 'closed' }, 1959 + options: StatefulPageOptions<'open' | 'closed'>, 1798 1960 ): Promise<PaginatedResult<IssueSummary>> => 1799 1961 listAppviewStatefulRecordsPage<ShTangledRepoIssue.Main, 'open' | 'closed'>( 1800 1962 'sh.tangled.repo.listIssues', 1801 1963 repo.repoDid, 1802 1964 options, 1803 1965 normalizeIssueState, 1804 - {}, 1966 + options.author ? { author: options.author } : {}, 1805 1967 (issue, record) => getIssueUpdatedAt(issue, record.stateUpdatedAt), 1806 1968 ); 1807 1969 ··· 1842 2004 1843 2005 const listPullsPageFromAppview = async ( 1844 2006 repo: RepoContext, 1845 - options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 2007 + options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 1846 2008 ): Promise<PaginatedResult<PullSummary>> => 1847 2009 listAppviewStatefulRecordsPage<ShTangledRepoPull.Main, 'open' | 'closed' | 'merged'>( 1848 2010 'sh.tangled.repo.listPulls', 1849 2011 repo.repoDid, 1850 2012 options, 1851 2013 normalizePullStatus, 1852 - {}, 2014 + options.author ? { author: options.author } : {}, 1853 2015 (pull, record) => getPullUpdatedAt(pull, record.stateUpdatedAt), 1854 2016 ); 1855 2017 ··· 1874 2036 1875 2037 export const listIssuesPage = async ( 1876 2038 repo: RepoContext, 1877 - options: { offset: number; limit: number; state: 'open' | 'closed' }, 2039 + options: StatefulPageOptions<'open' | 'closed'>, 1878 2040 ): Promise<PaginatedResult<IssueSummary>> => 1879 2041 appviewFallback( 1880 2042 () => listIssuesPageFromAppview(repo, options), ··· 1895 2057 1896 2058 export const listPullsPage = async ( 1897 2059 repo: RepoContext, 1898 - options: { offset: number; limit: number; state: 'open' | 'closed' | 'merged' }, 2060 + options: StatefulPageOptions<'open' | 'closed' | 'merged'>, 1899 2061 ): Promise<PaginatedResult<PullSummary>> => 1900 2062 appviewFallback( 1901 2063 () => listPullsPageFromAppview(repo, options),
+1
src/lib/api/issues.ts
··· 2 2 createIssue, 3 3 createIssueComment, 4 4 getIssue, 5 + getIssueRecord, 5 6 listIssues, 6 7 listIssuesPage, 7 8 setIssueState,
+1
src/lib/api/pulls.ts
··· 3 3 createPullComment, 4 4 fetchPullRoundPatch, 5 5 getPull, 6 + getPullRecord, 6 7 listPulls, 7 8 listPullsPage, 8 9 setPullStatus,
+1
src/lib/api/repos.ts
··· 6 6 getRepoBlob, 7 7 getRepoBranches, 8 8 getRepoDefaultBranch, 9 + getRepoByDid, 9 10 getRepoLanguages, 10 11 getRepoLog, 11 12 getRepoTags,
+11
src/lib/api/search.ts
··· 1 + export { 2 + searchRecords, 3 + } from './core'; 4 + 5 + export type { 6 + SearchCollection, 7 + SearchHit, 8 + SearchRecordValue, 9 + SearchRecordsOptions, 10 + SearchResponse, 11 + } from './core';
+53
src/pages/repo/issues-helpers.ts
··· 1 1 import type { IssueComment } from '../../lib/api'; 2 + import type { SearchParamValue } from '../../lib/repo-utils'; 3 + 4 + export type IssueStateFilter = 'open' | 'closed'; 5 + 6 + export interface IssueFilter { 7 + state: IssueStateFilter; 8 + author?: string; 9 + query?: string; 10 + } 11 + 12 + const searchParamString = (value: SearchParamValue): string => Array.isArray(value) ? value[0] ?? '' : value ?? ''; 13 + 14 + export const parseIssueFilter = (value: SearchParamValue): IssueFilter => { 15 + const filter: IssueFilter = { state: 'open' }; 16 + const tokens = searchParamString(value).split(/\s+/).filter(Boolean); 17 + const queryTokens: string[] = []; 18 + 19 + for (const token of tokens) { 20 + if (token === 'state:closed') { 21 + filter.state = 'closed'; 22 + continue; 23 + } 24 + 25 + if (token === 'state:open') { 26 + filter.state = 'open'; 27 + continue; 28 + } 29 + 30 + if (token.startsWith('author:')) { 31 + const author = token.slice('author:'.length).trim(); 32 + if (author) { 33 + filter.author = author; 34 + } 35 + continue; 36 + } 37 + 38 + queryTokens.push(token); 39 + } 40 + 41 + const query = queryTokens.join(' ').trim(); 42 + if (query) { 43 + filter.query = query; 44 + } 45 + 46 + return filter; 47 + }; 48 + 49 + export const buildIssueFilterQuery = (filter: IssueFilter): string => 50 + [ 51 + filter.query, 52 + `state:${filter.state}`, 53 + filter.author ? `author:${filter.author}` : undefined, 54 + ].filter(Boolean).join(' '); 2 55 3 56 export type IssueCommentThread = { 4 57 item: IssueComment;
+56 -17
src/pages/repo/issues.tsx
··· 3 3 import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import type { ResourceUri } from '@atcute/lexicons/syntax'; 6 - import { For, Match, Show, Switch, createMemo, createSignal, type Component } from 'solid-js'; 6 + import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 7 7 import { createIssue, createIssueComment, getIssue, listIssuesPage, parseAtUri, resolveActor, setIssueState, type RepoContext } from '../../lib/api'; 8 8 import { useAuth } from '../../lib/auth'; 9 - import { Avatar, ErrorState, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 10 - import { MarkdownBlock, PaginationControls, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 9 + import { Avatar, ErrorState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 10 + import { MarkdownBlock, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 11 11 import { RepoFrame, issueQueryKey, issuesQueryKey, useRepoQuery } from './shared'; 12 12 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, issueHref, parseIntegerSearchParam, uniqueCommenters } from '../../lib/repo-utils'; 13 - import { buildIssueCommentThreads } from './issues-helpers'; 13 + import { buildIssueCommentThreads, buildIssueFilterQuery, parseIssueFilter } from './issues-helpers'; 14 14 15 15 export const IssuesPage: Component = () => { 16 16 const auth = useAuth(); 17 17 const repoQuery = useRepoQuery(); 18 18 const [searchParams, setSearchParams] = useSearchParams(); 19 - const stateFilter = createMemo<'open' | 'closed'>(() => (searchParams.q === 'state:closed' ? 'closed' : 'open')); 20 - const filterQuery = createMemo(() => `state:${stateFilter()}`); 19 + const filter = createMemo(() => parseIssueFilter(searchParams.q)); 20 + const stateFilter = createMemo(() => filter().state); 21 + const authorFilter = createMemo(() => filter().author); 22 + const textFilter = createMemo(() => filter().query); 23 + const filterQuery = createMemo(() => buildIssueFilterQuery(filter())); 24 + const [filterDraft, setFilterDraft] = createSignal(filterQuery()); 21 25 const pageOffset = createMemo(() => parseIntegerSearchParam(searchParams.offset, 0, 0)); 22 26 const pageLimit = createMemo(() => parseIntegerSearchParam(searchParams.limit, REPO_LIST_PAGE_LIMIT, 1)); 23 27 const basePath = createMemo(() => { 24 28 const repo = repoQuery.data; 25 29 return repo ? `/${repo.owner.handle}/${repo.slug}/issues` : '/'; 30 + }); 31 + createEffect(() => { 32 + setFilterDraft(filterQuery()); 26 33 }); 27 34 const setStateFilter = (state: 'open' | 'closed') => { 28 35 setSearchParams({ 29 - q: `state:${state}`, 36 + q: buildIssueFilterQuery({ ...filter(), state }), 37 + offset: '0', 38 + limit: String(pageLimit()), 39 + }); 40 + }; 41 + const applyFilter = (event: SubmitEvent) => { 42 + event.preventDefault(); 43 + setSearchParams({ 44 + q: buildIssueFilterQuery(parseIssueFilter(filterDraft())), 45 + offset: '0', 46 + limit: String(pageLimit()), 47 + }); 48 + }; 49 + const resetFilter = () => { 50 + setSearchParams({ 51 + q: 'state:open', 30 52 offset: '0', 31 53 limit: String(pageLimit()), 32 54 }); ··· 35 57 const issuesQuery = createQuery(() => { 36 58 const repo = repoQuery.data; 37 59 return { 38 - queryKey: [...issuesQueryKey(repo?.repoDid ?? ''), stateFilter(), pageOffset(), pageLimit()], 60 + queryKey: [...issuesQueryKey(repo?.repoDid ?? ''), stateFilter(), authorFilter(), textFilter(), pageOffset(), pageLimit()], 39 61 enabled: Boolean(repo), 40 - queryFn: async () => 41 - listIssuesPage(repo!, { 62 + queryFn: async () => { 63 + const author = authorFilter() ? (await resolveActor(authorFilter()!)).did : undefined; 64 + return listIssuesPage(repo!, { 42 65 state: stateFilter(), 66 + author, 67 + query: textFilter(), 43 68 offset: pageOffset(), 44 69 limit: pageLimit(), 45 - }), 70 + }); 71 + }, 46 72 }; 47 73 }); 48 74 ··· 72 98 /> 73 99 </div> 74 100 75 - <div class="hidden min-w-0 items-center rounded border border-gray-300 px-3 text-gray-500 dark:border-gray-700 md:flex"> 76 - state:{stateFilter()} 77 - <span class="ml-auto">×</span> 78 - <Search class="ml-3 size-4" /> 79 - </div> 101 + <form onSubmit={applyFilter} class="untangled-repo-filter-search"> 102 + <input 103 + value={filterDraft()} 104 + onInput={(event) => setFilterDraft(event.currentTarget.value)} 105 + placeholder="search issues..." 106 + class="untangled-repo-filter-input" 107 + /> 108 + <button type="button" class="untangled-repo-filter-reset" aria-label="reset filters" onClick={resetFilter}> 109 + <X class="size-4" /> 110 + </button> 111 + <button type="submit" class="untangled-repo-filter-submit" aria-label="apply filters"> 112 + <Search class="size-4" /> 113 + </button> 114 + </form> 80 115 81 116 <Show when={auth.agent() && repoQuery.data}> 82 117 <A ··· 109 144 when={paginated().length > 0} 110 145 fallback={ 111 146 <div class={cardStyles('px-6 py-4 text-sm text-gray-500 dark:text-gray-400')}> 112 - {stateFilter() === 'open' ? 'No open issues.' : 'No closed issues.'} 147 + {stateFilter() === 'open' ? 'No open issues' : 'No closed issues'} 148 + <Show when={authorFilter()}> 149 + {' '}by {authorFilter()} 150 + </Show> 151 + . 113 152 </div> 114 153 } 115 154 >
+51 -6
src/pages/repo/pulls-helpers.ts
··· 3 3 export type PullStateFilter = 'open' | 'closed' | 'merged'; 4 4 export type PullSourceMode = 'branch' | 'patch'; 5 5 6 - export const parsePullStateFilter = (value: SearchParamValue): PullStateFilter => { 7 - if (value === 'state:closed') { 8 - return 'closed'; 6 + export interface PullFilter { 7 + state: PullStateFilter; 8 + author?: string; 9 + query?: string; 10 + } 11 + 12 + const searchParamString = (value: SearchParamValue): string => Array.isArray(value) ? value[0] ?? '' : value ?? ''; 13 + 14 + export const parsePullFilter = (value: SearchParamValue): PullFilter => { 15 + const filter: PullFilter = { state: 'open' }; 16 + const tokens = searchParamString(value).split(/\s+/).filter(Boolean); 17 + const queryTokens: string[] = []; 18 + 19 + for (const token of tokens) { 20 + if (token === 'state:closed') { 21 + filter.state = 'closed'; 22 + continue; 23 + } 24 + 25 + if (token === 'state:merged') { 26 + filter.state = 'merged'; 27 + continue; 28 + } 29 + 30 + if (token === 'state:open') { 31 + filter.state = 'open'; 32 + continue; 33 + } 34 + 35 + if (token.startsWith('author:')) { 36 + const author = token.slice('author:'.length).trim(); 37 + if (author) { 38 + filter.author = author; 39 + } 40 + continue; 41 + } 42 + 43 + queryTokens.push(token); 9 44 } 10 45 11 - if (value === 'state:merged') { 12 - return 'merged'; 46 + const query = queryTokens.join(' ').trim(); 47 + if (query) { 48 + filter.query = query; 13 49 } 14 50 15 - return 'open'; 51 + return filter; 16 52 }; 53 + 54 + export const parsePullStateFilter = (value: SearchParamValue): PullStateFilter => parsePullFilter(value).state; 55 + 56 + export const buildPullFilterQuery = (filter: PullFilter): string => 57 + [ 58 + filter.query, 59 + `state:${filter.state}`, 60 + filter.author ? `author:${filter.author}` : undefined, 61 + ].filter(Boolean).join(' '); 17 62 18 63 export const parsePastedPatchPrefill = (patch: string): { title: string; body: string } => { 19 64 const subject = /^Subject:\s*(?:\[[^\]]+\]\s*)?(.*)$/im.exec(patch)?.[1]?.trim() ?? '';
+57 -17
src/pages/repo/pulls.tsx
··· 12 12 MessageSquare, 13 13 Pencil, 14 14 Search, 15 + X, 15 16 } from 'lucide-solid'; 16 17 import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 17 18 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 18 19 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 19 - import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, setPullStatus, type RepoContext } from '../../lib/api'; 20 + import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, resolveActor, setPullStatus, type RepoContext } from '../../lib/api'; 20 21 import { useAuth } from '../../lib/auth'; 21 - import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 22 - import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffSkeleton, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 22 + import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 23 + import { BranchPill, CommentCard, DiffView, MarkdownBlock, PullDiffSkeleton, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 23 24 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 24 25 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils'; 25 - import { parsePastedPatchPrefill, parsePullStateFilter, type PullSourceMode, type PullStateFilter } from './pulls-helpers'; 26 + import { buildPullFilterQuery, parsePastedPatchPrefill, parsePullFilter, type PullSourceMode } from './pulls-helpers'; 26 27 27 28 export const PullsPage: Component = () => { 28 29 const auth = useAuth(); 29 30 const repoQuery = useRepoQuery(); 30 31 const [searchParams, setSearchParams] = useSearchParams(); 31 - const stateFilter = createMemo<PullStateFilter>(() => parsePullStateFilter(searchParams.q)); 32 - const filterQuery = createMemo(() => `state:${stateFilter()}`); 32 + const filter = createMemo(() => parsePullFilter(searchParams.q)); 33 + const stateFilter = createMemo(() => filter().state); 34 + const authorFilter = createMemo(() => filter().author); 35 + const textFilter = createMemo(() => filter().query); 36 + const filterQuery = createMemo(() => buildPullFilterQuery(filter())); 37 + const [filterDraft, setFilterDraft] = createSignal(filterQuery()); 33 38 const pageOffset = createMemo(() => parseIntegerSearchParam(searchParams.offset, 0, 0)); 34 39 const pageLimit = createMemo(() => parseIntegerSearchParam(searchParams.limit, REPO_LIST_PAGE_LIMIT, 1)); 35 40 const basePath = createMemo(() => { 36 41 const repo = repoQuery.data; 37 42 return repo ? `/${repo.owner.handle}/${repo.slug}/pulls` : '/'; 38 43 }); 44 + createEffect(() => { 45 + setFilterDraft(filterQuery()); 46 + }); 39 47 const setStateFilter = (state: 'open' | 'closed' | 'merged') => { 40 48 setSearchParams({ 41 - q: `state:${state}`, 49 + q: buildPullFilterQuery({ ...filter(), state }), 50 + offset: '0', 51 + limit: String(pageLimit()), 52 + }); 53 + }; 54 + const applyFilter = (event: SubmitEvent) => { 55 + event.preventDefault(); 56 + setSearchParams({ 57 + q: buildPullFilterQuery(parsePullFilter(filterDraft())), 58 + offset: '0', 59 + limit: String(pageLimit()), 60 + }); 61 + }; 62 + const resetFilter = () => { 63 + setSearchParams({ 64 + q: 'state:open', 42 65 offset: '0', 43 66 limit: String(pageLimit()), 44 67 }); ··· 47 70 const pullsQuery = createQuery(() => { 48 71 const repo = repoQuery.data; 49 72 return { 50 - queryKey: [...pullsQueryKey(repo?.repoDid ?? ''), stateFilter(), pageOffset(), pageLimit()], 73 + queryKey: [...pullsQueryKey(repo?.repoDid ?? ''), stateFilter(), authorFilter(), textFilter(), pageOffset(), pageLimit()], 51 74 enabled: Boolean(repo), 52 - queryFn: async () => 53 - listPullsPage(repo!, { 75 + queryFn: async () => { 76 + const author = authorFilter() ? (await resolveActor(authorFilter()!)).did : undefined; 77 + return listPullsPage(repo!, { 54 78 state: stateFilter(), 79 + author, 80 + query: textFilter(), 55 81 offset: pageOffset(), 56 82 limit: pageLimit(), 57 - }), 83 + }); 84 + }, 58 85 }; 59 86 }); 60 87 ··· 90 117 /> 91 118 </div> 92 119 93 - <div class="hidden min-w-0 items-center rounded border border-gray-300 px-3 text-gray-500 dark:border-gray-700 md:flex"> 94 - state:{stateFilter()} 95 - <span class="ml-auto">×</span> 96 - <Search class="ml-3 size-4" /> 97 - </div> 120 + <form onSubmit={applyFilter} class="untangled-repo-filter-search"> 121 + <input 122 + value={filterDraft()} 123 + onInput={(event) => setFilterDraft(event.currentTarget.value)} 124 + placeholder="search pulls..." 125 + class="untangled-repo-filter-input" 126 + /> 127 + <button type="button" class="untangled-repo-filter-reset" aria-label="reset filters" onClick={resetFilter}> 128 + <X class="size-4" /> 129 + </button> 130 + <button type="submit" class="untangled-repo-filter-submit" aria-label="apply filters"> 131 + <Search class="size-4" /> 132 + </button> 133 + </form> 98 134 99 135 <Show when={auth.agent() && repoQuery.data}> 100 136 <A ··· 127 163 when={paginated().length > 0} 128 164 fallback={ 129 165 <div class={cardStyles('px-6 py-4 text-sm text-gray-500 dark:text-gray-400')}> 130 - No {stateFilter()} pull requests. 166 + No {stateFilter()} pull requests 167 + <Show when={authorFilter()}> 168 + {' '}by {authorFilter()} 169 + </Show> 170 + . 131 171 </div> 132 172 } 133 173 >
+2
src/routes.tsx
··· 16 16 import { HomePage } from './views/home'; 17 17 import { NotFoundPage } from './views/not-found'; 18 18 import { OAuthCallbackPage } from './views/oauth-callback'; 19 + import { SearchPage } from './views/search'; 19 20 20 21 export const AppRoutes: Component = () => ( 21 22 <Router root={RootShell}> 22 23 <Route path="/" component={HomePage} /> 24 + <Route path="/search" component={SearchPage} /> 23 25 <Route path="/oauth/callback" component={OAuthCallbackPage} /> 24 26 <Route path="/:owner/:repo" component={RepoLayout}> 25 27 <Route path="/" component={RepoCodePage} />
+592
src/views/search.tsx
··· 1 + import clsx from 'clsx'; 2 + import { 3 + BookMarked, 4 + CircleDot, 5 + FileText, 6 + GitFork, 7 + GitPullRequest, 8 + MessageSquare, 9 + Search, 10 + Tag, 11 + UserRound, 12 + X, 13 + } from 'lucide-solid'; 14 + import { A, useNavigate, useSearchParams } from '@solidjs/router'; 15 + import { createQuery } from '@tanstack/solid-query'; 16 + import type { Did, ResourceUri } from '@atcute/lexicons/syntax'; 17 + import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 18 + 19 + import { ErrorState, LoadingState, PaginationControls } from '../components/common'; 20 + import { 21 + getIssueRecord, 22 + getPullRecord, 23 + getRepoByDid, 24 + searchRecords, 25 + type SearchCollection, 26 + type SearchHit, 27 + } from '../lib/api'; 28 + import { formatRelativeTime, getErrorMessage, issueHref, pullHref } from '../lib/repo-utils'; 29 + 30 + type SearchKind = 31 + | 'all' 32 + | 'repos' 33 + | 'issues' 34 + | 'pulls' 35 + | 'issue-comments' 36 + | 'pull-comments' 37 + | 'profiles' 38 + | 'strings' 39 + | 'labels'; 40 + 41 + interface SearchKindOption { 42 + kind: SearchKind; 43 + label: string; 44 + nsid?: SearchCollection; 45 + } 46 + 47 + const SEARCH_KINDS: SearchKindOption[] = [ 48 + { kind: 'all', label: 'all' }, 49 + { kind: 'repos', label: 'repos', nsid: 'sh.tangled.repo' }, 50 + { kind: 'issues', label: 'issues', nsid: 'sh.tangled.repo.issue' }, 51 + { kind: 'pulls', label: 'pulls', nsid: 'sh.tangled.repo.pull' }, 52 + { kind: 'issue-comments', label: 'issue comments', nsid: 'sh.tangled.repo.issue.comment' }, 53 + { kind: 'pull-comments', label: 'pull comments', nsid: 'sh.tangled.repo.pull.comment' }, 54 + { kind: 'profiles', label: 'profiles', nsid: 'sh.tangled.actor.profile' }, 55 + { kind: 'strings', label: 'strings', nsid: 'sh.tangled.string' }, 56 + { kind: 'labels', label: 'labels', nsid: 'sh.tangled.label.definition' }, 57 + ]; 58 + 59 + const SEARCH_PAGE_LIMIT = 20; 60 + const SEARCH_OFFSET_PATTERN = /^[0-9a-fA-F]{8}$/; 61 + 62 + const paramValue = (value: string | string[] | undefined): string => Array.isArray(value) ? value[0] ?? '' : value ?? ''; 63 + 64 + const parseSearchKind = (value: string | string[] | undefined): SearchKind => { 65 + const raw = paramValue(value); 66 + return SEARCH_KINDS.some((option) => option.kind === raw) ? raw as SearchKind : 'all'; 67 + }; 68 + 69 + const searchHref = (q: string, kind: SearchKind, cursor?: string): string => { 70 + const params = new URLSearchParams(); 71 + const trimmed = q.trim(); 72 + if (trimmed) params.set('q', trimmed); 73 + if (kind !== 'all') params.set('type', kind); 74 + if (cursor) params.set('cursor', cursor); 75 + const query = params.toString(); 76 + return query ? `/search?${query}` : '/search'; 77 + }; 78 + 79 + const decodeSearchOffset = (cursor: string): number => { 80 + const trimmed = cursor.trim(); 81 + if (!SEARCH_OFFSET_PATTERN.test(trimmed)) return 0; 82 + const parsed = Number.parseInt(trimmed, 16); 83 + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; 84 + }; 85 + 86 + const normalizeSearchCursor = (cursor: string): string | undefined => { 87 + const trimmed = cursor.trim(); 88 + return SEARCH_OFFSET_PATTERN.test(trimmed) ? trimmed.toLowerCase() : undefined; 89 + }; 90 + 91 + const encodeSearchOffset = (offset: number): string | undefined => { 92 + if (offset <= 0) return undefined; 93 + return Math.max(0, offset).toString(16).padStart(8, '0'); 94 + }; 95 + 96 + const formatSearchElapsed = (milliseconds?: number): string | undefined => { 97 + if (milliseconds === undefined) return undefined; 98 + if (milliseconds < 1) return `${Math.max(1, Math.round(milliseconds * 1000))}us`; 99 + if (milliseconds < 10) return `${milliseconds.toFixed(2)}ms`; 100 + if (milliseconds < 100) return `${milliseconds.toFixed(1)}ms`; 101 + return `${Math.round(milliseconds)}ms`; 102 + }; 103 + 104 + const snippet = (value?: string, max = 220): string => { 105 + const normalized = value?.replace(/\s+/g, ' ').trim() ?? ''; 106 + if (normalized.length <= max) { 107 + return normalized; 108 + } 109 + return `${normalized.slice(0, max - 1).trimEnd()}...`; 110 + }; 111 + 112 + const collectionLabel = (nsid: SearchCollection): string => { 113 + if (nsid === 'sh.tangled.repo') return 'repo'; 114 + if (nsid === 'sh.tangled.repo.issue') return 'issue'; 115 + if (nsid === 'sh.tangled.repo.pull') return 'pull'; 116 + if (nsid === 'sh.tangled.repo.issue.comment') return 'issue comment'; 117 + if (nsid === 'sh.tangled.repo.pull.comment') return 'pull comment'; 118 + if (nsid === 'sh.tangled.actor.profile') return 'profile'; 119 + if (nsid === 'sh.tangled.label.definition') return 'label'; 120 + return 'string'; 121 + }; 122 + 123 + const SearchResultIcon: Component<{ hit: SearchHit }> = (props) => ( 124 + <Switch fallback={<FileText class="w-4 h-4 mr-1.5 shrink-0" />}> 125 + <Match when={props.hit.nsid === 'sh.tangled.repo'}> 126 + <Show when={(props.hit.value as { source?: string }).source} fallback={<BookMarked class="w-4 h-4 mr-1.5 shrink-0" />}> 127 + <GitFork class="w-4 h-4 mr-1.5 shrink-0" /> 128 + </Show> 129 + </Match> 130 + <Match when={props.hit.nsid === 'sh.tangled.repo.issue'}> 131 + <CircleDot class="w-4 h-4 mr-1.5 shrink-0" /> 132 + </Match> 133 + <Match when={props.hit.nsid === 'sh.tangled.repo.pull'}> 134 + <GitPullRequest class="w-4 h-4 mr-1.5 shrink-0" /> 135 + </Match> 136 + <Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment'}> 137 + <MessageSquare class="w-4 h-4 mr-1.5 shrink-0" /> 138 + </Match> 139 + <Match when={props.hit.nsid === 'sh.tangled.actor.profile'}> 140 + <UserRound class="w-4 h-4 mr-1.5 shrink-0" /> 141 + </Match> 142 + <Match when={props.hit.nsid === 'sh.tangled.label.definition'}> 143 + <Tag class="w-4 h-4 mr-1.5 shrink-0" /> 144 + </Match> 145 + </Switch> 146 + ); 147 + 148 + const RepoContextLink: Component<{ repoDid: Did }> = (props) => { 149 + const repoQuery = createQuery(() => ({ 150 + queryKey: ['repo-by-did', props.repoDid], 151 + queryFn: async () => getRepoByDid(props.repoDid), 152 + staleTime: 300_000, 153 + })); 154 + 155 + return ( 156 + <Show 157 + when={repoQuery.data} 158 + fallback={ 159 + <div class="flex gap-1 items-center text-sm"> 160 + <BookMarked class="w-3 h-3" /> 161 + <span>repo</span> 162 + </div> 163 + } 164 + > 165 + {(repo) => ( 166 + <A 167 + href={`/${repo().owner.handle}/${repo().slug}`} 168 + class="flex min-w-0 gap-1 items-center text-sm no-underline hover:underline" 169 + > 170 + <BookMarked class="w-3 h-3 shrink-0" /> 171 + <span class="truncate">{repo().owner.handle}/{repo().slug}</span> 172 + </A> 173 + )} 174 + </Show> 175 + ); 176 + }; 177 + 178 + const RepoThreadLink: Component<{ 179 + kind: 'issue' | 'pull'; 180 + repoDid: Did; 181 + ref: string; 182 + children: JSX.Element; 183 + }> = (props) => { 184 + const repoQuery = createQuery(() => ({ 185 + queryKey: ['repo-by-did', props.repoDid], 186 + queryFn: async () => getRepoByDid(props.repoDid), 187 + staleTime: 300_000, 188 + })); 189 + const href = createMemo(() => { 190 + const repo = repoQuery.data; 191 + if (!repo) return undefined; 192 + return props.kind === 'issue' ? issueHref(repo, props.ref) : pullHref(repo, props.ref); 193 + }); 194 + 195 + return ( 196 + <Show when={href()} fallback={<span>{props.children}</span>}> 197 + {(target) => <A href={target()} class="truncate min-w-0">{props.children}</A>} 198 + </Show> 199 + ); 200 + }; 201 + 202 + const CommentParentLink: Component<{ hit: SearchHit }> = (props) => { 203 + const isIssueComment = createMemo(() => props.hit.nsid === 'sh.tangled.repo.issue.comment'); 204 + const parentUri = createMemo(() => { 205 + if (isIssueComment()) { 206 + return (props.hit.value as { issue: ResourceUri }).issue; 207 + } 208 + return (props.hit.value as { pull: ResourceUri }).pull; 209 + }); 210 + const parentQuery = createQuery(() => ({ 211 + queryKey: ['search-parent', props.hit.nsid, parentUri()], 212 + queryFn: async () => isIssueComment() 213 + ? { kind: 'issue' as const, record: await getIssueRecord(parentUri()) } 214 + : { kind: 'pull' as const, record: await getPullRecord(parentUri()) }, 215 + staleTime: 300_000, 216 + })); 217 + 218 + return ( 219 + <Show when={parentQuery.data} fallback={<span>comment</span>}> 220 + {(parent) => { 221 + const repoDid = createMemo(() => parent().kind === 'issue' 222 + ? (parent().record.value as { repo: Did }).repo 223 + : (parent().record.value as { target: { repo: Did } }).target.repo); 224 + return ( 225 + <RepoThreadLink kind={parent().kind} repoDid={repoDid()} ref={parent().record.rkey}> 226 + comment on {parent().kind} 227 + </RepoThreadLink> 228 + ); 229 + }} 230 + </Show> 231 + ); 232 + }; 233 + 234 + const ResultRepoContext: Component<{ hit: SearchHit }> = (props) => { 235 + const directRepoDid = createMemo(() => { 236 + if (props.hit.nsid === 'sh.tangled.repo.issue') { 237 + return (props.hit.value as { repo: Did }).repo; 238 + } 239 + if (props.hit.nsid === 'sh.tangled.repo.pull') { 240 + return (props.hit.value as { target: { repo: Did } }).target.repo; 241 + } 242 + return undefined; 243 + }); 244 + const isIssueComment = createMemo(() => props.hit.nsid === 'sh.tangled.repo.issue.comment'); 245 + const isRepoComment = createMemo(() => isIssueComment() || props.hit.nsid === 'sh.tangled.repo.pull.comment'); 246 + const parentUri = createMemo(() => { 247 + if (!isRepoComment()) return undefined; 248 + if (isIssueComment()) { 249 + return (props.hit.value as { issue: ResourceUri }).issue; 250 + } 251 + return (props.hit.value as { pull: ResourceUri }).pull; 252 + }); 253 + const parentQuery = createQuery(() => ({ 254 + queryKey: ['search-parent', props.hit.nsid, parentUri()], 255 + enabled: Boolean(parentUri()), 256 + queryFn: async () => isIssueComment() 257 + ? { kind: 'issue' as const, record: await getIssueRecord(parentUri()!) } 258 + : { kind: 'pull' as const, record: await getPullRecord(parentUri()!) }, 259 + staleTime: 300_000, 260 + })); 261 + const commentRepoDid = createMemo(() => { 262 + const parent = parentQuery.data; 263 + if (!parent) return undefined; 264 + return parent.kind === 'issue' 265 + ? (parent.record.value as { repo: Did }).repo 266 + : (parent.record.value as { target: { repo: Did } }).target.repo; 267 + }); 268 + const repoDid = createMemo(() => directRepoDid() ?? commentRepoDid()); 269 + 270 + return ( 271 + <Show when={repoDid()}> 272 + {(did) => <RepoContextLink repoDid={did()} />} 273 + </Show> 274 + ); 275 + }; 276 + 277 + const ResultTitle: Component<{ hit: SearchHit }> = (props) => { 278 + const title = createMemo(() => { 279 + const value = props.hit.value as { 280 + title?: string; 281 + name?: string; 282 + filename?: string; 283 + preferredHandle?: string; 284 + }; 285 + return value.title || value.name || value.filename || value.preferredHandle || props.hit.rkey; 286 + }); 287 + const repoName = createMemo(() => (props.hit.value as { name?: string }).name?.trim() || props.hit.rkey); 288 + 289 + return ( 290 + <div class="flex items-center min-w-0 flex-1 mr-2"> 291 + <SearchResultIcon hit={props.hit} /> 292 + <Switch fallback={<span class="truncate min-w-0">{title()}</span>}> 293 + <Match when={props.hit.nsid === 'sh.tangled.repo'}> 294 + <A href={`/${props.hit.author.handle}/${repoName()}`} class="truncate min-w-0"> 295 + {props.hit.author.handle}/{repoName()} 296 + </A> 297 + </Match> 298 + <Match when={props.hit.nsid === 'sh.tangled.repo.issue'}> 299 + <RepoThreadLink kind="issue" repoDid={(props.hit.value as { repo: Did }).repo} ref={props.hit.rkey}> 300 + {title()} 301 + </RepoThreadLink> 302 + </Match> 303 + <Match when={props.hit.nsid === 'sh.tangled.repo.pull'}> 304 + <RepoThreadLink kind="pull" repoDid={(props.hit.value as { target: { repo: Did } }).target.repo} ref={props.hit.rkey}> 305 + {title()} 306 + </RepoThreadLink> 307 + </Match> 308 + <Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment'}> 309 + <CommentParentLink hit={props.hit} /> 310 + </Match> 311 + </Switch> 312 + </div> 313 + ); 314 + }; 315 + 316 + const SearchResultCard: Component<{ hit: SearchHit }> = (props) => { 317 + const createdAt = createMemo(() => (props.hit.value as { createdAt?: string }).createdAt); 318 + const body = createMemo(() => { 319 + const value = props.hit.value as { 320 + body?: string; 321 + description?: string; 322 + contents?: string; 323 + location?: string; 324 + pronouns?: string; 325 + }; 326 + return snippet(value.body || value.description || value.contents || [value.location, value.pronouns].filter(Boolean).join(' ')); 327 + }); 328 + const topics = createMemo(() => props.hit.nsid === 'sh.tangled.repo' 329 + ? (props.hit.value as { topics?: string[] }).topics ?? [] 330 + : []); 331 + 332 + return ( 333 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 334 + <div class="min-h-32 py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800"> 335 + <div class="font-medium dark:text-white flex items-center justify-between"> 336 + <ResultTitle hit={props.hit} /> 337 + </div> 338 + 339 + <Show when={body()}> 340 + <div class="untangled-search-description text-gray-600 dark:text-gray-300 text-sm">{body()}</div> 341 + </Show> 342 + 343 + <div class="text-gray-400 text-sm font-mono inline-flex flex-wrap gap-4 mt-auto pt-4"> 344 + <div class="flex gap-2 items-center text-sm"> 345 + <span class="untangled-search-type-dot" /> 346 + <span>{collectionLabel(props.hit.nsid)}</span> 347 + </div> 348 + <div class="flex gap-1 items-center text-sm"> 349 + <UserRound class="w-3 h-3" /> 350 + <span>{props.hit.author.handle}</span> 351 + </div> 352 + <ResultRepoContext hit={props.hit} /> 353 + <Show when={createdAt()}> 354 + <div class="flex gap-1 items-center text-sm"> 355 + <span>{formatRelativeTime(createdAt())}</span> 356 + </div> 357 + </Show> 358 + </div> 359 + 360 + <Show when={topics().length > 0}> 361 + <div class="mt-2 flex flex-wrap gap-2"> 362 + <For each={topics()}> 363 + {(topic) => ( 364 + <span class="rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"> 365 + {topic} 366 + </span> 367 + )} 368 + </For> 369 + </div> 370 + </Show> 371 + </div> 372 + </div> 373 + ); 374 + }; 375 + 376 + const TypeFilters: Component<{ query: string; active: SearchKind }> = (props) => ( 377 + <div class="flex gap-2 md:flex-wrap overflow-x-auto pb-2 md:pb-0"> 378 + <For each={SEARCH_KINDS}> 379 + {(option) => ( 380 + <A 381 + href={searchHref(props.query, option.kind)} 382 + class={clsx( 383 + 'w-fit flex items-center flex-shrink-0 gap-2 font-normal normal-case rounded py-1 px-2 border text-sm transition-colors no-underline hover:no-underline', 384 + option.kind === props.active 385 + ? 'border-gray-400 bg-gray-100 text-gray-900 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-100' 386 + : 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700', 387 + )} 388 + > 389 + <span class="untangled-search-type-dot" /> 390 + <span>{option.label}</span> 391 + </A> 392 + )} 393 + </For> 394 + </div> 395 + ); 396 + 397 + const SearchStatistics: Component<{ 398 + returnedCount?: number; 399 + returnedStart?: number; 400 + elapsedMs?: number; 401 + hasMore?: boolean; 402 + }> = (props) => { 403 + const returnedThrough = createMemo(() => (props.returnedStart ?? 0) + (props.returnedCount ?? 0)); 404 + const elapsed = createMemo(() => formatSearchElapsed(props.elapsedMs)); 405 + 406 + return ( 407 + <Show when={props.returnedCount !== undefined}> 408 + <div class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 py-2"> 409 + returned {returnedThrough()}{props.hasMore ? '+' : ''} indexed record{returnedThrough() === 1 ? '' : 's'} 410 + <Show when={elapsed()}> in {elapsed()}</Show> 411 + </div> 412 + </Show> 413 + ); 414 + }; 415 + 416 + const SearchOptionsPanel: Component<{ 417 + query: string; 418 + kind: SearchKind; 419 + returnedCount?: number; 420 + returnedStart?: number; 421 + elapsedMs?: number; 422 + hasMore?: boolean; 423 + }> = (props) => ( 424 + <div class="flex flex-col gap-6 px-2 md:px-0"> 425 + <div> 426 + <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">sort by</h3> 427 + <select class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 dark:text-white text-sm"> 428 + <option>relevance</option> 429 + </select> 430 + </div> 431 + 432 + <div> 433 + <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">content</h3> 434 + <TypeFilters query={props.query} active={props.kind} /> 435 + <p class="hidden md:block text-gray-500 dark:text-gray-400 text-xs mt-3"> 436 + filter by indexed record type. 437 + </p> 438 + </div> 439 + 440 + <SearchStatistics 441 + returnedCount={props.returnedCount} 442 + returnedStart={props.returnedStart} 443 + elapsedMs={props.elapsedMs} 444 + hasMore={props.hasMore} 445 + /> 446 + </div> 447 + ); 448 + 449 + export const SearchPage: Component = () => { 450 + const [searchParams] = useSearchParams(); 451 + const navigate = useNavigate(); 452 + const q = createMemo(() => paramValue(searchParams.q)); 453 + const kind = createMemo(() => parseSearchKind(searchParams.type)); 454 + const cursor = createMemo(() => paramValue(searchParams.cursor)); 455 + const normalizedCursor = createMemo(() => normalizeSearchCursor(cursor())); 456 + const currentOffset = createMemo(() => decodeSearchOffset(normalizedCursor() ?? '')); 457 + const selectedKind = createMemo(() => SEARCH_KINDS.find((option) => option.kind === kind()) ?? SEARCH_KINDS[0]); 458 + const [draft, setDraft] = createSignal(q()); 459 + 460 + createEffect(() => { 461 + setDraft(q()); 462 + }); 463 + 464 + const searchQuery = createQuery(() => ({ 465 + queryKey: ['global-search', q(), kind(), normalizedCursor()], 466 + enabled: Boolean(q().trim()), 467 + queryFn: async () => { 468 + const startedAt = performance.now(); 469 + const result = await searchRecords({ 470 + q: q(), 471 + nsid: selectedKind().nsid, 472 + cursor: normalizedCursor(), 473 + limit: SEARCH_PAGE_LIMIT, 474 + }); 475 + return { ...result, elapsedMs: performance.now() - startedAt }; 476 + }, 477 + })); 478 + 479 + const onSubmit = (event: SubmitEvent) => { 480 + event.preventDefault(); 481 + navigate(searchHref(draft(), kind())); 482 + }; 483 + 484 + const clearHref = createMemo(() => searchHref('', kind())); 485 + const hits = createMemo(() => searchQuery.data?.hits ?? []); 486 + const returnedCount = createMemo(() => searchQuery.data ? hits().length : undefined); 487 + 488 + return ( 489 + <> 490 + <h1 class="text-2xl font-bold mb-4 px-2">Search</h1> 491 + 492 + <div class="untangled-search-grid px-2"> 493 + <div class="untangled-search-main"> 494 + <form onSubmit={onSubmit} class="flex items-center gap-2"> 495 + <div class="flex relative w-full"> 496 + <div class="flex-1 flex relative"> 497 + <input 498 + id="search-q" 499 + value={draft()} 500 + onInput={(event) => setDraft(event.currentTarget.value)} 501 + placeholder="search..." 502 + class="untangled-search-page-input peer" 503 + /> 504 + <A 505 + href={clearHref()} 506 + class={clsx( 507 + 'absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300', 508 + !draft().trim() && 'hidden', 509 + )} 510 + > 511 + <X class="w-4 h-4" /> 512 + </A> 513 + </div> 514 + <button type="submit" class="untangled-search-page-submit"> 515 + <Search class="w-4 h-4" /> 516 + </button> 517 + </div> 518 + </form> 519 + 520 + <div class="md:hidden mt-3"> 521 + <TypeFilters query={q()} active={kind()} /> 522 + </div> 523 + 524 + <Show 525 + when={q().trim()} 526 + fallback={<div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded">Enter a query to search indexed records.</div>} 527 + > 528 + <Switch> 529 + <Match when={searchQuery.isLoading}> 530 + <div class="p-12 border border-gray-200 dark:border-gray-700 rounded"> 531 + <LoadingState label="Searching..." /> 532 + </div> 533 + </Match> 534 + <Match when={searchQuery.error}> 535 + <div class="p-6 border border-gray-200 dark:border-gray-700 rounded"> 536 + <ErrorState message={getErrorMessage(searchQuery.error)} /> 537 + </div> 538 + </Match> 539 + <Match when={searchQuery.data}> 540 + {(data) => ( 541 + <> 542 + <div id="search-results" class="grid grid-cols-1 gap-4 mb-6"> 543 + <Show 544 + when={data().hits.length > 0} 545 + fallback={ 546 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 547 + <span>No results found.</span> 548 + </div> 549 + } 550 + > 551 + <For each={data().hits}> 552 + {(hit) => <SearchResultCard hit={hit} />} 553 + </For> 554 + </Show> 555 + </div> 556 + 557 + <div class="md:hidden mb-4"> 558 + <SearchStatistics 559 + returnedCount={data().hits.length} 560 + returnedStart={currentOffset()} 561 + elapsedMs={data().elapsedMs} 562 + hasMore={Boolean(data().cursor)} 563 + /> 564 + </div> 565 + 566 + <PaginationControls 567 + offset={currentOffset()} 568 + limit={SEARCH_PAGE_LIMIT} 569 + hasNext={Boolean(data().cursor)} 570 + hrefForOffset={(offset) => searchHref(q(), kind(), encodeSearchOffset(offset))} 571 + /> 572 + </> 573 + )} 574 + </Match> 575 + </Switch> 576 + </Show> 577 + </div> 578 + 579 + <div class="untangled-search-sidebar"> 580 + <SearchOptionsPanel 581 + query={q()} 582 + kind={kind()} 583 + returnedCount={returnedCount()} 584 + returnedStart={currentOffset()} 585 + elapsedMs={searchQuery.data?.elapsedMs} 586 + hasMore={Boolean(searchQuery.data?.cursor)} 587 + /> 588 + </div> 589 + </div> 590 + </> 591 + ); 592 + };