@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillsgate/tui",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillsgate-tui": "bin/skillsgate-tui"
@@ -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
+ }
@@ -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
+ }
@@ -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<CatalogSkill | null>(null)
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 (results[selectedIndex]) {
37
- setPreviewSkill(results[selectedIndex])
74
+ if (selectedIndex >= 0 && visibleSkills[selectedIndex]) {
75
+ setPreviewSkill(visibleSkills[selectedIndex])
38
76
  } else {
39
77
  setPreviewSkill(null)
40
78
  }
41
- }, [selectedIndex, results])
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
- // j/k or arrow keys
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(0, i - 1))
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(results.length - 1, i + 1)
57
- // If we're near the bottom and there's more, load next page
58
- if (next >= results.length - 3 && hasMore && !loading) {
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, results.length - 1))
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" && results[selectedIndex]) {
75
- const skill = results[selectedIndex]
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" && results[selectedIndex]) {
85
- setInstallTarget(results[selectedIndex])
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
- : query.trim()
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
- {/* List header */}
212
+ {/* Section header */}
168
213
  <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
169
- <text fg={colors.textDim}>RESULTS</text>
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
- {results.length === 0 && !loading ? (
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
- {query.trim()
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
- {results.map((skill, i) => (
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..."}