Skip to content

Commit 3602ddc

Browse files
committed
search
1 parent 45eb971 commit 3602ddc

File tree

5 files changed

+295
-86
lines changed

5 files changed

+295
-86
lines changed

.github/workflows/remove-stale.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ on:
22
workflow_dispatch:
33
inputs:
44
remove:
5-
description: 'Actually delete items (true/false)'
5+
description: "Actually delete items (true/false)"
66
required: false
7-
default: 'false'
7+
default: "false"
88

99
jobs:
1010
rm_stale_packages:
@@ -18,7 +18,7 @@ jobs:
1818
- name: Set up Node.js
1919
uses: actions/setup-node@v4
2020
with:
21-
node-version: 'lts/*'
21+
node-version: "lts/*"
2222

2323
- name: Run remove-stale.js
2424
run: |

packages/app/app/components/RepoSearch.vue

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,105 @@
11
<script lang="ts" setup>
2-
const search = useSessionStorage("search", "");
2+
interface RepoOwner {
3+
login: string;
4+
avatarUrl: string;
5+
}
6+
7+
interface RepoNode {
8+
name: string;
9+
owner: RepoOwner;
10+
}
11+
12+
interface SearchStreamChunk {
13+
nodes?: RepoNode[];
14+
streaming?: boolean;
15+
complete?: boolean;
16+
error?: boolean;
17+
message?: string;
18+
}
319
20+
const search = useSessionStorage("search", "");
421
const throttledSearch = useThrottle(search, 500, true, false);
22+
const searchResults = ref<RepoNode[]>([]);
23+
const isLoading = ref(false);
24+
const isComplete = ref(true);
25+
const error = ref<string | null>(null);
26+
27+
watchEffect(async () => {
28+
if (!throttledSearch.value) {
29+
searchResults.value = [];
30+
isLoading.value = false;
31+
isComplete.value = true;
32+
return;
33+
}
34+
35+
isLoading.value = true;
36+
isComplete.value = false;
37+
searchResults.value = [];
38+
error.value = null;
39+
40+
try {
41+
const response = await fetch(
42+
`/api/repo/search?text=${encodeURIComponent(throttledSearch.value)}`,
43+
);
44+
45+
if (!response.ok) {
46+
throw new Error(
47+
`Search failed: ${response.status} ${response.statusText}`,
48+
);
49+
}
550
6-
const { data, status } = useFetch("/api/repo/search", {
7-
query: computed(() => ({ text: throttledSearch.value })),
8-
immediate: !!throttledSearch.value,
51+
const reader = response.body?.getReader();
52+
if (!reader) {
53+
throw new Error("Failed to get response stream reader");
54+
}
55+
56+
const decoder = new TextDecoder();
57+
58+
while (true) {
59+
const { done, value } = await reader.read();
60+
61+
if (done) {
62+
console.log("Stream complete");
63+
isLoading.value = false;
64+
isComplete.value = true;
65+
break;
66+
}
67+
68+
const chunk = decoder.decode(value, { stream: true });
69+
const lines = chunk.split("\n").filter((line) => line.trim());
70+
71+
for (const line of lines) {
72+
try {
73+
const data = JSON.parse(line) as SearchStreamChunk;
74+
console.log("Received data:", data);
75+
76+
if (data.error) {
77+
error.value = data.message || "Unknown error";
78+
isLoading.value = false;
79+
break;
80+
}
81+
82+
if (data.nodes && data.nodes.length > 0) {
83+
searchResults.value = [...searchResults.value, ...data.nodes];
84+
}
85+
86+
if (data.streaming === false && data.complete) {
87+
isLoading.value = false;
88+
isComplete.value = true;
89+
}
90+
} catch (e) {
91+
const err = e as Error;
92+
console.error("Error parsing JSON chunk:", err, line);
93+
}
94+
}
95+
}
96+
} catch (e) {
97+
const err = e as Error;
98+
console.error("Error with search request:", err);
99+
error.value = err.message;
100+
isLoading.value = false;
101+
isComplete.value = true;
102+
}
9103
});
10104
11105
const examples = [
@@ -39,13 +133,13 @@ const examples = [
39133
const router = useRouter();
40134
41135
function openFirstResult() {
42-
if (data.value?.nodes[0]) {
43-
const { owner, name } = data.value.nodes[0];
136+
const firstResult = searchResults.value[0];
137+
if (firstResult) {
44138
router.push({
45139
name: "repo:details",
46140
params: {
47-
owner: owner.login,
48-
repo: name,
141+
owner: firstResult.owner.login,
142+
repo: firstResult.name,
49143
},
50144
});
51145
}
@@ -64,22 +158,26 @@ function openFirstResult() {
64158
@keydown.enter="openFirstResult()"
65159
/>
66160

67-
<div v-if="status === 'pending'" class="-mb-2 relative">
161+
<div v-if="isLoading" class="-mb-2 relative">
68162
<UProgress size="xs" class="absolute inset-x-0 top-0" />
69163
</div>
70164

71-
<div v-if="data?.nodes.length">
165+
<div v-if="error" class="text-red-500 p-4 text-center">
166+
{{ error }}
167+
</div>
168+
169+
<div v-else-if="searchResults.length">
72170
<RepoButton
73-
v-for="repo in data.nodes"
74-
:key="repo.id"
171+
v-for="repo in searchResults"
172+
:key="`${repo.owner.login}-${repo.name}`"
75173
:owner="repo.owner.login"
76174
:name="repo.name"
77175
:avatar="repo.owner.avatarUrl"
78176
/>
79177
</div>
80178

81179
<div
82-
v-else-if="search && status !== 'pending'"
180+
v-else-if="search && isComplete && !isLoading"
83181
class="text-gray-500 p-12 text-center"
84182
>
85183
No repositories found
Lines changed: 135 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
import { z } from "zod";
2+
import {
3+
defineEventHandler,
4+
getValidatedQuery,
5+
setResponseHeader,
6+
toWebRequest,
7+
} from "h3";
8+
import { Readable } from "node:stream";
9+
10+
import { useBinding } from "../../../server/utils/bucket";
11+
import { usePackagesBucket } from "../../../server/utils/bucket";
212

313
const querySchema = z.object({
414
text: z.string(),
@@ -20,53 +30,122 @@ export default defineEventHandler(async (event) => {
2030

2131
const searchText = query.text.toLowerCase();
2232

23-
// Internal pagination: iterate until uniqueNodes is filled or no more objects
24-
let cursor: string | undefined;
25-
const seen = new Set<string>();
26-
const uniqueNodes = [];
27-
const maxNodes = 10;
28-
let keepGoing = true;
29-
30-
while (uniqueNodes.length < maxNodes && keepGoing && !signal.aborted) {
31-
const listResult = await r2Binding.list({
32-
prefix: usePackagesBucket.base,
33-
limit: 1000,
34-
cursor,
35-
});
36-
const { objects, truncated } = listResult;
37-
cursor = truncated ? listResult.cursor : undefined;
38-
39-
for (const obj of objects) {
40-
const parts = parseKey(obj.key);
41-
const orgRepo = `${parts.org}/${parts.repo}`.toLowerCase();
42-
const applies =
43-
parts.org.toLowerCase().includes(searchText) ||
44-
parts.repo.toLowerCase().includes(searchText) ||
45-
orgRepo.includes(searchText);
46-
if (!applies) continue;
47-
48-
const key = `${parts.org}/${parts.repo}`;
49-
if (!seen.has(key)) {
50-
seen.add(key);
51-
uniqueNodes.push({
52-
name: parts.repo,
53-
owner: {
54-
login: parts.org,
55-
avatarUrl: `https://github.com/${parts.org}.png`,
56-
},
33+
setResponseHeader(event, "Content-Type", "application/json");
34+
setResponseHeader(event, "Cache-Control", "no-cache");
35+
setResponseHeader(event, "Connection", "keep-alive");
36+
37+
const stream = new Readable({
38+
objectMode: true,
39+
read() {},
40+
});
41+
42+
stream.push(JSON.stringify({ nodes: [], streaming: true }) + "\n");
43+
44+
processSearchAsync();
45+
46+
return stream;
47+
48+
async function processSearchAsync() {
49+
try {
50+
let cursor: string | undefined;
51+
const seen = new Set<string>();
52+
const maxNodes = 10;
53+
let count = 0;
54+
let keepGoing = true;
55+
56+
// Debug: Log the base prefix we're using to search
57+
console.log(`Searching with base prefix: ${usePackagesBucket.base}`);
58+
59+
while (count < maxNodes && keepGoing && !signal.aborted) {
60+
const prefix = usePackagesBucket.base;
61+
62+
console.log(
63+
`Fetching batch with prefix: ${prefix}, cursor: ${cursor || "initial"}`,
64+
);
65+
66+
const listResult = await r2Binding.list({
67+
prefix: prefix,
68+
limit: 1000,
69+
cursor,
5770
});
58-
if (uniqueNodes.length >= maxNodes) break;
71+
72+
console.log(
73+
`Fetched ${listResult.objects.length} objects, truncated: ${listResult.truncated}`,
74+
);
75+
76+
const { objects, truncated } = listResult;
77+
cursor = truncated ? listResult.cursor : undefined;
78+
79+
const batchResults = [];
80+
81+
for (const obj of objects) {
82+
console.log(`Examining key: ${obj.key}`);
83+
84+
try {
85+
const parts = parseKey(obj.key);
86+
87+
if (!parts.org || !parts.repo) {
88+
console.log(`Skipping malformed key: ${obj.key}`);
89+
continue;
90+
}
91+
92+
const orgRepo = `${parts.org}/${parts.repo}`.toLowerCase();
93+
94+
console.log(`Matching ${orgRepo} against search: ${searchText}`);
95+
96+
const applies =
97+
parts.org.toLowerCase().includes(searchText) ||
98+
parts.repo.toLowerCase().includes(searchText) ||
99+
orgRepo.includes(searchText);
100+
101+
if (!applies) continue;
102+
103+
const key = `${parts.org}/${parts.repo}`;
104+
if (!seen.has(key)) {
105+
seen.add(key);
106+
const node = {
107+
name: parts.repo,
108+
owner: {
109+
login: parts.org,
110+
avatarUrl: `https://github.com/${parts.org}.png`,
111+
},
112+
};
113+
batchResults.push(node);
114+
count++;
115+
console.log(`Found match: ${key}`);
116+
if (count >= maxNodes) break;
117+
}
118+
} catch (err) {
119+
console.error(`Error parsing key ${obj.key}:`, err);
120+
continue;
121+
}
122+
}
123+
124+
if (batchResults.length > 0) {
125+
console.log(`Streaming batch of ${batchResults.length} results`);
126+
stream.push(
127+
JSON.stringify({ nodes: batchResults, streaming: true }) + "\n",
128+
);
129+
}
130+
131+
if (!truncated || count >= maxNodes) {
132+
keepGoing = false;
133+
}
59134
}
60-
}
61135

62-
if (!truncated || uniqueNodes.length >= maxNodes) {
63-
keepGoing = false;
136+
console.log(`Search complete, found ${count} results`);
137+
stream.push(
138+
JSON.stringify({ streaming: false, complete: true }) + "\n",
139+
);
140+
stream.push(null);
141+
} catch (error) {
142+
console.error("Error processing search:", error);
143+
stream.push(
144+
JSON.stringify({ error: true, message: (error as Error).message }) +
145+
"\n",
146+
);
64147
}
65148
}
66-
67-
return {
68-
nodes: uniqueNodes,
69-
};
70149
} catch (error) {
71150
console.error("Error in repository search:", error);
72151
return {
@@ -78,9 +157,18 @@ export default defineEventHandler(async (event) => {
78157
});
79158

80159
function parseKey(key: string) {
81-
const parts = key.split(":");
82-
return {
83-
org: parts[2],
84-
repo: parts[3],
85-
};
160+
try {
161+
const parts = key.split(":");
162+
if (parts.length < 4) {
163+
console.warn(`Key format unexpected: ${key}, parts: ${parts.length}`);
164+
return { org: "", repo: "" };
165+
}
166+
return {
167+
org: parts[2] || "",
168+
repo: parts[3] || "",
169+
};
170+
} catch (err) {
171+
console.error(`Failed to parse key: ${key}`, err);
172+
return { org: "", repo: "" };
173+
}
86174
}

0 commit comments

Comments
 (0)