aihand 0.0.1 → 0.1.1
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/README.md +152 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-3J7EYI6G.cjs +651 -0
- package/dist/cli-FIJLKAGI.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- package/index.js +0 -2
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
import type { Tree } from 'web-tree-sitter'
|
|
2
|
+
import type { Config, FileBlock, FileDetailLevel } from './types.js'
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import ignore from 'ignore'
|
|
5
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
6
|
+
import { parse as parseHtml } from 'node-html-parser'
|
|
7
|
+
import postcss from 'postcss'
|
|
8
|
+
import postcssLess from 'postcss-less'
|
|
9
|
+
import postcssScss from 'postcss-scss'
|
|
10
|
+
import { Parser } from 'web-tree-sitter'
|
|
11
|
+
import { parse as parseYaml } from 'yaml'
|
|
12
|
+
import { compact, extractImports, getLang } from './compact.js'
|
|
13
|
+
|
|
14
|
+
// ── D2: Classify ────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/** Pre-compile fileDetailLevel patterns into a fast per-path classifier. */
|
|
17
|
+
export function buildClassifier(
|
|
18
|
+
detail: Record<string, FileDetailLevel>,
|
|
19
|
+
defaultLevel: FileDetailLevel = 'compact',
|
|
20
|
+
): (path: string) => FileDetailLevel {
|
|
21
|
+
const matchers: Array<{ level: FileDetailLevel, ig: ReturnType<typeof ignore> }> = []
|
|
22
|
+
for (const level of ['tree', 'full', 'compact'] as const) {
|
|
23
|
+
const globs = Object.keys(detail).filter(p => detail[p] === level)
|
|
24
|
+
if (globs.length)
|
|
25
|
+
matchers.push({ level, ig: ignore().add(globs) })
|
|
26
|
+
}
|
|
27
|
+
return path => matchers.find(m => m.ig.ignores(path))?.level ?? defaultLevel
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── D3: Transform ───────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
// ── Tech stack detection ─────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Known skeleton-defining packages (whitelist). Only these appear in stack line. */
|
|
35
|
+
const STACK_INCLUDE: Set<string> = new Set([
|
|
36
|
+
// Platform
|
|
37
|
+
'electron',
|
|
38
|
+
'react-native',
|
|
39
|
+
'@tauri-apps/api',
|
|
40
|
+
'@capacitor/core',
|
|
41
|
+
// Meta-framework
|
|
42
|
+
'next',
|
|
43
|
+
'nuxt',
|
|
44
|
+
'@remix-run/react',
|
|
45
|
+
'astro',
|
|
46
|
+
'gatsby',
|
|
47
|
+
'@sveltejs/kit',
|
|
48
|
+
// UI framework
|
|
49
|
+
'react',
|
|
50
|
+
'vue',
|
|
51
|
+
'svelte',
|
|
52
|
+
'solid-js',
|
|
53
|
+
'@angular/core',
|
|
54
|
+
// Build
|
|
55
|
+
'vite',
|
|
56
|
+
'webpack',
|
|
57
|
+
'esbuild',
|
|
58
|
+
'@rspack/core',
|
|
59
|
+
'turbopack',
|
|
60
|
+
// Monorepo
|
|
61
|
+
'turbo',
|
|
62
|
+
'nx',
|
|
63
|
+
// Language
|
|
64
|
+
'typescript',
|
|
65
|
+
// Styling
|
|
66
|
+
'tailwindcss',
|
|
67
|
+
'unocss',
|
|
68
|
+
'styled-components',
|
|
69
|
+
'@emotion/css',
|
|
70
|
+
'@emotion/react',
|
|
71
|
+
'@vanilla-extract/css',
|
|
72
|
+
// Component lib
|
|
73
|
+
'class-variance-authority',
|
|
74
|
+
'@mui/material',
|
|
75
|
+
'antd',
|
|
76
|
+
'@chakra-ui/react',
|
|
77
|
+
'@mantine/core',
|
|
78
|
+
'@headlessui/react',
|
|
79
|
+
'element-plus',
|
|
80
|
+
'@arco-design/web-react',
|
|
81
|
+
'@arco-design/mobile-react',
|
|
82
|
+
'naive-ui',
|
|
83
|
+
'vuetify',
|
|
84
|
+
// Icons
|
|
85
|
+
'lucide-react',
|
|
86
|
+
'@heroicons/react',
|
|
87
|
+
// Routing
|
|
88
|
+
'react-router-dom',
|
|
89
|
+
'@tanstack/react-router',
|
|
90
|
+
'vue-router',
|
|
91
|
+
// State
|
|
92
|
+
'mobx',
|
|
93
|
+
'zustand',
|
|
94
|
+
'redux',
|
|
95
|
+
'@reduxjs/toolkit',
|
|
96
|
+
'@tanstack/react-query',
|
|
97
|
+
'jotai',
|
|
98
|
+
'pinia',
|
|
99
|
+
'valtio',
|
|
100
|
+
'xstate',
|
|
101
|
+
'swr',
|
|
102
|
+
'@apollo/client',
|
|
103
|
+
// Forms
|
|
104
|
+
'react-hook-form',
|
|
105
|
+
// Animation
|
|
106
|
+
'framer-motion',
|
|
107
|
+
'@react-spring/web',
|
|
108
|
+
// Rich text / Editor
|
|
109
|
+
'@tiptap/react',
|
|
110
|
+
'@monaco-editor/react',
|
|
111
|
+
// DnD
|
|
112
|
+
'@dnd-kit/core',
|
|
113
|
+
// Virtualization
|
|
114
|
+
'@tanstack/react-virtual',
|
|
115
|
+
// Charts
|
|
116
|
+
'recharts',
|
|
117
|
+
'@nivo/core',
|
|
118
|
+
'd3',
|
|
119
|
+
// Canvas / Visual
|
|
120
|
+
'@xyflow/react',
|
|
121
|
+
'three',
|
|
122
|
+
'@react-three/fiber',
|
|
123
|
+
// Testing
|
|
124
|
+
'vitest',
|
|
125
|
+
'jest',
|
|
126
|
+
'@playwright/test',
|
|
127
|
+
'cypress',
|
|
128
|
+
// Backend
|
|
129
|
+
'express',
|
|
130
|
+
'fastify',
|
|
131
|
+
'hono',
|
|
132
|
+
'@nestjs/core',
|
|
133
|
+
// ORM / DB
|
|
134
|
+
'drizzle-orm',
|
|
135
|
+
'prisma',
|
|
136
|
+
'@prisma/client',
|
|
137
|
+
'mongoose',
|
|
138
|
+
// BaaS
|
|
139
|
+
'@supabase/supabase-js',
|
|
140
|
+
'firebase',
|
|
141
|
+
// AI
|
|
142
|
+
'ai',
|
|
143
|
+
'@langchain/core',
|
|
144
|
+
// API
|
|
145
|
+
'@trpc/server',
|
|
146
|
+
'graphql',
|
|
147
|
+
// Storage
|
|
148
|
+
'dexie',
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
/** Scoped packages → collapsed label (first prefix match wins, avoids duplicates) */
|
|
152
|
+
const STACK_COLLAPSE: Array<{ prefix: string, label: string }> = [
|
|
153
|
+
{ prefix: '@dnd-kit/', label: 'dnd-kit' },
|
|
154
|
+
{ prefix: '@tiptap/', label: 'tiptap' },
|
|
155
|
+
{ prefix: '@tanstack/react-query', label: 'tanstack-query' },
|
|
156
|
+
{ prefix: '@tanstack/react-router', label: 'tanstack-router' },
|
|
157
|
+
{ prefix: '@tanstack/react-virtual', label: 'tanstack-virtual' },
|
|
158
|
+
{ prefix: '@emotion/', label: 'emotion' },
|
|
159
|
+
{ prefix: '@mui/', label: 'mui' },
|
|
160
|
+
{ prefix: '@chakra-ui/', label: 'chakra' },
|
|
161
|
+
{ prefix: '@mantine/', label: 'mantine' },
|
|
162
|
+
{ prefix: '@headlessui/', label: 'headless-ui' },
|
|
163
|
+
{ prefix: '@heroicons/', label: 'heroicons' },
|
|
164
|
+
{ prefix: '@arco-design/', label: 'arco-design' },
|
|
165
|
+
{ prefix: '@nestjs/', label: 'nestjs' },
|
|
166
|
+
{ prefix: '@supabase/', label: 'supabase' },
|
|
167
|
+
{ prefix: '@langchain/', label: 'langchain' },
|
|
168
|
+
{ prefix: '@trpc/', label: 'trpc' },
|
|
169
|
+
{ prefix: '@prisma/', label: 'prisma' },
|
|
170
|
+
{ prefix: '@remix-run/', label: 'remix' },
|
|
171
|
+
{ prefix: '@sveltejs/kit', label: 'sveltekit' },
|
|
172
|
+
{ prefix: '@angular/', label: 'angular' },
|
|
173
|
+
{ prefix: '@tauri-apps/', label: 'tauri' },
|
|
174
|
+
{ prefix: '@capacitor/', label: 'capacitor' },
|
|
175
|
+
{ prefix: '@xyflow/', label: 'react-flow' },
|
|
176
|
+
{ prefix: '@react-three/', label: 'r3f' },
|
|
177
|
+
{ prefix: '@react-spring/', label: 'react-spring' },
|
|
178
|
+
{ prefix: '@reduxjs/', label: 'redux' },
|
|
179
|
+
{ prefix: '@playwright/', label: 'playwright' },
|
|
180
|
+
{ prefix: '@rspack/', label: 'rspack' },
|
|
181
|
+
{ prefix: '@monaco-editor/', label: 'monaco' },
|
|
182
|
+
{ prefix: '@apollo/', label: 'apollo' },
|
|
183
|
+
{ prefix: '@nivo/', label: 'nivo' },
|
|
184
|
+
{ prefix: '@vanilla-extract/', label: 'vanilla-extract' },
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
/** Rename map — only for deps whose package name != concept name */
|
|
188
|
+
const STACK_RENAME: Record<string, string> = {
|
|
189
|
+
'typescript': 'ts',
|
|
190
|
+
'tailwindcss': 'tailwind',
|
|
191
|
+
'class-variance-authority': 'shadcn/ui',
|
|
192
|
+
'ai': 'vercel-ai',
|
|
193
|
+
'solid-js': 'solid',
|
|
194
|
+
'drizzle-orm': 'drizzle',
|
|
195
|
+
'lucide-react': 'lucide',
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Priority tiers for stack display order (lower = more important)
|
|
199
|
+
const STACK_PRIORITY: Record<string, number> = {
|
|
200
|
+
// Platform / runtime
|
|
201
|
+
"electron": 0,
|
|
202
|
+
'react-native': 0,
|
|
203
|
+
"tauri": 0,
|
|
204
|
+
"capacitor": 0,
|
|
205
|
+
// Meta-framework
|
|
206
|
+
"next": 1,
|
|
207
|
+
"nuxt": 1,
|
|
208
|
+
"remix": 1,
|
|
209
|
+
"astro": 1,
|
|
210
|
+
"gatsby": 1,
|
|
211
|
+
"sveltekit": 1,
|
|
212
|
+
// UI framework
|
|
213
|
+
"react": 2,
|
|
214
|
+
"vue": 2,
|
|
215
|
+
"svelte": 2,
|
|
216
|
+
"solid": 2,
|
|
217
|
+
"angular": 2,
|
|
218
|
+
// Bundler + lang
|
|
219
|
+
"vite": 3,
|
|
220
|
+
"webpack": 3,
|
|
221
|
+
"esbuild": 3,
|
|
222
|
+
"rspack": 3,
|
|
223
|
+
"turbopack": 3,
|
|
224
|
+
"ts": 3,
|
|
225
|
+
// Monorepo
|
|
226
|
+
"turbo": 4,
|
|
227
|
+
"nx": 4,
|
|
228
|
+
// State
|
|
229
|
+
"mobx": 5,
|
|
230
|
+
"zustand": 5,
|
|
231
|
+
"redux": 5,
|
|
232
|
+
"jotai": 5,
|
|
233
|
+
"pinia": 5,
|
|
234
|
+
"valtio": 5,
|
|
235
|
+
"xstate": 5,
|
|
236
|
+
// Styling
|
|
237
|
+
"tailwind": 5,
|
|
238
|
+
"unocss": 5,
|
|
239
|
+
'styled-components': 5,
|
|
240
|
+
"emotion": 5,
|
|
241
|
+
'vanilla-extract': 5,
|
|
242
|
+
// Component library
|
|
243
|
+
'shadcn/ui': 6,
|
|
244
|
+
"mui": 6,
|
|
245
|
+
"antd": 6,
|
|
246
|
+
"chakra": 6,
|
|
247
|
+
"mantine": 6,
|
|
248
|
+
'headless-ui': 6,
|
|
249
|
+
'element-plus': 6,
|
|
250
|
+
'arco-design': 6,
|
|
251
|
+
'naive-ui': 6,
|
|
252
|
+
"vuetify": 6,
|
|
253
|
+
// Data / network
|
|
254
|
+
'vercel-ai': 7,
|
|
255
|
+
"langchain": 7,
|
|
256
|
+
'tanstack-query': 7,
|
|
257
|
+
"swr": 7,
|
|
258
|
+
"apollo": 7,
|
|
259
|
+
"trpc": 7,
|
|
260
|
+
"graphql": 7,
|
|
261
|
+
"drizzle": 7,
|
|
262
|
+
"prisma": 7,
|
|
263
|
+
"mongoose": 7,
|
|
264
|
+
"supabase": 7,
|
|
265
|
+
"firebase": 7,
|
|
266
|
+
"dexie": 7,
|
|
267
|
+
// Testing
|
|
268
|
+
"vitest": 8,
|
|
269
|
+
"jest": 8,
|
|
270
|
+
"playwright": 8,
|
|
271
|
+
"cypress": 8,
|
|
272
|
+
// Package manager (detected separately, always first)
|
|
273
|
+
"bun": -1,
|
|
274
|
+
"pnpm": -1,
|
|
275
|
+
"yarn": -1,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function detectStack(pkg: any, packageManager: string): string[] {
|
|
279
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
280
|
+
const keys = Object.keys(allDeps)
|
|
281
|
+
const result: string[] = []
|
|
282
|
+
|
|
283
|
+
if (packageManager)
|
|
284
|
+
result.push(packageManager)
|
|
285
|
+
|
|
286
|
+
for (const dep of keys) {
|
|
287
|
+
if (!STACK_INCLUDE.has(dep))
|
|
288
|
+
continue
|
|
289
|
+
const collapse = STACK_COLLAPSE.find(c => dep.startsWith(c.prefix))
|
|
290
|
+
const label = collapse ? collapse.label : (STACK_RENAME[dep] ?? dep)
|
|
291
|
+
if (!result.includes(label))
|
|
292
|
+
result.push(label)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return result.sort((a, b) => (STACK_PRIORITY[a] ?? 7) - (STACK_PRIORITY[b] ?? 7))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Non-code compressors ─────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/** Probe lock files next to package.json to name the package manager. */
|
|
301
|
+
function detectPackageManager(filePath: string): string {
|
|
302
|
+
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : './'
|
|
303
|
+
if (existsSync(`${dir}bun.lock`))
|
|
304
|
+
return 'bun'
|
|
305
|
+
if (existsSync(`${dir}pnpm-lock.yaml`))
|
|
306
|
+
return 'pnpm'
|
|
307
|
+
if (existsSync(`${dir}yarn.lock`))
|
|
308
|
+
return 'yarn'
|
|
309
|
+
return ''
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function compactPackageJson(content: string, filePath: string, packageManager = detectPackageManager(filePath)): string {
|
|
313
|
+
try {
|
|
314
|
+
const pkg = JSON.parse(content)
|
|
315
|
+
const parts: string[] = []
|
|
316
|
+
const stack = detectStack(pkg, packageManager)
|
|
317
|
+
if (stack.length)
|
|
318
|
+
parts.push(`stack: ${stack.join(', ')}`)
|
|
319
|
+
if (pkg.scripts)
|
|
320
|
+
parts.push(`scripts: ${Object.keys(pkg.scripts).join(', ')}`)
|
|
321
|
+
// Structural metadata
|
|
322
|
+
const meta: string[] = []
|
|
323
|
+
if (pkg.type)
|
|
324
|
+
meta.push(`type: ${pkg.type}`)
|
|
325
|
+
if (pkg.main)
|
|
326
|
+
meta.push(`main: ${pkg.main}`)
|
|
327
|
+
if (pkg.workspaces) {
|
|
328
|
+
const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? []
|
|
329
|
+
if (ws.length)
|
|
330
|
+
meta.push(`workspaces: ${ws.join(', ')}`)
|
|
331
|
+
}
|
|
332
|
+
if (meta.length)
|
|
333
|
+
parts.splice(1, 0, meta.join(', '))
|
|
334
|
+
return parts.join('\n') || ''
|
|
335
|
+
}
|
|
336
|
+
catch { return '' }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Strip // line comments and block comments from JSONC. */
|
|
340
|
+
export function stripJsonComments(src: string): string {
|
|
341
|
+
let out = ''
|
|
342
|
+
for (let i = 0; i < src.length; i++) {
|
|
343
|
+
if (src[i] === '/' && src[i + 1] === '/') {
|
|
344
|
+
i += 2
|
|
345
|
+
while (i < src.length && src[i] !== '\n') i++
|
|
346
|
+
i-- // keep the newline (loop's i++ re-adds it)
|
|
347
|
+
}
|
|
348
|
+
else if (src[i] === '/' && src[i + 1] === '*') {
|
|
349
|
+
i += 2
|
|
350
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++
|
|
351
|
+
i++ // skip the closing '/'
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
out += src[i]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function compactTsconfig(content: string): string {
|
|
361
|
+
try {
|
|
362
|
+
// strip comments (// and /* */) before parsing
|
|
363
|
+
const stripped = stripJsonComments(content)
|
|
364
|
+
const cfg = JSON.parse(stripped)
|
|
365
|
+
const co = cfg.compilerOptions ?? {}
|
|
366
|
+
const parts: string[] = []
|
|
367
|
+
if (co.target)
|
|
368
|
+
parts.push(`target: ${co.target}`)
|
|
369
|
+
if (co.jsx)
|
|
370
|
+
parts.push(`jsx: ${co.jsx}`)
|
|
371
|
+
if (co.paths) {
|
|
372
|
+
const aliases = Object.entries(co.paths as Record<string, string[]>)
|
|
373
|
+
.map(([k, v]) => `"${k}": "${v[0] ?? ''}"`)
|
|
374
|
+
.join(', ')
|
|
375
|
+
parts.push(`paths: { ${aliases} }`)
|
|
376
|
+
}
|
|
377
|
+
if (cfg.extends)
|
|
378
|
+
parts.push(`extends: ${cfg.extends}`)
|
|
379
|
+
if (cfg.include)
|
|
380
|
+
parts.push(`include: ${cfg.include.join(', ')}`)
|
|
381
|
+
return parts.join(', ') || ''
|
|
382
|
+
}
|
|
383
|
+
catch { return '' }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** YAML: extract top-level keys and first-level sub-keys (jobs, services, on, …) */
|
|
387
|
+
export function compactYaml(content: string): string {
|
|
388
|
+
let doc: unknown
|
|
389
|
+
try {
|
|
390
|
+
doc = parseYaml(content)
|
|
391
|
+
}
|
|
392
|
+
catch { return '' }
|
|
393
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
394
|
+
return ''
|
|
395
|
+
const result: string[] = []
|
|
396
|
+
for (const [key, val] of Object.entries(doc as Record<string, unknown>)) {
|
|
397
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
398
|
+
const sub = Object.keys(val).slice(0, 12).join(', ')
|
|
399
|
+
result.push(sub ? `${key}: ${sub}` : key)
|
|
400
|
+
}
|
|
401
|
+
else if (Array.isArray(val)) {
|
|
402
|
+
result.push(`${key}: ${(val as unknown[]).slice(0, 8).join(', ')}`)
|
|
403
|
+
}
|
|
404
|
+
else if (val != null) {
|
|
405
|
+
result.push(`${key}: ${val}`)
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
result.push(key)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return result.join('\n')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Dockerfile: extract FROM / WORKDIR / EXPOSE / ARG / CMD / ENTRYPOINT */
|
|
415
|
+
export function compactDockerfile(content: string): string {
|
|
416
|
+
const KEEP = new Set(['FROM', 'WORKDIR', 'EXPOSE', 'CMD', 'ENTRYPOINT', 'ARG'])
|
|
417
|
+
const parts: string[] = []
|
|
418
|
+
let buf = ''
|
|
419
|
+
for (const raw of content.split('\n')) {
|
|
420
|
+
const line = raw.trim()
|
|
421
|
+
if (!line || line.startsWith('#'))
|
|
422
|
+
continue
|
|
423
|
+
if (line.endsWith('\\')) {
|
|
424
|
+
buf += `${line.slice(0, -1)} `
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
const full = (buf + line).trim()
|
|
428
|
+
buf = ''
|
|
429
|
+
const spaceIdx = full.indexOf(' ')
|
|
430
|
+
if (spaceIdx === -1)
|
|
431
|
+
continue
|
|
432
|
+
const cmd = full.slice(0, spaceIdx).toUpperCase()
|
|
433
|
+
const arg = full.slice(spaceIdx + 1).trim()
|
|
434
|
+
if (!KEEP.has(cmd))
|
|
435
|
+
continue
|
|
436
|
+
const val = cmd === 'ARG' ? arg.slice(0, arg.includes('=') ? arg.indexOf('=') : undefined) : arg
|
|
437
|
+
parts.push(`${cmd} ${val}`)
|
|
438
|
+
}
|
|
439
|
+
return parts.join('\n')
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** CSS/SCSS/LESS: extract CSS custom property names via PostCSS AST */
|
|
443
|
+
export function compactCss(content: string, filename: string): string {
|
|
444
|
+
const syntax = filename.endsWith('.scss')
|
|
445
|
+
? postcssScss
|
|
446
|
+
: filename.endsWith('.less')
|
|
447
|
+
? postcssLess
|
|
448
|
+
: undefined
|
|
449
|
+
try {
|
|
450
|
+
const root = syntax
|
|
451
|
+
? postcss().process(content, { syntax }).root
|
|
452
|
+
: postcss.parse(content)
|
|
453
|
+
const seen = new Set<string>()
|
|
454
|
+
root.walkDecls((decl) => {
|
|
455
|
+
if (decl.prop.startsWith('--'))
|
|
456
|
+
seen.add(decl.prop)
|
|
457
|
+
})
|
|
458
|
+
return [...seen].join(', ')
|
|
459
|
+
}
|
|
460
|
+
catch { return '' }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** .env: keep variable names, strip values */
|
|
464
|
+
export function compactEnv(content: string): string {
|
|
465
|
+
const keys = content.split('\n')
|
|
466
|
+
.map(l => l.trim())
|
|
467
|
+
.filter(l => l && !l.startsWith('#') && l.includes('='))
|
|
468
|
+
.map(l => l.slice(0, l.indexOf('=')))
|
|
469
|
+
return keys.length ? keys.join(', ') : ''
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** HTML: extract title and entry script, skip boilerplate (favicon, viewport, …) */
|
|
473
|
+
export function compactHtml(content: string): string {
|
|
474
|
+
const root = parseHtml(content)
|
|
475
|
+
const parts: string[] = []
|
|
476
|
+
const title = root.querySelector('title')
|
|
477
|
+
if (title?.text.trim())
|
|
478
|
+
parts.push(`title: ${title.text.trim()}`)
|
|
479
|
+
const SCRIPT_EXTS = ['.tsx', '.ts', '.jsx', '.js', '.mjs']
|
|
480
|
+
const entry = root.querySelectorAll('script[src]')
|
|
481
|
+
.map(n => n.getAttribute('src')!)
|
|
482
|
+
.find(src => SCRIPT_EXTS.some(ext => src.endsWith(ext)))
|
|
483
|
+
if (entry)
|
|
484
|
+
parts.push(`entry: ${entry}`)
|
|
485
|
+
return parts.join(', ') || ''
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Markdown: extract h1/h2/h3 headings as structural outline (AST-based, ignores code blocks) */
|
|
489
|
+
export function compactMarkdown(content: string): string {
|
|
490
|
+
const tree = fromMarkdown(content)
|
|
491
|
+
return tree.children
|
|
492
|
+
.filter(n => n.type === 'heading' && n.depth <= 3)
|
|
493
|
+
.map((n) => {
|
|
494
|
+
const text = (n as import('mdast').Heading).children.map(c => ('value' in c ? c.value : '')).join('')
|
|
495
|
+
return `${'#'.repeat((n as import('mdast').Heading).depth)} ${text}`
|
|
496
|
+
})
|
|
497
|
+
.join('\n')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const NON_CODE_COMPRESSORS: Array<{ match: (filename: string) => boolean, compress: (content: string, path: string) => string }> = [
|
|
501
|
+
{ match: f => f === 'package.json', compress: compactPackageJson },
|
|
502
|
+
{ match: f => f.startsWith('tsconfig') && f.endsWith('.json'), compress: c => compactTsconfig(c) },
|
|
503
|
+
{ match: f => f === '.env' || f.startsWith('.env.'), compress: c => compactEnv(c) },
|
|
504
|
+
{ match: f => f.endsWith('.html'), compress: c => compactHtml(c) },
|
|
505
|
+
{ match: f => f.endsWith('.yml') || f.endsWith('.yaml'), compress: c => compactYaml(c) },
|
|
506
|
+
{ match: f => f === 'Dockerfile' || f.startsWith('Dockerfile.'), compress: c => compactDockerfile(c) },
|
|
507
|
+
{ match: f => f.endsWith('.css') || f.endsWith('.scss') || f.endsWith('.less'), compress: (c, p) => compactCss(c, p.slice(p.lastIndexOf('/') + 1)) },
|
|
508
|
+
{ match: f => f.endsWith('.md'), compress: c => compactMarkdown(c) },
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
const isWs = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v'
|
|
512
|
+
const isDigit = (c: string) => c >= '0' && c <= '9'
|
|
513
|
+
|
|
514
|
+
/** Find first `port:` (optional whitespace) followed by digits. */
|
|
515
|
+
export function extractPort(src: string): string {
|
|
516
|
+
let from = 0
|
|
517
|
+
for (;;) {
|
|
518
|
+
const idx = src.indexOf('port', from)
|
|
519
|
+
if (idx === -1)
|
|
520
|
+
return ''
|
|
521
|
+
from = idx + 4
|
|
522
|
+
let i = from
|
|
523
|
+
while (i < src.length && isWs(src[i])) i++
|
|
524
|
+
if (src[i] !== ':')
|
|
525
|
+
continue
|
|
526
|
+
i++
|
|
527
|
+
while (i < src.length && isWs(src[i])) i++
|
|
528
|
+
let n = 0
|
|
529
|
+
while (i + n < src.length && isDigit(src[i + n])) n++
|
|
530
|
+
if (n)
|
|
531
|
+
return src.slice(i, i + n)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Find `alias:` `{...}` block, return quoted keys (text before each `:`). */
|
|
536
|
+
export function extractAliasKeys(src: string): string[] {
|
|
537
|
+
let from = 0
|
|
538
|
+
for (;;) {
|
|
539
|
+
const idx = src.indexOf('alias', from)
|
|
540
|
+
if (idx === -1)
|
|
541
|
+
return []
|
|
542
|
+
from = idx + 5
|
|
543
|
+
let i = from
|
|
544
|
+
while (i < src.length && isWs(src[i])) i++
|
|
545
|
+
if (src[i] !== ':')
|
|
546
|
+
continue
|
|
547
|
+
i++
|
|
548
|
+
while (i < src.length && isWs(src[i])) i++
|
|
549
|
+
if (src[i] !== '{')
|
|
550
|
+
continue
|
|
551
|
+
const close = src.indexOf('}', i)
|
|
552
|
+
if (close === -1)
|
|
553
|
+
continue
|
|
554
|
+
return extractQuotedKeys(src.slice(i + 1, close))
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Inside a block body, collect quoted strings immediately followed by `:`. */
|
|
559
|
+
function extractQuotedKeys(body: string): string[] {
|
|
560
|
+
const keys: string[] = []
|
|
561
|
+
for (let i = 0; i < body.length; i++) {
|
|
562
|
+
const q = body[i]
|
|
563
|
+
if (q !== '\'' && q !== '"')
|
|
564
|
+
continue
|
|
565
|
+
const end = body.indexOf(q, i + 1)
|
|
566
|
+
if (end === -1)
|
|
567
|
+
break
|
|
568
|
+
const key = body.slice(i + 1, end)
|
|
569
|
+
let j = end + 1
|
|
570
|
+
while (j < body.length && isWs(body[j])) j++
|
|
571
|
+
if (body[j] === ':')
|
|
572
|
+
keys.push(key)
|
|
573
|
+
i = end
|
|
574
|
+
}
|
|
575
|
+
return keys
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export async function buildBlocks(
|
|
579
|
+
paths: string[],
|
|
580
|
+
config: Config,
|
|
581
|
+
wasmDir?: string,
|
|
582
|
+
classifyFn?: (path: string) => FileDetailLevel,
|
|
583
|
+
contents?: Map<string, string>,
|
|
584
|
+
): Promise<FileBlock[]> {
|
|
585
|
+
await Parser.init()
|
|
586
|
+
const parser = new Parser()
|
|
587
|
+
const classifyPath = classifyFn ?? buildClassifier(config.read.fileDetailLevel)
|
|
588
|
+
const blocks: FileBlock[] = []
|
|
589
|
+
let currentLang: Awaited<ReturnType<typeof getLang>> = null
|
|
590
|
+
|
|
591
|
+
for (const path of paths) {
|
|
592
|
+
const level = classifyPath(path)
|
|
593
|
+
|
|
594
|
+
if (level === 'tree') {
|
|
595
|
+
blocks.push({ path, level, content: '' })
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const raw = contents?.get(path) ?? readFileSync(path, 'utf-8')
|
|
600
|
+
if (!raw.trim())
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
if (level === 'full') {
|
|
604
|
+
blocks.push({ path, level, content: raw })
|
|
605
|
+
continue
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// compact: load language once per extension, parse once, reuse tree
|
|
609
|
+
const ext = path.slice(path.lastIndexOf('.'))
|
|
610
|
+
const lang = await getLang(ext, wasmDir)
|
|
611
|
+
if (lang && lang !== currentLang) {
|
|
612
|
+
parser.setLanguage(lang)
|
|
613
|
+
currentLang = lang
|
|
614
|
+
}
|
|
615
|
+
let tree: Tree | undefined
|
|
616
|
+
if (lang) {
|
|
617
|
+
tree = parser.parse(raw) ?? undefined
|
|
618
|
+
}
|
|
619
|
+
const imports = lang ? extractImports(raw, parser, lang, tree) : undefined
|
|
620
|
+
|
|
621
|
+
// inline compact content
|
|
622
|
+
const filename = path.slice(path.lastIndexOf('/') + 1)
|
|
623
|
+
let content: string
|
|
624
|
+
|
|
625
|
+
if (filename.startsWith('vite.config.') && lang) {
|
|
626
|
+
const plugins = (imports ?? [])
|
|
627
|
+
.filter(p => !p.startsWith('node:') && !p.startsWith('.')
|
|
628
|
+
&& (p.includes('plugin') || p.startsWith('@vitejs/')))
|
|
629
|
+
const info: string[] = []
|
|
630
|
+
if (plugins.length)
|
|
631
|
+
info.push(`plugins: ${plugins.join(', ')}`)
|
|
632
|
+
// Extract server.port
|
|
633
|
+
const port = extractPort(raw)
|
|
634
|
+
if (port)
|
|
635
|
+
info.push(`port: ${port}`)
|
|
636
|
+
// Extract resolve.alias keys
|
|
637
|
+
const aliasKeys = extractAliasKeys(raw)
|
|
638
|
+
if (aliasKeys.length)
|
|
639
|
+
info.push(`alias: ${aliasKeys.join(', ')}`)
|
|
640
|
+
content = info.join(', ')
|
|
641
|
+
}
|
|
642
|
+
else if (lang) {
|
|
643
|
+
content = compact(raw, parser, lang, {
|
|
644
|
+
skipNoParamFn: !config.read.showNoParamFn,
|
|
645
|
+
}, tree)
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
const c = NON_CODE_COMPRESSORS.find(c => c.match(filename))
|
|
649
|
+
content = c ? c.compress(raw, path) : ''
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
tree?.delete()
|
|
653
|
+
|
|
654
|
+
if (content.trim())
|
|
655
|
+
blocks.push({ path, level, content, imports })
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
parser.delete()
|
|
659
|
+
return blocks
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── D4: Format ──────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
const CODE_EXTS = new Set([
|
|
665
|
+
'.ts',
|
|
666
|
+
'.tsx',
|
|
667
|
+
'.js',
|
|
668
|
+
'.jsx',
|
|
669
|
+
'.mjs',
|
|
670
|
+
'.cjs',
|
|
671
|
+
'.vue',
|
|
672
|
+
'.svelte',
|
|
673
|
+
'.py',
|
|
674
|
+
'.rb',
|
|
675
|
+
'.go',
|
|
676
|
+
'.rs',
|
|
677
|
+
'.java',
|
|
678
|
+
'.c',
|
|
679
|
+
'.cpp',
|
|
680
|
+
'.cs',
|
|
681
|
+
'.swift',
|
|
682
|
+
'.kt',
|
|
683
|
+
])
|
|
684
|
+
|
|
685
|
+
function getExt(name: string): string {
|
|
686
|
+
return name.includes('.') ? name.slice(name.lastIndexOf('.')) : '(no ext)'
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const isHex = (c: string) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
690
|
+
const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Char-class signature of a filename stem (extension stripped), run-length compressed.
|
|
694
|
+
* Used to fold machine-generated names (hash/uuid/serial/timestamp) into one pattern.
|
|
695
|
+
* Digit and hex collapse to one class H — for id-folding their distinction is noise
|
|
696
|
+
* (a3f9 vs c8e1 must share a signature). Classes: H=hex/digit, A=alpha, S=separator(-_.).
|
|
697
|
+
*/
|
|
698
|
+
function classSeq(stem: string): string {
|
|
699
|
+
let out = ''
|
|
700
|
+
let prev = ''
|
|
701
|
+
for (const c of stem) {
|
|
702
|
+
const cls = isHex(c) ? 'H' : isAlpha(c) ? 'A' : (c === '-' || c === '_' || c === '.') ? 'S' : 'X'
|
|
703
|
+
if (cls !== prev) {
|
|
704
|
+
out += cls
|
|
705
|
+
prev = cls
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return out
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Min siblings/files before a same-shape fold triggers. Below this, list individually. */
|
|
712
|
+
const HOMOGENEOUS_FOLD_MIN = 3
|
|
713
|
+
|
|
714
|
+
export interface TreeOptions {
|
|
715
|
+
/** When a Signatures section already lists every path, drop filenames here — show dir + count. */
|
|
716
|
+
skeletonOnly?: boolean
|
|
717
|
+
/** Files/dir headers that fit the token budget and stay expanded. Empty/undefined = expand all. */
|
|
718
|
+
kept?: Set<string>
|
|
719
|
+
/** Dir prefixes whose whole subtree collapsed to a `name/ (N files, M dirs)` summary line. */
|
|
720
|
+
collapsedDirs?: Set<string>
|
|
721
|
+
/** Paths the control panel references (knob/state filePaths) → marked ★ "关键" and never folded away. */
|
|
722
|
+
starred?: Set<string>
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function formatTree(paths: string[], foldThreshold = 8, opts: TreeOptions = {}): string {
|
|
726
|
+
const { skeletonOnly = false, kept, collapsedDirs, starred } = opts
|
|
727
|
+
const star = (fullPath: string) => (starred?.has(fullPath) ? ' ★' : '')
|
|
728
|
+
|
|
729
|
+
// count=1: name tracks the filename so single files show by name instead of "ext ×1"
|
|
730
|
+
interface ExtEntry { count: number, name?: string }
|
|
731
|
+
|
|
732
|
+
interface TreeNode {
|
|
733
|
+
children: Map<string, TreeNode>
|
|
734
|
+
file?: true
|
|
735
|
+
hasCode?: boolean
|
|
736
|
+
subtreeExts?: Map<string, ExtEntry>
|
|
737
|
+
subtreeCount?: number
|
|
738
|
+
subtreeDirCount?: number
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const root: TreeNode = { children: new Map() }
|
|
742
|
+
for (const path of paths) {
|
|
743
|
+
const parts = path.split('/')
|
|
744
|
+
let node = root
|
|
745
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
746
|
+
if (!node.children.has(parts[i]))
|
|
747
|
+
node.children.set(parts[i], { children: new Map() })
|
|
748
|
+
node = node.children.get(parts[i])!
|
|
749
|
+
}
|
|
750
|
+
node.children.set(parts[parts.length - 1], { children: new Map(), file: true })
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function mergeExt(exts: Map<string, ExtEntry>, ext: string, incoming: ExtEntry): void {
|
|
754
|
+
const existing = exts.get(ext)
|
|
755
|
+
if (!existing) {
|
|
756
|
+
exts.set(ext, { ...incoming })
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
const count = existing.count + incoming.count
|
|
760
|
+
// name only kept when exactly one file total — drop it once count > 1
|
|
761
|
+
exts.set(ext, { count })
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Pre-pass: annotate each dir node
|
|
765
|
+
function annotate(node: TreeNode): void {
|
|
766
|
+
const exts = new Map<string, ExtEntry>()
|
|
767
|
+
let count = 0
|
|
768
|
+
let dirCount = 0
|
|
769
|
+
let hasCode = false
|
|
770
|
+
for (const [name, child] of node.children) {
|
|
771
|
+
if (child.file) {
|
|
772
|
+
const ext = getExt(name)
|
|
773
|
+
mergeExt(exts, ext, { count: 1, name })
|
|
774
|
+
count++
|
|
775
|
+
if (CODE_EXTS.has(ext))
|
|
776
|
+
hasCode = true
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
annotate(child)
|
|
780
|
+
count += child.subtreeCount ?? 0
|
|
781
|
+
dirCount += 1 + (child.subtreeDirCount ?? 0)
|
|
782
|
+
if (child.hasCode)
|
|
783
|
+
hasCode = true
|
|
784
|
+
for (const [ext, entry] of child.subtreeExts ?? [])
|
|
785
|
+
mergeExt(exts, ext, entry)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
node.hasCode = hasCode
|
|
789
|
+
node.subtreeExts = exts
|
|
790
|
+
node.subtreeCount = count
|
|
791
|
+
node.subtreeDirCount = dirCount
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
annotate(root)
|
|
795
|
+
|
|
796
|
+
const lines: string[] = []
|
|
797
|
+
const FOLD_DIRS = new Set(['ui', 'icons'])
|
|
798
|
+
|
|
799
|
+
/** A leaf dir holds only files (no nested dirs). Its name-set is its structural signature. */
|
|
800
|
+
const leafFileNames = (n: TreeNode): string[] | null => {
|
|
801
|
+
const names: string[] = []
|
|
802
|
+
for (const [name, c] of n.children) {
|
|
803
|
+
if (!c.file)
|
|
804
|
+
return null
|
|
805
|
+
names.push(name)
|
|
806
|
+
}
|
|
807
|
+
return names
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
type Entry = [string, TreeNode]
|
|
811
|
+
|
|
812
|
+
// Fold B: among sibling leaf dirs, group by name-set (or ext-multiset) signature.
|
|
813
|
+
// A group of ≥3 isomorphic dirs collapses to one `[name]/(<files>) ×N` line.
|
|
814
|
+
function foldSiblingDirs(dirEntries: Entry[]): { folded: string[], rest: Entry[] } {
|
|
815
|
+
interface Item { entry: Entry, names: string[] }
|
|
816
|
+
const groups = new Map<string, Item[]>()
|
|
817
|
+
const rest: Entry[] = []
|
|
818
|
+
for (const [name, c] of dirEntries) {
|
|
819
|
+
const names = leafFileNames(c)
|
|
820
|
+
if (!names || !names.length) {
|
|
821
|
+
rest.push([name, c])
|
|
822
|
+
continue
|
|
823
|
+
}
|
|
824
|
+
const sorted = [...names].sort()
|
|
825
|
+
const sig = `N:${sorted.join('|')}` // exact filename set → identical [name]/index.md dirs collapse
|
|
826
|
+
groups.set(sig, [...(groups.get(sig) ?? []), { entry: [name, c], names: sorted }])
|
|
827
|
+
}
|
|
828
|
+
// Below-threshold name groups get a second chance: fold by ext-multiset (names differ, shape same).
|
|
829
|
+
const extGroups = new Map<string, Item[]>()
|
|
830
|
+
for (const [sig, arr] of [...groups]) {
|
|
831
|
+
if (arr.length >= HOMOGENEOUS_FOLD_MIN)
|
|
832
|
+
continue
|
|
833
|
+
groups.delete(sig)
|
|
834
|
+
for (const item of arr) {
|
|
835
|
+
const extSig = `E:${item.names.map(getExt).sort().join('|')}`
|
|
836
|
+
extGroups.set(extSig, [...(extGroups.get(extSig) ?? []), item])
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const folded: string[] = []
|
|
840
|
+
const emit = (arr: Item[], byName: boolean) => {
|
|
841
|
+
const sample = arr[0].names
|
|
842
|
+
const body = byName
|
|
843
|
+
? sample.join(', ')
|
|
844
|
+
: [...new Set(sample.map(getExt))].map((e) => {
|
|
845
|
+
const n = sample.filter(name => getExt(name) === e).length
|
|
846
|
+
return n > 1 ? `${e} ×${n}` : e
|
|
847
|
+
}).join(', ')
|
|
848
|
+
folded.push(`[name]/ (${body}) ×${arr.length}`)
|
|
849
|
+
}
|
|
850
|
+
for (const arr of groups.values()) {
|
|
851
|
+
if (arr.length >= HOMOGENEOUS_FOLD_MIN)
|
|
852
|
+
emit(arr, true)
|
|
853
|
+
else arr.forEach(i => rest.push(i.entry))
|
|
854
|
+
}
|
|
855
|
+
for (const arr of extGroups.values()) {
|
|
856
|
+
if (arr.length >= HOMOGENEOUS_FOLD_MIN)
|
|
857
|
+
emit(arr, false)
|
|
858
|
+
else arr.forEach(i => rest.push(i.entry))
|
|
859
|
+
}
|
|
860
|
+
return { folded, rest }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Fold C: within one dir, fold ≥3 same-shape *machine-generated* filenames → `[id].ext ×N`.
|
|
864
|
+
// Only pure-id stems (hex/digit/sep, no alpha word) fold — `data0.json`/`fn1.ts` keep their
|
|
865
|
+
// names since the alpha prefix is a real navigational word. Hashes/uuids/serials lose nothing.
|
|
866
|
+
function foldHomogeneousFiles(fileEntries: Entry[]): { folded: string[], rest: Entry[] } {
|
|
867
|
+
const stemOf = (name: string) => {
|
|
868
|
+
const ext = getExt(name)
|
|
869
|
+
return ext === '(no ext)' ? name : name.slice(0, name.length - ext.length)
|
|
870
|
+
}
|
|
871
|
+
const isPureId = (stem: string) => stem.length > 0
|
|
872
|
+
&& [...stem].every(c => isHex(c) || c === '-' || c === '_')
|
|
873
|
+
const groups = new Map<string, Entry[]>()
|
|
874
|
+
const rest: Entry[] = []
|
|
875
|
+
for (const [name, c] of fileEntries) {
|
|
876
|
+
const stem = stemOf(name)
|
|
877
|
+
if (!isPureId(stem)) {
|
|
878
|
+
rest.push([name, c])
|
|
879
|
+
continue
|
|
880
|
+
}
|
|
881
|
+
const key = `${classSeq(stem)}|${getExt(name)}` // a3f9.json & c8e1.json fold; log.txt stays separate
|
|
882
|
+
groups.set(key, [...(groups.get(key) ?? []), [name, c]])
|
|
883
|
+
}
|
|
884
|
+
const folded: string[] = []
|
|
885
|
+
for (const entries of groups.values()) {
|
|
886
|
+
if (entries.length >= HOMOGENEOUS_FOLD_MIN) {
|
|
887
|
+
const ext = getExt(entries[0][0])
|
|
888
|
+
folded.push(`[id]${ext === '(no ext)' ? '' : ext} ×${entries.length}`)
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
rest.push(...entries)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return { folded, rest }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function walk(node: TreeNode, prefix: string, dirName?: string, dirPath = '') {
|
|
898
|
+
let rawEntries = Array.from(node.children.entries())
|
|
899
|
+
|
|
900
|
+
// Budget collapse: when `kept` is set, un-kept files in this (kept) dir don't list
|
|
901
|
+
// individually — they fold into a single `.ext ×N` summary. Dirs are untouched here;
|
|
902
|
+
// a fully-collapsed dir is summarized by emitItems via `collapsedDirs`.
|
|
903
|
+
const unkeptExtLines: string[] = []
|
|
904
|
+
if (kept?.size) {
|
|
905
|
+
const unkept = rawEntries.filter(([name, c]) => c.file && !kept.has(dirPath ? `${dirPath}/${name}` : name))
|
|
906
|
+
if (unkept.length) {
|
|
907
|
+
rawEntries = rawEntries.filter(([name, c]) => !(c.file && !kept.has(dirPath ? `${dirPath}/${name}` : name)))
|
|
908
|
+
const extCounts = new Map<string, number>()
|
|
909
|
+
for (const [name] of unkept) extCounts.set(getExt(name), (extCounts.get(getExt(name)) ?? 0) + 1)
|
|
910
|
+
for (const [ext, n] of [...extCounts.entries()].sort((a, b) => b[1] - a[1]))
|
|
911
|
+
unkeptExtLines.push(`${ext} ×${n}`)
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Code items: code files + dirs that contain any code (shown individually)
|
|
916
|
+
// Non-code items: data files + data-only dirs (folded when count exceeds threshold)
|
|
917
|
+
const codeItems = rawEntries.filter(([name, c]) => c.file ? CODE_EXTS.has(getExt(name)) : !!c.hasCode)
|
|
918
|
+
const nonCodeItems = rawEntries.filter(([name, c]) => c.file ? !CODE_EXTS.has(getExt(name)) : !c.hasCode)
|
|
919
|
+
|
|
920
|
+
// Count non-code items: each item = 1 (dirs count as 1, not their subtreeCount)
|
|
921
|
+
// This preserves dir names at the parent level — a dir only folds inside itself
|
|
922
|
+
const nonCodeItemCount = nonCodeItems.length
|
|
923
|
+
|
|
924
|
+
const items: Item[] = []
|
|
925
|
+
|
|
926
|
+
// Fold E: a Signatures section already lists every file path, so the tree only needs the
|
|
927
|
+
// dir skeleton + a file count. Recurse into subdirs; collapse direct files to "N files".
|
|
928
|
+
// A leaf dir (only files, no subdirs) renders inline as "Name/ N files".
|
|
929
|
+
if (skeletonOnly) {
|
|
930
|
+
const dirs = rawEntries.filter(([, c]) => !c.file)
|
|
931
|
+
const fileCount = rawEntries.length - dirs.length
|
|
932
|
+
const branchDirs = dirs.filter(([, c]) => [...c.children.values()].some(ch => !ch.file))
|
|
933
|
+
const leafDirs = dirs.filter(([, c]) => ![...c.children.values()].some(ch => !ch.file))
|
|
934
|
+
const { folded, rest } = foldSiblingDirs(leafDirs)
|
|
935
|
+
for (const [name, c] of rest)
|
|
936
|
+
items.push({ display: `${name}/ ${c.children.size} file${c.children.size > 1 ? 's' : ''}` })
|
|
937
|
+
for (const d of folded) items.push({ display: d })
|
|
938
|
+
for (const [name, c] of branchDirs) items.push({ display: name, child: c })
|
|
939
|
+
if (fileCount > 0)
|
|
940
|
+
items.push({ display: `${fileCount} file${fileCount > 1 ? 's' : ''}` })
|
|
941
|
+
return emitItems(items, prefix)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Fold code files in well-known homogeneous directories (e.g. shadcn ui/, icons/)
|
|
945
|
+
const codeFiles = codeItems.filter(([, c]) => c.file)
|
|
946
|
+
const codeDirs = codeItems.filter(([, c]) => !c.file)
|
|
947
|
+
if (dirName && FOLD_DIRS.has(dirName) && codeFiles.length > 1 && codeDirs.length === 0) {
|
|
948
|
+
const ext = getExt(codeFiles[0][0])
|
|
949
|
+
const allSameExt = codeFiles.every(([name]) => getExt(name) === ext)
|
|
950
|
+
if (allSameExt) {
|
|
951
|
+
items.push({ display: `${ext} ×${codeFiles.length}` })
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
const extCounts = new Map<string, number>()
|
|
955
|
+
for (const [name] of codeFiles) {
|
|
956
|
+
const e = getExt(name)
|
|
957
|
+
extCounts.set(e, (extCounts.get(e) ?? 0) + 1)
|
|
958
|
+
}
|
|
959
|
+
for (const [ext, count] of [...extCounts.entries()].sort((a, b) => b[1] - a[1]))
|
|
960
|
+
items.push({ display: `${ext} ×${count}` })
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
// Fold B: collapse ≥3 isomorphic sibling code dirs into one [name]/ pattern line.
|
|
965
|
+
const { folded: foldedDirs, rest: restDirs } = foldSiblingDirs(codeDirs)
|
|
966
|
+
// Fold C: collapse ≥3 same-shape code filenames (hash/serial names) into [id].ext.
|
|
967
|
+
const { folded: foldedFiles, rest: restFiles } = foldHomogeneousFiles(codeFiles)
|
|
968
|
+
for (const [name] of restFiles)
|
|
969
|
+
items.push({ display: name, child: undefined })
|
|
970
|
+
for (const [name, c] of restDirs)
|
|
971
|
+
items.push({ display: name, child: c })
|
|
972
|
+
for (const d of foldedFiles) items.push({ display: d })
|
|
973
|
+
for (const d of foldedDirs) items.push({ display: d })
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (nonCodeItemCount > foldThreshold) {
|
|
977
|
+
// Absorb all non-code items into extension summary
|
|
978
|
+
const extCounts = new Map<string, ExtEntry>()
|
|
979
|
+
for (const [name, c] of nonCodeItems) {
|
|
980
|
+
if (c.file) {
|
|
981
|
+
mergeExt(extCounts, getExt(name), { count: 1, name })
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
for (const [ext, entry] of c.subtreeExts ?? []) mergeExt(extCounts, ext, entry)
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
// count=1 with known name → show filename; count≥2 → show "ext ×N"
|
|
988
|
+
for (const [ext, entry] of [...extCounts.entries()].sort((a, b) => b[1].count - a[1].count))
|
|
989
|
+
items.push({ display: entry.count === 1 && entry.name ? entry.name : `${ext} ×${entry.count}` })
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
// Fold B/C also apply to non-code dirs/files (e.g. knowledge/[name]/index.md, Logs/[id].json).
|
|
993
|
+
const ncDirs = nonCodeItems.filter(([, c]) => !c.file)
|
|
994
|
+
const ncFiles = nonCodeItems.filter(([, c]) => c.file)
|
|
995
|
+
const { folded: foldedDirs, rest: restDirs } = foldSiblingDirs(ncDirs)
|
|
996
|
+
const { folded: foldedFiles, rest: restFiles } = foldHomogeneousFiles(ncFiles)
|
|
997
|
+
for (const [name] of restFiles)
|
|
998
|
+
items.push({ display: name, child: undefined })
|
|
999
|
+
for (const [name, c] of restDirs)
|
|
1000
|
+
items.push({ display: name, child: c })
|
|
1001
|
+
for (const d of foldedFiles) items.push({ display: d })
|
|
1002
|
+
for (const d of foldedDirs) items.push({ display: d })
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
for (const line of unkeptExtLines)
|
|
1006
|
+
items.push({ display: line })
|
|
1007
|
+
|
|
1008
|
+
return emitItems(items, prefix, dirPath)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
interface Item { display: string, child?: TreeNode }
|
|
1012
|
+
function emitItems(items: Item[], prefix: string, dirPath = ''): void {
|
|
1013
|
+
for (const { display, child } of items) {
|
|
1014
|
+
if (child) {
|
|
1015
|
+
// Budget collapse: a dir whose whole subtree didn't fit renders as a one-line
|
|
1016
|
+
// `name/ (N files, M dirs)` summary and is not recursed into.
|
|
1017
|
+
const childPath = dirPath ? `${dirPath}/${display}` : display
|
|
1018
|
+
if (collapsedDirs?.has(childPath)) {
|
|
1019
|
+
const files = child.subtreeCount ?? 0
|
|
1020
|
+
const dirs = child.subtreeDirCount ?? 0
|
|
1021
|
+
lines.push(`${prefix}${display}/ (${files} file${files === 1 ? '' : 's'}, ${dirs} dir${dirs === 1 ? '' : 's'})`)
|
|
1022
|
+
continue
|
|
1023
|
+
}
|
|
1024
|
+
// Fold A: collapse single-child dir chains — a/ → b/ → file becomes a/b/file.
|
|
1025
|
+
let label = display
|
|
1026
|
+
let cur = child
|
|
1027
|
+
let fileLeaf = false
|
|
1028
|
+
while (cur.children.size === 1) {
|
|
1029
|
+
const [onlyName, onlyChild] = [...cur.children][0]
|
|
1030
|
+
label += `/${onlyName}`
|
|
1031
|
+
if (onlyChild.file) {
|
|
1032
|
+
lines.push(`${prefix}${label}${star(dirPath ? `${dirPath}/${label}` : label)}`)
|
|
1033
|
+
fileLeaf = true
|
|
1034
|
+
break
|
|
1035
|
+
}
|
|
1036
|
+
cur = onlyChild
|
|
1037
|
+
}
|
|
1038
|
+
if (fileLeaf)
|
|
1039
|
+
continue
|
|
1040
|
+
lines.push(`${prefix}${label}/`)
|
|
1041
|
+
walk(cur, `${prefix} `, label.slice(label.lastIndexOf('/') + 1), dirPath ? `${dirPath}/${label}` : label)
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
lines.push(`${prefix}${display}${star(dirPath ? `${dirPath}/${display}` : display)}`)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
walk(root, '')
|
|
1050
|
+
return lines.join('\n')
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// 关键文件版图:tree 只显示 starred(控制面触碰的)文件 + 它们所在的目录骨架。
|
|
1054
|
+
// 非 ★ 的叶子/子树整块折成 `… N more`,让 ★ 不再被全量噪音稀释。与 formatTree 的全量折叠
|
|
1055
|
+
// 模式正交(一个答"全部物理布局",一个答"哪些文件被控制面触碰"),故独立函数不耦合进 walk。
|
|
1056
|
+
export function formatStarredTree(paths: string[], starred: Set<string>): string {
|
|
1057
|
+
interface Node { children: Map<string, Node>, file?: true, fullPath?: string }
|
|
1058
|
+
const root: Node = { children: new Map() }
|
|
1059
|
+
for (const path of paths) {
|
|
1060
|
+
const parts = path.split('/')
|
|
1061
|
+
let node = root
|
|
1062
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1063
|
+
const isLeaf = i === parts.length - 1
|
|
1064
|
+
if (!node.children.has(parts[i]))
|
|
1065
|
+
node.children.set(parts[i], { children: new Map() })
|
|
1066
|
+
node = node.children.get(parts[i])!
|
|
1067
|
+
if (isLeaf) { node.file = true; node.fullPath = path }
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// 子树是否含 ★(决定是否展开此目录)。
|
|
1072
|
+
const hasStar = (n: Node): boolean => {
|
|
1073
|
+
if (n.file) return !!(n.fullPath && starred.has(n.fullPath))
|
|
1074
|
+
for (const c of n.children.values()) if (hasStar(c)) return true
|
|
1075
|
+
return false
|
|
1076
|
+
}
|
|
1077
|
+
// 子树里非 ★ 的文件总数(折成 `… N more` 的计数,全子树深算,诚实)。
|
|
1078
|
+
const countNonStar = (n: Node): number => {
|
|
1079
|
+
if (n.file) return n.fullPath && starred.has(n.fullPath) ? 0 : 1
|
|
1080
|
+
let s = 0
|
|
1081
|
+
for (const c of n.children.values()) s += countNonStar(c)
|
|
1082
|
+
return s
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const lines: string[] = []
|
|
1086
|
+
const walk = (node: Node, prefix: string) => {
|
|
1087
|
+
const entries = [...node.children.entries()]
|
|
1088
|
+
const starDirs = entries.filter(([, c]) => !c.file && hasStar(c))
|
|
1089
|
+
const starFiles = entries.filter(([, c]) => c.file && hasStar(c))
|
|
1090
|
+
// 此目录下被折叠掉的非★文件:直属非★文件 + 不含★的整个子目录里的文件。
|
|
1091
|
+
let hidden = 0
|
|
1092
|
+
for (const [, c] of entries)
|
|
1093
|
+
if (!hasStar(c)) hidden += countNonStar(c)
|
|
1094
|
+
|
|
1095
|
+
for (const [name, c] of starFiles)
|
|
1096
|
+
lines.push(`${prefix}${name} ★`)
|
|
1097
|
+
for (const [name, c] of starDirs) {
|
|
1098
|
+
lines.push(`${prefix}${name}/`)
|
|
1099
|
+
walk(c, `${prefix} `)
|
|
1100
|
+
}
|
|
1101
|
+
if (hidden > 0)
|
|
1102
|
+
lines.push(`${prefix}… ${hidden} more`)
|
|
1103
|
+
}
|
|
1104
|
+
walk(root, '')
|
|
1105
|
+
return lines.join('\n')
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function formatXml(blocks: FileBlock[], levels?: Map<string, FileDetailLevel>): string {
|
|
1109
|
+
return blocks
|
|
1110
|
+
.filter((b) => {
|
|
1111
|
+
if (levels && !levels.has(b.path))
|
|
1112
|
+
return false
|
|
1113
|
+
const level = levels?.get(b.path) ?? b.level
|
|
1114
|
+
return level !== 'tree' && b.content.trim()
|
|
1115
|
+
})
|
|
1116
|
+
.map(b => `<file path="${b.path}">\n${b.content}\n</file>`)
|
|
1117
|
+
.join('\n\n')
|
|
1118
|
+
}
|