@skillsgate/tui 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/api-client.ts +8 -0
- package/src/data/use-trending.ts +57 -0
- package/src/db/migrations.ts +12 -0
- package/src/db/trending-cache.ts +45 -0
- package/src/views/discover.tsx +98 -27
package/package.json
CHANGED
package/src/data/api-client.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// Re-export the shared trending browse helpers from the CLI core so the
|
|
2
|
+
// scraping/filtering logic lives in exactly one place.
|
|
3
|
+
export {
|
|
4
|
+
fetchTrending,
|
|
5
|
+
filterSkills,
|
|
6
|
+
type SkillsShSkill,
|
|
7
|
+
} from "../../../cli/src/core/skills-sh-client.js"
|
|
8
|
+
|
|
1
9
|
const SKILLS_SH_BASE = "https://skills.sh"
|
|
2
10
|
const GITHUB_API_BASE = "https://api.github.com"
|
|
3
11
|
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { useDb } from "../db/context.js"
|
|
3
|
+
import {
|
|
4
|
+
loadTrendingCache,
|
|
5
|
+
saveTrendingCache,
|
|
6
|
+
} from "../db/trending-cache.js"
|
|
7
|
+
import { fetchTrending, type SkillsShSkill } from "./api-client.js"
|
|
8
|
+
|
|
9
|
+
interface UseTrendingResult {
|
|
10
|
+
trending: SkillsShSkill[]
|
|
11
|
+
loading: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loads the ranked, most-installed skills for an instant local browse + filter.
|
|
16
|
+
* Reads a fresh local cache first; if it's stale or missing, scrapes the live
|
|
17
|
+
* list and persists it. A scrape failure is non-fatal — trending falls back to
|
|
18
|
+
* empty and the live search path still works.
|
|
19
|
+
*/
|
|
20
|
+
export function useTrending(): UseTrendingResult {
|
|
21
|
+
const { db } = useDb()
|
|
22
|
+
const [trending, setTrending] = useState<SkillsShSkill[]>([])
|
|
23
|
+
const [loading, setLoading] = useState(true)
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false
|
|
27
|
+
|
|
28
|
+
const cached = loadTrendingCache(db)
|
|
29
|
+
if (cached) {
|
|
30
|
+
setTrending(cached)
|
|
31
|
+
setLoading(false)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const load = async () => {
|
|
36
|
+
try {
|
|
37
|
+
const skills = await fetchTrending()
|
|
38
|
+
if (cancelled) return
|
|
39
|
+
saveTrendingCache(db, skills)
|
|
40
|
+
setTrending(skills)
|
|
41
|
+
} catch {
|
|
42
|
+
// Non-fatal: live search remains available without trending.
|
|
43
|
+
if (!cancelled) setTrending([])
|
|
44
|
+
} finally {
|
|
45
|
+
if (!cancelled) setLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
void load()
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true
|
|
53
|
+
}
|
|
54
|
+
}, [db])
|
|
55
|
+
|
|
56
|
+
return { trending, loading }
|
|
57
|
+
}
|
package/src/db/migrations.ts
CHANGED
|
@@ -81,6 +81,18 @@ const MIGRATIONS: Migration[] = [
|
|
|
81
81
|
INSERT OR IGNORE INTO schema_version VALUES (3);
|
|
82
82
|
`,
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
version: 4,
|
|
86
|
+
up: `
|
|
87
|
+
CREATE TABLE IF NOT EXISTS trending_cache (
|
|
88
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
89
|
+
fetched_at TEXT NOT NULL,
|
|
90
|
+
payload TEXT NOT NULL
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
INSERT OR IGNORE INTO schema_version VALUES (4);
|
|
94
|
+
`,
|
|
95
|
+
},
|
|
84
96
|
]
|
|
85
97
|
|
|
86
98
|
function getCurrentVersion(db: Database): number {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import type { SkillsShSkill } from "../../../cli/src/core/skills-sh-client.js"
|
|
3
|
+
|
|
4
|
+
// How long a cached trending payload stays fresh before a re-fetch is needed.
|
|
5
|
+
const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
|
6
|
+
|
|
7
|
+
interface TrendingCacheRow {
|
|
8
|
+
id: number
|
|
9
|
+
fetched_at: string
|
|
10
|
+
payload: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the cached trending skills if the single-row cache exists and is
|
|
15
|
+
* still within the TTL window; otherwise returns null so the caller can
|
|
16
|
+
* re-fetch.
|
|
17
|
+
*/
|
|
18
|
+
export function loadTrendingCache(db: Database): SkillsShSkill[] | null {
|
|
19
|
+
const row = db
|
|
20
|
+
.query("SELECT * FROM trending_cache WHERE id = 1")
|
|
21
|
+
.get() as TrendingCacheRow | null
|
|
22
|
+
|
|
23
|
+
if (!row) return null
|
|
24
|
+
|
|
25
|
+
const fetchedAt = Date.parse(row.fetched_at)
|
|
26
|
+
if (Number.isNaN(fetchedAt)) return null
|
|
27
|
+
if (Date.now() - fetchedAt >= TTL_MS) return null
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(row.payload) as SkillsShSkill[]
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Upserts the single-row (id=1) trending cache with the given skills and the
|
|
38
|
+
* current timestamp.
|
|
39
|
+
*/
|
|
40
|
+
export function saveTrendingCache(db: Database, skills: SkillsShSkill[]): void {
|
|
41
|
+
db.query(
|
|
42
|
+
`INSERT OR REPLACE INTO trending_cache (id, fetched_at, payload)
|
|
43
|
+
VALUES (1, ?, ?)`
|
|
44
|
+
).run(new Date().toISOString(), JSON.stringify(skills))
|
|
45
|
+
}
|
package/src/views/discover.tsx
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { useState, useEffect } from "react"
|
|
1
|
+
import { useState, useEffect, useMemo } from "react"
|
|
2
2
|
import { useKeyboard } from "@opentui/react"
|
|
3
3
|
import { useStore, useDispatch } from "../store/context.js"
|
|
4
4
|
import { useSearch } from "../data/use-search.js"
|
|
5
|
+
import { useTrending } from "../data/use-trending.js"
|
|
5
6
|
import { useSkillActions } from "../data/use-skill-actions.js"
|
|
6
7
|
import { ConfirmDialog } from "../components/confirm-dialog.js"
|
|
7
|
-
import type { CatalogSkill } from "../data/api-client.js"
|
|
8
|
+
import type { CatalogSkill, SkillsShSkill } from "../data/api-client.js"
|
|
9
|
+
import { filterSkills } from "../data/api-client.js"
|
|
8
10
|
import { colors } from "../utils/colors.js"
|
|
9
11
|
|
|
12
|
+
// Sentinel selection index for the focusable "Official only" toggle row that
|
|
13
|
+
// sits above the skill rows in the list pane.
|
|
14
|
+
const TOGGLE_INDEX = -1
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
17
|
* Discover view: two-column layout with search results.
|
|
12
18
|
* LEFT - Search input + results list (40%)
|
|
@@ -17,8 +23,9 @@ export function DiscoverView() {
|
|
|
17
23
|
const dispatch = useDispatch()
|
|
18
24
|
const [query, setQuery] = useState("")
|
|
19
25
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
26
|
+
const [officialOnly, setOfficialOnly] = useState(false)
|
|
20
27
|
const [installTarget, setInstallTarget] = useState<CatalogSkill | null>(null)
|
|
21
|
-
const [previewSkill, setPreviewSkill] = useState<
|
|
28
|
+
const [previewSkill, setPreviewSkill] = useState<SkillsShSkill | null>(null)
|
|
22
29
|
|
|
23
30
|
// Auto-focus search input when Discover view mounts
|
|
24
31
|
useEffect(() => {
|
|
@@ -29,16 +36,47 @@ export function DiscoverView() {
|
|
|
29
36
|
|
|
30
37
|
const { results, loading, error, total, hasMore, loadMore } =
|
|
31
38
|
useSearch(query)
|
|
39
|
+
const { trending, loading: trendingLoading } = useTrending()
|
|
32
40
|
const { installSkill } = useSkillActions()
|
|
33
41
|
|
|
42
|
+
const isSearching = query.trim().length >= 2
|
|
43
|
+
|
|
44
|
+
// Compose the list shown to the user:
|
|
45
|
+
// - short query -> ranked trending (instant local browse)
|
|
46
|
+
// - 2+ chars -> local filter over trending FIRST, then live API results
|
|
47
|
+
// whose id is not already present, then optionally restrict to official.
|
|
48
|
+
const visibleSkills = useMemo<SkillsShSkill[]>(() => {
|
|
49
|
+
let combined: SkillsShSkill[]
|
|
50
|
+
if (!isSearching) {
|
|
51
|
+
combined = trending
|
|
52
|
+
} else {
|
|
53
|
+
const local = filterSkills(trending, query)
|
|
54
|
+
const seen = new Set(local.map((s) => s.id))
|
|
55
|
+
const remote = results.filter((s) => !seen.has(s.id))
|
|
56
|
+
combined = [...local, ...remote]
|
|
57
|
+
}
|
|
58
|
+
if (officialOnly) {
|
|
59
|
+
combined = combined.filter((s) => s.isOfficial === true)
|
|
60
|
+
}
|
|
61
|
+
return combined
|
|
62
|
+
}, [isSearching, query, trending, results, officialOnly])
|
|
63
|
+
|
|
64
|
+
// Clamp selection whenever the visible set changes.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setSelectedIndex((i) => {
|
|
67
|
+
if (i === TOGGLE_INDEX) return TOGGLE_INDEX
|
|
68
|
+
return Math.min(Math.max(0, i), Math.max(0, visibleSkills.length - 1))
|
|
69
|
+
})
|
|
70
|
+
}, [visibleSkills.length])
|
|
71
|
+
|
|
34
72
|
// Update preview when selection changes
|
|
35
73
|
useEffect(() => {
|
|
36
|
-
if (
|
|
37
|
-
setPreviewSkill(
|
|
74
|
+
if (selectedIndex >= 0 && visibleSkills[selectedIndex]) {
|
|
75
|
+
setPreviewSkill(visibleSkills[selectedIndex])
|
|
38
76
|
} else {
|
|
39
77
|
setPreviewSkill(null)
|
|
40
78
|
}
|
|
41
|
-
}, [selectedIndex,
|
|
79
|
+
}, [selectedIndex, visibleSkills])
|
|
42
80
|
|
|
43
81
|
// Keyboard navigation for the discover list
|
|
44
82
|
useKeyboard((key) => {
|
|
@@ -47,15 +85,24 @@ export function DiscoverView() {
|
|
|
47
85
|
if (state.focusedPane === "search") return
|
|
48
86
|
if (installTarget) return
|
|
49
87
|
|
|
50
|
-
//
|
|
88
|
+
// space/enter toggles the focusable "Official only" row when selected
|
|
89
|
+
if (
|
|
90
|
+
selectedIndex === TOGGLE_INDEX &&
|
|
91
|
+
(key.name === "space" || key.name === "return")
|
|
92
|
+
) {
|
|
93
|
+
setOfficialOnly((v) => !v)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// j/k or arrow keys (TOGGLE_INDEX sits above the first skill row)
|
|
51
98
|
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
52
|
-
setSelectedIndex((i) => Math.max(
|
|
99
|
+
setSelectedIndex((i) => Math.max(TOGGLE_INDEX, i - 1))
|
|
53
100
|
}
|
|
54
101
|
if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
|
|
55
102
|
setSelectedIndex((i) => {
|
|
56
|
-
const next = Math.min(
|
|
57
|
-
// If we're near the bottom and
|
|
58
|
-
if (next >=
|
|
103
|
+
const next = Math.min(visibleSkills.length - 1, i + 1)
|
|
104
|
+
// If we're near the bottom and the live API has more, load next page.
|
|
105
|
+
if (isSearching && next >= visibleSkills.length - 3 && hasMore && !loading) {
|
|
59
106
|
loadMore()
|
|
60
107
|
}
|
|
61
108
|
return next
|
|
@@ -67,12 +114,12 @@ export function DiscoverView() {
|
|
|
67
114
|
setSelectedIndex(0)
|
|
68
115
|
}
|
|
69
116
|
if (key.name === "g" && key.shift) {
|
|
70
|
-
setSelectedIndex(Math.max(0,
|
|
117
|
+
setSelectedIndex(Math.max(0, visibleSkills.length - 1))
|
|
71
118
|
}
|
|
72
119
|
|
|
73
120
|
// v to open full detail view
|
|
74
|
-
if (key.name === "v" &&
|
|
75
|
-
const skill =
|
|
121
|
+
if (key.name === "v" && visibleSkills[selectedIndex]) {
|
|
122
|
+
const skill = visibleSkills[selectedIndex]
|
|
76
123
|
dispatch({
|
|
77
124
|
type: "SELECT_SKILL",
|
|
78
125
|
skill: catalogSkillToEnriched(skill),
|
|
@@ -81,8 +128,8 @@ export function DiscoverView() {
|
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
// i to install
|
|
84
|
-
if (key.name === "i" &&
|
|
85
|
-
setInstallTarget(
|
|
131
|
+
if (key.name === "i" && visibleSkills[selectedIndex]) {
|
|
132
|
+
setInstallTarget(visibleSkills[selectedIndex])
|
|
86
133
|
return
|
|
87
134
|
}
|
|
88
135
|
})
|
|
@@ -143,13 +190,11 @@ export function DiscoverView() {
|
|
|
143
190
|
>
|
|
144
191
|
{/* Results info */}
|
|
145
192
|
<text fg={colors.textDim}>
|
|
146
|
-
{loading
|
|
193
|
+
{isSearching && loading
|
|
147
194
|
? "Loading..."
|
|
148
|
-
: error
|
|
195
|
+
: isSearching && error
|
|
149
196
|
? `Error: ${error}`
|
|
150
|
-
:
|
|
151
|
-
? `${results.length} result${results.length !== 1 ? "s" : ""}`
|
|
152
|
-
: `${results.length}/${total} skills`}
|
|
197
|
+
: `${visibleSkills.length} ${isSearching ? "result" : "skill"}${visibleSkills.length !== 1 ? "s" : ""}`}
|
|
153
198
|
</text>
|
|
154
199
|
</box>
|
|
155
200
|
|
|
@@ -164,15 +209,38 @@ export function DiscoverView() {
|
|
|
164
209
|
flexDirection: "column",
|
|
165
210
|
} as any}
|
|
166
211
|
>
|
|
167
|
-
{/*
|
|
212
|
+
{/* Section header */}
|
|
168
213
|
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
169
|
-
<text fg={colors.textDim}>
|
|
214
|
+
<text fg={colors.textDim}>{isSearching ? "Results" : "Trending"}</text>
|
|
215
|
+
</box>
|
|
216
|
+
|
|
217
|
+
{/* Focusable "Official only" filter row */}
|
|
218
|
+
<box
|
|
219
|
+
style={{
|
|
220
|
+
height: 1,
|
|
221
|
+
width: "100%",
|
|
222
|
+
paddingLeft: 1,
|
|
223
|
+
paddingRight: 1,
|
|
224
|
+
flexDirection: "row",
|
|
225
|
+
backgroundColor:
|
|
226
|
+
selectedIndex === TOGGLE_INDEX ? colors.bgAlt : "transparent",
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<text
|
|
230
|
+
fg={selectedIndex === TOGGLE_INDEX ? colors.primary : colors.textDim}
|
|
231
|
+
>
|
|
232
|
+
{officialOnly ? "[x]" : "[ ]"} Official only
|
|
233
|
+
</text>
|
|
170
234
|
</box>
|
|
171
235
|
|
|
172
|
-
{
|
|
236
|
+
{!isSearching && trendingLoading && trending.length === 0 ? (
|
|
237
|
+
<box style={{ padding: 1 }}>
|
|
238
|
+
<text fg={colors.textDim}>Loading popular skills…</text>
|
|
239
|
+
</box>
|
|
240
|
+
) : visibleSkills.length === 0 && !(isSearching && loading) ? (
|
|
173
241
|
<box style={{ padding: 1 }}>
|
|
174
242
|
<text fg={colors.textDim}>
|
|
175
|
-
{
|
|
243
|
+
{isSearching
|
|
176
244
|
? "No skills found matching your query."
|
|
177
245
|
: "No skills available in the catalog."}
|
|
178
246
|
</text>
|
|
@@ -194,7 +262,7 @@ export function DiscoverView() {
|
|
|
194
262
|
},
|
|
195
263
|
}}
|
|
196
264
|
>
|
|
197
|
-
{
|
|
265
|
+
{visibleSkills.map((skill, i) => (
|
|
198
266
|
<box
|
|
199
267
|
key={skill.id ?? `${skill.skillId}-${i}`}
|
|
200
268
|
style={{
|
|
@@ -205,12 +273,15 @@ export function DiscoverView() {
|
|
|
205
273
|
backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
|
|
206
274
|
}}
|
|
207
275
|
>
|
|
276
|
+
{skill.isOfficial ? (
|
|
277
|
+
<text fg={colors.primary}>{"✓ "}</text>
|
|
278
|
+
) : null}
|
|
208
279
|
<text fg={i === selectedIndex ? colors.primary : colors.text}>
|
|
209
280
|
{skill.name}
|
|
210
281
|
</text>
|
|
211
282
|
</box>
|
|
212
283
|
))}
|
|
213
|
-
{hasMore && (
|
|
284
|
+
{isSearching && hasMore && (
|
|
214
285
|
<box style={{ paddingLeft: 1, height: 1 }}>
|
|
215
286
|
<text fg={colors.textDim}>
|
|
216
287
|
{loading ? "Loading more..." : "Scroll down to load more..."}
|