@rch4com/agent-setup 1.9.0 → 1.10.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/LICENSE +21 -21
- package/README.md +199 -199
- package/install.mjs +163 -163
- package/lib/args.mjs +276 -276
- package/lib/bootstrap/adapter.mjs +96 -96
- package/lib/bootstrap/apply.mjs +380 -380
- package/lib/bootstrap/flow.mjs +140 -140
- package/lib/bootstrap/record.mjs +154 -154
- package/lib/bootstrap/templates.mjs +148 -148
- package/lib/bootstrap/text.mjs +19 -19
- package/lib/catalog.mjs +234 -234
- package/lib/claude-plugins.mjs +64 -64
- package/lib/clis.mjs +111 -111
- package/lib/context.mjs +66 -66
- package/lib/deps.mjs +17 -17
- package/lib/design-md/catalog.json +454 -454
- package/lib/design-md/catalog.mjs +266 -266
- package/lib/design-md/flow.mjs +192 -192
- package/lib/design-md/open.mjs +51 -51
- package/lib/design-md/providers/awesome-design-md.mjs +124 -124
- package/lib/design-md/providers/index.mjs +9 -9
- package/lib/design-md/scan.mjs +295 -295
- package/lib/engine.mjs +115 -115
- package/lib/gitignore.mjs +18 -18
- package/lib/i18n/catalog/en.mjs +426 -425
- package/lib/i18n/catalog/ko.mjs +411 -410
- package/lib/i18n/detect.mjs +39 -39
- package/lib/i18n/index.mjs +93 -93
- package/lib/items/mcp.codebase-memory.mjs +6 -6
- package/lib/items/mcp.notion.mjs +6 -6
- package/lib/items/mcp.supabase.mjs +6 -6
- package/lib/items/mcp.vercel.mjs +6 -6
- package/lib/items/plugin.mattpocock-skills.mjs +8 -8
- package/lib/items/plugin.ponytail.mjs +104 -104
- package/lib/items/skill.gsd.mjs +44 -44
- package/lib/items/skill.gstack.mjs +54 -54
- package/lib/items/skill.hallmark.mjs +10 -0
- package/lib/jsonfile.mjs +40 -40
- package/lib/status.mjs +93 -93
- package/lib/tomlfile.mjs +60 -60
- package/lib/tui/detail.mjs +127 -127
- package/lib/tui/progress.mjs +160 -160
- package/lib/tui/render.mjs +268 -268
- package/lib/tui/rows.mjs +317 -317
- package/lib/tui/run.mjs +396 -396
- package/lib/tui/state.mjs +249 -249
- package/lib/update.mjs +85 -85
- package/lib/width.mjs +115 -115
- package/package.json +36 -36
package/lib/tui/state.mjs
CHANGED
|
@@ -1,249 +1,249 @@
|
|
|
1
|
-
// 순수 리듀서 — 아무것도 import 하지 않는다. 터미널도 파일시스템도 모른다.
|
|
2
|
-
// 모든 전이는 새 상태를 돌려준다. 키 시퀀스를 넣고 결과를 검증할 수 있게 하기 위해서다.
|
|
3
|
-
|
|
4
|
-
function clamp(n, lo, hi) {
|
|
5
|
-
return n < lo ? lo : n > hi ? hi : n
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
// 탭 = 섹션. 순서는 행 배열의 등장 순서를 그대로 따른다 —
|
|
9
|
-
// buildRows가 이미 SECTION_ORDER로 정렬해 주므로 여기서 순서를 다시 알 필요가 없다.
|
|
10
|
-
export function tabsOf(rows) {
|
|
11
|
-
const out = []
|
|
12
|
-
for (const row of rows) if (!out.includes(row.section)) out.push(row.section)
|
|
13
|
-
return out
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// 포커스는 두 곳 중 하나에 있다: 'search'(검색 입력칸) 또는 'list'(항목 목록).
|
|
17
|
-
// 어느 쪽이 포커스인지가 Space·타이핑의 뜻을 가른다 — 검색칸에서는 글자가
|
|
18
|
-
// 검색어(스페이스 포함)로, 목록에서는 Space가 선택으로 동작한다.
|
|
19
|
-
// 흔한 TUI의 두-존(zone) 모델이다.
|
|
20
|
-
export function createState(rows, { selectedIds = [], query = '', tabIndex = 0, focus = 'list', cliFilter = null } = {}) {
|
|
21
|
-
const tabs = tabsOf(rows)
|
|
22
|
-
const index = clamp(tabIndex, 0, Math.max(0, tabs.length - 1))
|
|
23
|
-
return {
|
|
24
|
-
rows,
|
|
25
|
-
tabs,
|
|
26
|
-
tabIndex: index,
|
|
27
|
-
focus,
|
|
28
|
-
query,
|
|
29
|
-
cliFilter,
|
|
30
|
-
filtered: visibleRows(rows, query, tabs[index], cliFilter),
|
|
31
|
-
selected: new Set(selectedIds),
|
|
32
|
-
cursor: 0,
|
|
33
|
-
offset: 0,
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 포커스를 검색칸/목록으로 옮긴다. 커서·선택·검색어는 건드리지 않는다.
|
|
38
|
-
export function setFocus(state, focus) {
|
|
39
|
-
return focus === state.focus ? state : { ...state, focus }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 공백으로 나눈 토큰 AND 부분일치.
|
|
43
|
-
// 빈 검색어는 전체 통과다 — 여기서는 빈 검색어가 곧 초기 화면이다.
|
|
44
|
-
// 필터는 selected를 건드리지 않으므로, 빈 검색어가 전체 선택으로 새는 일이 없다.
|
|
45
|
-
export function filterRows(rows, query) {
|
|
46
|
-
const tokens = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
47
|
-
if (tokens.length === 0) return rows
|
|
48
|
-
return rows.filter((row) => tokens.every((tok) => row.searchText.includes(tok)))
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// CLI 필터 통과 판정. 액션 행은 CLI 개념이 없고(설치가 아니라 실행이다),
|
|
52
|
-
// design.md는 모든 CLI가 함께 읽는 문서라 supports를 갖지 않는다.
|
|
53
|
-
// 둘을 걸러 내면 필터를 건 순간 화면에서 길이 끊긴다.
|
|
54
|
-
export function matchesCli(row, cliFilter) {
|
|
55
|
-
if (!cliFilter) return true
|
|
56
|
-
if (row.kind !== 'item') return true
|
|
57
|
-
const supports = row.item?.supports
|
|
58
|
-
if (!supports) return true
|
|
59
|
-
return supports.includes(cliFilter)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// 화면에 실제로 보이는 행 = 활성 탭 ∩ 검색 결과 ∩ CLI 필터.
|
|
63
|
-
// 검색은 탭 안으로 스코프하되, 다른 탭의 적중 수는 tabCounts로 함께 보여 준다 —
|
|
64
|
-
// 그래야 "어느 탭에 있는지" 를 잃지 않으면서도 Tab 키가 늘 의미를 갖는다.
|
|
65
|
-
function visibleRows(rows, query, tab, cliFilter) {
|
|
66
|
-
return filterRows(rows, query).filter((row) => row.section === tab && matchesCli(row, cliFilter))
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function activeTab(state) {
|
|
70
|
-
return state.tabs[state.tabIndex] ?? null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 탭별 적중 수 — 검색·필터 중에도 어느 탭에 결과가 있는지 한눈에 보이게 한다.
|
|
74
|
-
// total은 필터를 타지 않는다: 분모가 함께 줄면 "이 탭에서 몇 개가 걸러졌나"를
|
|
75
|
-
// 알 수 없다.
|
|
76
|
-
export function tabCounts(state) {
|
|
77
|
-
const hits = filterRows(state.rows, state.query).filter((r) => matchesCli(r, state.cliFilter))
|
|
78
|
-
return state.tabs.map((tab) => ({
|
|
79
|
-
tab,
|
|
80
|
-
shown: hits.filter((r) => r.section === tab).length,
|
|
81
|
-
total: state.rows.filter((r) => r.section === tab).length,
|
|
82
|
-
}))
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function currentRow(state) {
|
|
86
|
-
return state.filtered[state.cursor] ?? null
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function refocus(state, patch) {
|
|
90
|
-
const next = { ...state, ...patch }
|
|
91
|
-
return { ...next, filtered: visibleRows(next.rows, next.query, activeTab(next), next.cliFilter), cursor: 0, offset: 0 }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 검색어를 바꾸면 커서는 첫 행으로 돌아간다.
|
|
95
|
-
// selected는 절대 건드리지 않는다 — 화면 밖 설치본이 조용히 사라지지 않게 하는 핵심 규칙이다.
|
|
96
|
-
export function setQuery(state, query) {
|
|
97
|
-
return refocus(state, { query })
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 탭 이동은 순환한다 — 탭은 대여섯 개뿐이라 끝에서 막히면 오히려 불편하다.
|
|
101
|
-
// 검색어는 유지한다: 같은 검색어로 탭을 훑는 것이 탭 UI의 본래 쓸모다.
|
|
102
|
-
export function moveTab(state, delta) {
|
|
103
|
-
if (state.tabs.length === 0) return state
|
|
104
|
-
const n = state.tabs.length
|
|
105
|
-
const tabIndex = (((state.tabIndex + delta) % n) + n) % n
|
|
106
|
-
return tabIndex === state.tabIndex ? state : refocus(state, { tabIndex })
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function setTab(state, index) {
|
|
110
|
-
if (state.tabs.length === 0) return state
|
|
111
|
-
const tabIndex = clamp(index, 0, state.tabs.length - 1)
|
|
112
|
-
return tabIndex === state.tabIndex ? state : refocus(state, { tabIndex })
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 필터를 바꾸면 커서는 첫 행으로 돌아간다. selected는 절대 건드리지 않는다 —
|
|
116
|
-
// setQuery와 같은 규칙이다. 화면 밖 설치본이 조용히 사라지지 않게 하는 핵심이다.
|
|
117
|
-
export function setCliFilter(state, cliFilter) {
|
|
118
|
-
return cliFilter === state.cliFilter ? state : refocus(state, { cliFilter })
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// 순환 대상은 [null, ...CLI_IDS]다. 이 모듈은 아무것도 import 하지 않으므로
|
|
122
|
-
// 목록을 인자로 받는다 — 그 규칙이 이 파일을 순수하게 지켜 준다.
|
|
123
|
-
export function cycleCliFilter(state, delta, options) {
|
|
124
|
-
if (!options || options.length === 0) return state
|
|
125
|
-
const at = options.indexOf(state.cliFilter ?? null)
|
|
126
|
-
const n = options.length
|
|
127
|
-
const next = options[((((at === -1 ? 0 : at) + delta) % n) + n) % n]
|
|
128
|
-
return setCliFilter(state, next ?? null)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// 양 끝에서 멈춘다(순환 없음) — 76개 목록에서 한 칸 지나쳤을 때 반대편으로 튀면 길을 잃는다.
|
|
132
|
-
export function move(state, delta) {
|
|
133
|
-
if (state.filtered.length === 0) return state
|
|
134
|
-
const cursor = clamp(state.cursor + delta, 0, state.filtered.length - 1)
|
|
135
|
-
return cursor === state.cursor ? state : { ...state, cursor }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// row.exclusive는 "이 항목들은 같은 파일 하나를 두고 다툰다"는 표시다
|
|
139
|
-
// (.gitmessage.txt의 영어판·한국어판). 하나를 켜면 나머지는 꺼진다 — 둘을 함께
|
|
140
|
-
// 고르면 나중에 적용된 쪽이 앞선 쪽을 조용히 덮어써, 화면의 체크 두 개가
|
|
141
|
-
// 디스크의 한 파일을 가리키는 거짓말이 된다.
|
|
142
|
-
//
|
|
143
|
-
// 화면 밖(검색·CLI 필터로 가려진) 형제도 끈다. 보이는 것만 건드린다는 이
|
|
144
|
-
// 모듈의 규칙에서 유일하게 벗어나는 자리인데, 여기서는 그래야 한다 —
|
|
145
|
-
// 가려진 형제를 남겨 두면 사용자가 방금 명시적으로 고른 판이 적용 순서에
|
|
146
|
-
// 따라 뒤집힐 수 있다.
|
|
147
|
-
function dropSiblings(rows, selected, row) {
|
|
148
|
-
if (!row.exclusive) return
|
|
149
|
-
for (const other of rows) {
|
|
150
|
-
if (other.id !== row.id && other.exclusive === row.exclusive) selected.delete(other.id)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// 형제 중 하나라도 이미 켜져 있는가. 배타 묶음은 "전부 켜짐"이 불가능하므로
|
|
155
|
-
// 전체 토글의 방향 판정도 이 기준으로 봐야 한다 — 아니면 Ctrl+A가 영원히
|
|
156
|
-
// 켜기만 하고 끄지 못한다.
|
|
157
|
-
function groupCovered(items, selected, row) {
|
|
158
|
-
return items.some((other) => other.exclusive === row.exclusive && selected.has(other.id))
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// 커서가 항목 행일 때만 뒤집는다. 액션 행은 제거 개념이 없어 체크 대상이 아니다.
|
|
162
|
-
export function toggle(state) {
|
|
163
|
-
const row = currentRow(state)
|
|
164
|
-
if (row?.kind !== 'item') return state
|
|
165
|
-
const selected = new Set(state.selected)
|
|
166
|
-
if (selected.has(row.id)) {
|
|
167
|
-
selected.delete(row.id)
|
|
168
|
-
} else {
|
|
169
|
-
selected.add(row.id)
|
|
170
|
-
dropSiblings(state.rows, selected, row)
|
|
171
|
-
}
|
|
172
|
-
return { ...state, selected }
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 지금 보이는 항목 행 전체를 켜거나 끈다.
|
|
176
|
-
// 보이는 것만 건드린다 — 화면 밖 설치본을 일괄 조작으로 날리지 않기 위해서다.
|
|
177
|
-
export function toggleVisible(state, on) {
|
|
178
|
-
const items = state.filtered.filter((r) => r.kind === 'item')
|
|
179
|
-
if (items.length === 0) return state
|
|
180
|
-
const selected = new Set(state.selected)
|
|
181
|
-
const turnOn = on ?? !items.every((r) => selected.has(r.id) || (r.exclusive && groupCovered(items, selected, r)))
|
|
182
|
-
for (const row of items) {
|
|
183
|
-
if (!turnOn) { selected.delete(row.id); continue }
|
|
184
|
-
// 배타 묶음에서 이미 켜진 형제가 있으면 그 선택을 이긴다 — 전체 선택이
|
|
185
|
-
// 사용자가 고른 언어판을 목록 순서로 뒤집으면 안 된다.
|
|
186
|
-
if (row.exclusive && groupCovered(items, selected, row)) continue
|
|
187
|
-
selected.add(row.id)
|
|
188
|
-
dropSiblings(state.rows, selected, row)
|
|
189
|
-
}
|
|
190
|
-
return { ...state, selected }
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 행을 갈아끼운다(적용·액션 실행 뒤 재스캔). 검색어·활성 탭은 유지하고
|
|
194
|
-
// 선택은 새 설치 상태로 재초기화한다.
|
|
195
|
-
export function replaceRows(state, rows, selectedIds) {
|
|
196
|
-
const tabs = tabsOf(rows)
|
|
197
|
-
const tabIndex = clamp(tabs.indexOf(activeTab(state)), 0, Math.max(0, tabs.length - 1))
|
|
198
|
-
const filtered = visibleRows(rows, state.query, tabs[tabIndex], state.cliFilter)
|
|
199
|
-
return {
|
|
200
|
-
...state,
|
|
201
|
-
rows,
|
|
202
|
-
tabs,
|
|
203
|
-
tabIndex,
|
|
204
|
-
filtered,
|
|
205
|
-
selected: new Set(selectedIds),
|
|
206
|
-
cursor: clamp(state.cursor, 0, Math.max(0, filtered.length - 1)),
|
|
207
|
-
offset: 0,
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// 활성 탭의 목록에 그룹 헤더를 끼워 넣는다.
|
|
212
|
-
// 그룹은 행의 group 필드다(design.md는 카테고리, 나머지는 없음) —
|
|
213
|
-
// 탭이 이미 섹션을 갈라 놓았으므로 섹션 헤더는 더 이상 필요 없다.
|
|
214
|
-
export function displayList(state) {
|
|
215
|
-
const tab = activeTab(state)
|
|
216
|
-
const totals = new Map()
|
|
217
|
-
for (const row of state.rows) {
|
|
218
|
-
if (row.section !== tab || !row.group) continue
|
|
219
|
-
totals.set(row.group, (totals.get(row.group) ?? 0) + 1)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const out = []
|
|
223
|
-
let header = null
|
|
224
|
-
state.filtered.forEach((row, index) => {
|
|
225
|
-
if (row.group && (!header || header.section !== row.group)) {
|
|
226
|
-
header = { type: 'header', section: row.group, shown: 0, total: totals.get(row.group) ?? 0 }
|
|
227
|
-
out.push(header)
|
|
228
|
-
}
|
|
229
|
-
if (!row.group) header = null
|
|
230
|
-
if (header) header.shown++
|
|
231
|
-
out.push({ type: 'row', row, index })
|
|
232
|
-
})
|
|
233
|
-
return out
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// 커서가 창 밖으로 나가면 최소한으로 민다. 이미 보이면 그대로 둔다 — 목록이 덜 흔들린다.
|
|
237
|
-
export function scroll(state, height) {
|
|
238
|
-
const lines = displayList(state)
|
|
239
|
-
const line = lines.findIndex((e) => e.type === 'row' && e.index === state.cursor)
|
|
240
|
-
if (line < 0) return state.offset === 0 ? state : { ...state, offset: 0 }
|
|
241
|
-
|
|
242
|
-
let offset = state.offset
|
|
243
|
-
// 커서 바로 위 그룹 헤더까지 같이 보이게 한다 — 어느 그룹을 보고 있는지 잃지 않게.
|
|
244
|
-
const top = lines[line - 1]?.type === 'header' ? line - 1 : line
|
|
245
|
-
if (top < offset) offset = top
|
|
246
|
-
else if (line >= offset + height) offset = line - height + 1
|
|
247
|
-
offset = clamp(offset, 0, Math.max(0, lines.length - height))
|
|
248
|
-
return offset === state.offset ? state : { ...state, offset }
|
|
249
|
-
}
|
|
1
|
+
// 순수 리듀서 — 아무것도 import 하지 않는다. 터미널도 파일시스템도 모른다.
|
|
2
|
+
// 모든 전이는 새 상태를 돌려준다. 키 시퀀스를 넣고 결과를 검증할 수 있게 하기 위해서다.
|
|
3
|
+
|
|
4
|
+
function clamp(n, lo, hi) {
|
|
5
|
+
return n < lo ? lo : n > hi ? hi : n
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// 탭 = 섹션. 순서는 행 배열의 등장 순서를 그대로 따른다 —
|
|
9
|
+
// buildRows가 이미 SECTION_ORDER로 정렬해 주므로 여기서 순서를 다시 알 필요가 없다.
|
|
10
|
+
export function tabsOf(rows) {
|
|
11
|
+
const out = []
|
|
12
|
+
for (const row of rows) if (!out.includes(row.section)) out.push(row.section)
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 포커스는 두 곳 중 하나에 있다: 'search'(검색 입력칸) 또는 'list'(항목 목록).
|
|
17
|
+
// 어느 쪽이 포커스인지가 Space·타이핑의 뜻을 가른다 — 검색칸에서는 글자가
|
|
18
|
+
// 검색어(스페이스 포함)로, 목록에서는 Space가 선택으로 동작한다.
|
|
19
|
+
// 흔한 TUI의 두-존(zone) 모델이다.
|
|
20
|
+
export function createState(rows, { selectedIds = [], query = '', tabIndex = 0, focus = 'list', cliFilter = null } = {}) {
|
|
21
|
+
const tabs = tabsOf(rows)
|
|
22
|
+
const index = clamp(tabIndex, 0, Math.max(0, tabs.length - 1))
|
|
23
|
+
return {
|
|
24
|
+
rows,
|
|
25
|
+
tabs,
|
|
26
|
+
tabIndex: index,
|
|
27
|
+
focus,
|
|
28
|
+
query,
|
|
29
|
+
cliFilter,
|
|
30
|
+
filtered: visibleRows(rows, query, tabs[index], cliFilter),
|
|
31
|
+
selected: new Set(selectedIds),
|
|
32
|
+
cursor: 0,
|
|
33
|
+
offset: 0,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 포커스를 검색칸/목록으로 옮긴다. 커서·선택·검색어는 건드리지 않는다.
|
|
38
|
+
export function setFocus(state, focus) {
|
|
39
|
+
return focus === state.focus ? state : { ...state, focus }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 공백으로 나눈 토큰 AND 부분일치.
|
|
43
|
+
// 빈 검색어는 전체 통과다 — 여기서는 빈 검색어가 곧 초기 화면이다.
|
|
44
|
+
// 필터는 selected를 건드리지 않으므로, 빈 검색어가 전체 선택으로 새는 일이 없다.
|
|
45
|
+
export function filterRows(rows, query) {
|
|
46
|
+
const tokens = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
47
|
+
if (tokens.length === 0) return rows
|
|
48
|
+
return rows.filter((row) => tokens.every((tok) => row.searchText.includes(tok)))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// CLI 필터 통과 판정. 액션 행은 CLI 개념이 없고(설치가 아니라 실행이다),
|
|
52
|
+
// design.md는 모든 CLI가 함께 읽는 문서라 supports를 갖지 않는다.
|
|
53
|
+
// 둘을 걸러 내면 필터를 건 순간 화면에서 길이 끊긴다.
|
|
54
|
+
export function matchesCli(row, cliFilter) {
|
|
55
|
+
if (!cliFilter) return true
|
|
56
|
+
if (row.kind !== 'item') return true
|
|
57
|
+
const supports = row.item?.supports
|
|
58
|
+
if (!supports) return true
|
|
59
|
+
return supports.includes(cliFilter)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 화면에 실제로 보이는 행 = 활성 탭 ∩ 검색 결과 ∩ CLI 필터.
|
|
63
|
+
// 검색은 탭 안으로 스코프하되, 다른 탭의 적중 수는 tabCounts로 함께 보여 준다 —
|
|
64
|
+
// 그래야 "어느 탭에 있는지" 를 잃지 않으면서도 Tab 키가 늘 의미를 갖는다.
|
|
65
|
+
function visibleRows(rows, query, tab, cliFilter) {
|
|
66
|
+
return filterRows(rows, query).filter((row) => row.section === tab && matchesCli(row, cliFilter))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function activeTab(state) {
|
|
70
|
+
return state.tabs[state.tabIndex] ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 탭별 적중 수 — 검색·필터 중에도 어느 탭에 결과가 있는지 한눈에 보이게 한다.
|
|
74
|
+
// total은 필터를 타지 않는다: 분모가 함께 줄면 "이 탭에서 몇 개가 걸러졌나"를
|
|
75
|
+
// 알 수 없다.
|
|
76
|
+
export function tabCounts(state) {
|
|
77
|
+
const hits = filterRows(state.rows, state.query).filter((r) => matchesCli(r, state.cliFilter))
|
|
78
|
+
return state.tabs.map((tab) => ({
|
|
79
|
+
tab,
|
|
80
|
+
shown: hits.filter((r) => r.section === tab).length,
|
|
81
|
+
total: state.rows.filter((r) => r.section === tab).length,
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function currentRow(state) {
|
|
86
|
+
return state.filtered[state.cursor] ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function refocus(state, patch) {
|
|
90
|
+
const next = { ...state, ...patch }
|
|
91
|
+
return { ...next, filtered: visibleRows(next.rows, next.query, activeTab(next), next.cliFilter), cursor: 0, offset: 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 검색어를 바꾸면 커서는 첫 행으로 돌아간다.
|
|
95
|
+
// selected는 절대 건드리지 않는다 — 화면 밖 설치본이 조용히 사라지지 않게 하는 핵심 규칙이다.
|
|
96
|
+
export function setQuery(state, query) {
|
|
97
|
+
return refocus(state, { query })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 탭 이동은 순환한다 — 탭은 대여섯 개뿐이라 끝에서 막히면 오히려 불편하다.
|
|
101
|
+
// 검색어는 유지한다: 같은 검색어로 탭을 훑는 것이 탭 UI의 본래 쓸모다.
|
|
102
|
+
export function moveTab(state, delta) {
|
|
103
|
+
if (state.tabs.length === 0) return state
|
|
104
|
+
const n = state.tabs.length
|
|
105
|
+
const tabIndex = (((state.tabIndex + delta) % n) + n) % n
|
|
106
|
+
return tabIndex === state.tabIndex ? state : refocus(state, { tabIndex })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function setTab(state, index) {
|
|
110
|
+
if (state.tabs.length === 0) return state
|
|
111
|
+
const tabIndex = clamp(index, 0, state.tabs.length - 1)
|
|
112
|
+
return tabIndex === state.tabIndex ? state : refocus(state, { tabIndex })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 필터를 바꾸면 커서는 첫 행으로 돌아간다. selected는 절대 건드리지 않는다 —
|
|
116
|
+
// setQuery와 같은 규칙이다. 화면 밖 설치본이 조용히 사라지지 않게 하는 핵심이다.
|
|
117
|
+
export function setCliFilter(state, cliFilter) {
|
|
118
|
+
return cliFilter === state.cliFilter ? state : refocus(state, { cliFilter })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 순환 대상은 [null, ...CLI_IDS]다. 이 모듈은 아무것도 import 하지 않으므로
|
|
122
|
+
// 목록을 인자로 받는다 — 그 규칙이 이 파일을 순수하게 지켜 준다.
|
|
123
|
+
export function cycleCliFilter(state, delta, options) {
|
|
124
|
+
if (!options || options.length === 0) return state
|
|
125
|
+
const at = options.indexOf(state.cliFilter ?? null)
|
|
126
|
+
const n = options.length
|
|
127
|
+
const next = options[((((at === -1 ? 0 : at) + delta) % n) + n) % n]
|
|
128
|
+
return setCliFilter(state, next ?? null)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 양 끝에서 멈춘다(순환 없음) — 76개 목록에서 한 칸 지나쳤을 때 반대편으로 튀면 길을 잃는다.
|
|
132
|
+
export function move(state, delta) {
|
|
133
|
+
if (state.filtered.length === 0) return state
|
|
134
|
+
const cursor = clamp(state.cursor + delta, 0, state.filtered.length - 1)
|
|
135
|
+
return cursor === state.cursor ? state : { ...state, cursor }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// row.exclusive는 "이 항목들은 같은 파일 하나를 두고 다툰다"는 표시다
|
|
139
|
+
// (.gitmessage.txt의 영어판·한국어판). 하나를 켜면 나머지는 꺼진다 — 둘을 함께
|
|
140
|
+
// 고르면 나중에 적용된 쪽이 앞선 쪽을 조용히 덮어써, 화면의 체크 두 개가
|
|
141
|
+
// 디스크의 한 파일을 가리키는 거짓말이 된다.
|
|
142
|
+
//
|
|
143
|
+
// 화면 밖(검색·CLI 필터로 가려진) 형제도 끈다. 보이는 것만 건드린다는 이
|
|
144
|
+
// 모듈의 규칙에서 유일하게 벗어나는 자리인데, 여기서는 그래야 한다 —
|
|
145
|
+
// 가려진 형제를 남겨 두면 사용자가 방금 명시적으로 고른 판이 적용 순서에
|
|
146
|
+
// 따라 뒤집힐 수 있다.
|
|
147
|
+
function dropSiblings(rows, selected, row) {
|
|
148
|
+
if (!row.exclusive) return
|
|
149
|
+
for (const other of rows) {
|
|
150
|
+
if (other.id !== row.id && other.exclusive === row.exclusive) selected.delete(other.id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 형제 중 하나라도 이미 켜져 있는가. 배타 묶음은 "전부 켜짐"이 불가능하므로
|
|
155
|
+
// 전체 토글의 방향 판정도 이 기준으로 봐야 한다 — 아니면 Ctrl+A가 영원히
|
|
156
|
+
// 켜기만 하고 끄지 못한다.
|
|
157
|
+
function groupCovered(items, selected, row) {
|
|
158
|
+
return items.some((other) => other.exclusive === row.exclusive && selected.has(other.id))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 커서가 항목 행일 때만 뒤집는다. 액션 행은 제거 개념이 없어 체크 대상이 아니다.
|
|
162
|
+
export function toggle(state) {
|
|
163
|
+
const row = currentRow(state)
|
|
164
|
+
if (row?.kind !== 'item') return state
|
|
165
|
+
const selected = new Set(state.selected)
|
|
166
|
+
if (selected.has(row.id)) {
|
|
167
|
+
selected.delete(row.id)
|
|
168
|
+
} else {
|
|
169
|
+
selected.add(row.id)
|
|
170
|
+
dropSiblings(state.rows, selected, row)
|
|
171
|
+
}
|
|
172
|
+
return { ...state, selected }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 지금 보이는 항목 행 전체를 켜거나 끈다.
|
|
176
|
+
// 보이는 것만 건드린다 — 화면 밖 설치본을 일괄 조작으로 날리지 않기 위해서다.
|
|
177
|
+
export function toggleVisible(state, on) {
|
|
178
|
+
const items = state.filtered.filter((r) => r.kind === 'item')
|
|
179
|
+
if (items.length === 0) return state
|
|
180
|
+
const selected = new Set(state.selected)
|
|
181
|
+
const turnOn = on ?? !items.every((r) => selected.has(r.id) || (r.exclusive && groupCovered(items, selected, r)))
|
|
182
|
+
for (const row of items) {
|
|
183
|
+
if (!turnOn) { selected.delete(row.id); continue }
|
|
184
|
+
// 배타 묶음에서 이미 켜진 형제가 있으면 그 선택을 이긴다 — 전체 선택이
|
|
185
|
+
// 사용자가 고른 언어판을 목록 순서로 뒤집으면 안 된다.
|
|
186
|
+
if (row.exclusive && groupCovered(items, selected, row)) continue
|
|
187
|
+
selected.add(row.id)
|
|
188
|
+
dropSiblings(state.rows, selected, row)
|
|
189
|
+
}
|
|
190
|
+
return { ...state, selected }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 행을 갈아끼운다(적용·액션 실행 뒤 재스캔). 검색어·활성 탭은 유지하고
|
|
194
|
+
// 선택은 새 설치 상태로 재초기화한다.
|
|
195
|
+
export function replaceRows(state, rows, selectedIds) {
|
|
196
|
+
const tabs = tabsOf(rows)
|
|
197
|
+
const tabIndex = clamp(tabs.indexOf(activeTab(state)), 0, Math.max(0, tabs.length - 1))
|
|
198
|
+
const filtered = visibleRows(rows, state.query, tabs[tabIndex], state.cliFilter)
|
|
199
|
+
return {
|
|
200
|
+
...state,
|
|
201
|
+
rows,
|
|
202
|
+
tabs,
|
|
203
|
+
tabIndex,
|
|
204
|
+
filtered,
|
|
205
|
+
selected: new Set(selectedIds),
|
|
206
|
+
cursor: clamp(state.cursor, 0, Math.max(0, filtered.length - 1)),
|
|
207
|
+
offset: 0,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 활성 탭의 목록에 그룹 헤더를 끼워 넣는다.
|
|
212
|
+
// 그룹은 행의 group 필드다(design.md는 카테고리, 나머지는 없음) —
|
|
213
|
+
// 탭이 이미 섹션을 갈라 놓았으므로 섹션 헤더는 더 이상 필요 없다.
|
|
214
|
+
export function displayList(state) {
|
|
215
|
+
const tab = activeTab(state)
|
|
216
|
+
const totals = new Map()
|
|
217
|
+
for (const row of state.rows) {
|
|
218
|
+
if (row.section !== tab || !row.group) continue
|
|
219
|
+
totals.set(row.group, (totals.get(row.group) ?? 0) + 1)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const out = []
|
|
223
|
+
let header = null
|
|
224
|
+
state.filtered.forEach((row, index) => {
|
|
225
|
+
if (row.group && (!header || header.section !== row.group)) {
|
|
226
|
+
header = { type: 'header', section: row.group, shown: 0, total: totals.get(row.group) ?? 0 }
|
|
227
|
+
out.push(header)
|
|
228
|
+
}
|
|
229
|
+
if (!row.group) header = null
|
|
230
|
+
if (header) header.shown++
|
|
231
|
+
out.push({ type: 'row', row, index })
|
|
232
|
+
})
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 커서가 창 밖으로 나가면 최소한으로 민다. 이미 보이면 그대로 둔다 — 목록이 덜 흔들린다.
|
|
237
|
+
export function scroll(state, height) {
|
|
238
|
+
const lines = displayList(state)
|
|
239
|
+
const line = lines.findIndex((e) => e.type === 'row' && e.index === state.cursor)
|
|
240
|
+
if (line < 0) return state.offset === 0 ? state : { ...state, offset: 0 }
|
|
241
|
+
|
|
242
|
+
let offset = state.offset
|
|
243
|
+
// 커서 바로 위 그룹 헤더까지 같이 보이게 한다 — 어느 그룹을 보고 있는지 잃지 않게.
|
|
244
|
+
const top = lines[line - 1]?.type === 'header' ? line - 1 : line
|
|
245
|
+
if (top < offset) offset = top
|
|
246
|
+
else if (line >= offset + height) offset = line - height + 1
|
|
247
|
+
offset = clamp(offset, 0, Math.max(0, lines.length - height))
|
|
248
|
+
return offset === state.offset ? state : { ...state, offset }
|
|
249
|
+
}
|