@muyichengshayu/promptx 0.1.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.
@@ -0,0 +1,662 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const IGNORED_DIRECTORY_NAMES = new Set([
6
+ '.git',
7
+ '.hg',
8
+ '.svn',
9
+ '.next',
10
+ '.nuxt',
11
+ '.output',
12
+ '.turbo',
13
+ '.cache',
14
+ 'node_modules',
15
+ 'dist',
16
+ 'build',
17
+ 'coverage',
18
+ 'tmp',
19
+ 'temp',
20
+ 'uploads',
21
+ ])
22
+
23
+ const DIRECTORY_PICKER_HIDDEN_NAMES = new Set([
24
+ 'Applications',
25
+ 'Downloads',
26
+ 'Library',
27
+ 'Movies',
28
+ 'Music',
29
+ 'Pictures',
30
+ 'Public',
31
+ ])
32
+
33
+ const DEFAULT_TREE_LIMIT = 200
34
+ const DEFAULT_SEARCH_LIMIT = 80
35
+ const MAX_SEARCH_VISITS = 20000
36
+ const DIRECTORY_PICKER_LIMIT = 240
37
+
38
+ function createHttpError(message, statusCode = 400) {
39
+ const error = new Error(message)
40
+ error.statusCode = statusCode
41
+ return error
42
+ }
43
+
44
+ function toPosixPath(value = '') {
45
+ return String(value || '').replace(/\\/g, '/')
46
+ }
47
+
48
+ function normalizeRelativePath(relativePath = '') {
49
+ const value = toPosixPath(relativePath).trim()
50
+ if (!value || value === '.') {
51
+ return ''
52
+ }
53
+
54
+ if (value.includes('\0')) {
55
+ throw createHttpError('路径不合法。')
56
+ }
57
+
58
+ const cleaned = value
59
+ .replace(/^\/+/, '')
60
+ .replace(/^\.\/+/, '')
61
+ .replace(/\/{2,}/g, '/')
62
+ .replace(/\/+$/, '')
63
+
64
+ if (!cleaned) {
65
+ return ''
66
+ }
67
+
68
+ const segments = cleaned.split('/')
69
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
70
+ throw createHttpError('路径不合法。')
71
+ }
72
+
73
+ return cleaned
74
+ }
75
+
76
+ function ensurePathInsideWorkspace(workspacePath, targetPath) {
77
+ const root = path.resolve(String(workspacePath || ''))
78
+ const target = path.resolve(String(targetPath || ''))
79
+
80
+ if (target === root) {
81
+ return target
82
+ }
83
+
84
+ if (target.startsWith(`${root}${path.sep}`)) {
85
+ return target
86
+ }
87
+
88
+ throw createHttpError('只能访问当前工作目录内的文件。', 403)
89
+ }
90
+
91
+ function resolveWorkspaceTarget(workspacePath, relativePath = '') {
92
+ const root = path.resolve(String(workspacePath || ''))
93
+ const normalizedRelativePath = normalizeRelativePath(relativePath)
94
+ const targetPath = normalizedRelativePath
95
+ ? path.resolve(root, normalizedRelativePath)
96
+ : root
97
+
98
+ return {
99
+ root,
100
+ relativePath: normalizedRelativePath,
101
+ absolutePath: ensurePathInsideWorkspace(root, targetPath),
102
+ }
103
+ }
104
+
105
+ function getPathType(absolutePath = '') {
106
+ try {
107
+ const stats = fs.statSync(absolutePath)
108
+ if (stats.isDirectory()) {
109
+ return 'directory'
110
+ }
111
+ if (stats.isFile()) {
112
+ return 'file'
113
+ }
114
+ } catch {
115
+ return ''
116
+ }
117
+
118
+ return ''
119
+ }
120
+
121
+ function shouldIgnoreDirectory(entry) {
122
+ return entry?.isDirectory?.() && IGNORED_DIRECTORY_NAMES.has(entry.name)
123
+ }
124
+
125
+ function shouldIgnorePickerDirectory(entry) {
126
+ if (!entry?.isDirectory?.()) {
127
+ return false
128
+ }
129
+
130
+ const name = String(entry.name || '').trim()
131
+ if (!name) {
132
+ return false
133
+ }
134
+
135
+ return name.startsWith('.')
136
+ || IGNORED_DIRECTORY_NAMES.has(name)
137
+ || DIRECTORY_PICKER_HIDDEN_NAMES.has(name)
138
+ }
139
+
140
+ function compareWorkspaceEntries(left, right) {
141
+ const typeDiff = Number(left.type !== 'directory') - Number(right.type !== 'directory')
142
+ if (typeDiff) {
143
+ return typeDiff
144
+ }
145
+
146
+ return String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN')
147
+ }
148
+
149
+ function compareDirectoryEntries(left, right) {
150
+ return String(left.name || left.path || '').localeCompare(String(right.name || right.path || ''), 'zh-CN')
151
+ }
152
+
153
+ function buildWorkspaceItem(workspacePath, absolutePath, entry, typeOverride = '') {
154
+ const type = typeOverride || (entry?.isDirectory?.() ? 'directory' : 'file')
155
+ const relativePath = path.relative(workspacePath, absolutePath)
156
+
157
+ return {
158
+ name: entry?.name || path.basename(absolutePath),
159
+ path: toPosixPath(relativePath),
160
+ type,
161
+ hasChildren: type === 'directory' ? directoryHasVisibleChildren(absolutePath) : false,
162
+ }
163
+ }
164
+
165
+ function directoryHasVisibleChildren(directoryPath = '') {
166
+ try {
167
+ const entries = fs.readdirSync(directoryPath, { withFileTypes: true })
168
+ return entries.some((entry) => !shouldIgnoreDirectory(entry))
169
+ } catch {
170
+ return false
171
+ }
172
+ }
173
+
174
+ function directoryHasVisiblePickerChildren(directoryPath = '') {
175
+ try {
176
+ const entries = fs.readdirSync(directoryPath, { withFileTypes: true })
177
+ return entries.some((entry) => entry.isDirectory() && !shouldIgnorePickerDirectory(entry))
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ function createDirectoryPickerItem(directoryPath = '', entryName = '') {
184
+ const normalizedPath = path.resolve(String(directoryPath || ''))
185
+ const parsed = path.parse(normalizedPath)
186
+ const isRoot = normalizedPath === parsed.root
187
+ const displayPath = normalizedPath
188
+ const displayName = entryName
189
+ || (isRoot
190
+ ? (process.platform === 'win32'
191
+ ? normalizedPath.replace(/[\\/]+$/, '')
192
+ : normalizedPath)
193
+ : path.basename(normalizedPath))
194
+
195
+ return {
196
+ name: displayName || displayPath,
197
+ path: displayPath,
198
+ type: 'directory',
199
+ hasChildren: directoryHasVisiblePickerChildren(normalizedPath),
200
+ isRoot,
201
+ }
202
+ }
203
+
204
+ function getDirectoryPickerHomePath() {
205
+ return path.resolve(os.homedir())
206
+ }
207
+
208
+ function normalizeDirectoryPickerPath(input = '') {
209
+ const value = String(input || '').trim()
210
+ if (!value) {
211
+ return ''
212
+ }
213
+
214
+ const resolved = path.resolve(value)
215
+ if (!fs.existsSync(resolved)) {
216
+ throw createHttpError('目录不存在,请重新选择。', 404)
217
+ }
218
+
219
+ const stats = fs.statSync(resolved)
220
+ if (!stats.isDirectory()) {
221
+ throw createHttpError('只能选择文件夹。')
222
+ }
223
+
224
+ return resolved
225
+ }
226
+
227
+ function getDirectoryParentPath(directoryPath = '') {
228
+ const resolved = path.resolve(String(directoryPath || ''))
229
+ const parsed = path.parse(resolved)
230
+
231
+ if (resolved === parsed.root) {
232
+ return ''
233
+ }
234
+
235
+ const parentPath = path.dirname(resolved)
236
+ return parentPath === resolved ? '' : parentPath
237
+ }
238
+
239
+ function clampLimit(value, fallback, max) {
240
+ const normalized = Number(value)
241
+ if (!Number.isFinite(normalized) || normalized <= 0) {
242
+ return fallback
243
+ }
244
+
245
+ return Math.min(Math.floor(normalized), max)
246
+ }
247
+
248
+ function removeExtension(value = '') {
249
+ const normalized = String(value || '')
250
+ const extensionIndex = normalized.lastIndexOf('.')
251
+ if (extensionIndex <= 0) {
252
+ return normalized
253
+ }
254
+
255
+ return normalized.slice(0, extensionIndex)
256
+ }
257
+
258
+ function splitSegmentWords(segment = '') {
259
+ return removeExtension(String(segment || ''))
260
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
261
+ .split(/[^a-zA-Z0-9]+/)
262
+ .map((part) => part.trim().toLowerCase())
263
+ .filter(Boolean)
264
+ }
265
+
266
+ function scoreExactPrefixSubstring(candidate = '', query = '', weights = {}) {
267
+ const source = String(candidate || '').toLowerCase()
268
+ const keyword = String(query || '').trim().toLowerCase()
269
+
270
+ if (!source || !keyword) {
271
+ return 0
272
+ }
273
+
274
+ if (source === keyword) {
275
+ return weights.exact ?? 0
276
+ }
277
+
278
+ if (source.startsWith(keyword)) {
279
+ return (weights.prefix ?? 0) - source.length
280
+ }
281
+
282
+ const substringIndex = source.indexOf(keyword)
283
+ if (substringIndex >= 0) {
284
+ return (weights.substring ?? 0) - substringIndex * 12 - Math.max(source.length - keyword.length, 0)
285
+ }
286
+
287
+ return 0
288
+ }
289
+
290
+ function scoreCompactSubsequence(candidate = '', query = '') {
291
+ const source = String(candidate || '').toLowerCase()
292
+ const keyword = String(query || '').trim().toLowerCase()
293
+
294
+ if (!source || !keyword || keyword.length < 2 || keyword.length > source.length) {
295
+ return 0
296
+ }
297
+
298
+ const positions = []
299
+ let searchIndex = 0
300
+
301
+ for (const char of keyword) {
302
+ const matchIndex = source.indexOf(char, searchIndex)
303
+ if (matchIndex < 0) {
304
+ return 0
305
+ }
306
+ positions.push(matchIndex)
307
+ searchIndex = matchIndex + 1
308
+ }
309
+
310
+ const span = positions[positions.length - 1] - positions[0] + 1
311
+ const gap = span - keyword.length
312
+ const maxGap = Math.max(2, Math.floor(keyword.length * 0.6))
313
+ const coverage = keyword.length / span
314
+
315
+ if (gap > maxGap || coverage < 0.72) {
316
+ return 0
317
+ }
318
+
319
+ return 3200 - gap * 120 - Math.max(source.length - keyword.length, 0)
320
+ }
321
+
322
+ function scorePathMatch(relativePath = '', query = '') {
323
+ const normalizedPath = toPosixPath(relativePath).toLowerCase()
324
+ const keyword = String(query || '').trim().toLowerCase()
325
+
326
+ if (!normalizedPath || !keyword) {
327
+ return 0
328
+ }
329
+
330
+ if (normalizedPath === keyword) {
331
+ return 16000
332
+ }
333
+
334
+ if (normalizedPath.startsWith(keyword)) {
335
+ return 13200 - normalizedPath.length
336
+ }
337
+
338
+ const boundaryPrefixIndex = normalizedPath.indexOf(`/${keyword}`)
339
+ if (boundaryPrefixIndex >= 0) {
340
+ return 12000 - boundaryPrefixIndex * 8 - normalizedPath.length
341
+ }
342
+
343
+ const substringIndex = normalizedPath.indexOf(keyword)
344
+ if (substringIndex >= 0) {
345
+ return 9800 - substringIndex * 12 - Math.max(normalizedPath.length - keyword.length, 0)
346
+ }
347
+
348
+ return 0
349
+ }
350
+
351
+ function scoreSegmentMatch(segment = '', query = '', options = {}) {
352
+ const keyword = String(query || '').trim().toLowerCase()
353
+ const normalizedSegment = String(segment || '').toLowerCase()
354
+ const bareSegment = removeExtension(normalizedSegment)
355
+
356
+ if (!normalizedSegment || !keyword) {
357
+ return 0
358
+ }
359
+
360
+ const exactWeights = options.isFileName
361
+ ? { exact: 15000, prefix: 12600, substring: 10600 }
362
+ : { exact: 12400, prefix: 10400, substring: 9000 }
363
+
364
+ let bestScore = Math.max(
365
+ scoreExactPrefixSubstring(normalizedSegment, keyword, exactWeights),
366
+ scoreExactPrefixSubstring(bareSegment, keyword, exactWeights)
367
+ )
368
+
369
+ const words = splitSegmentWords(segment)
370
+ if (words.length) {
371
+ const initials = words.map((word) => word[0]).join('')
372
+ const compact = words.join('')
373
+
374
+ for (const word of words) {
375
+ bestScore = Math.max(
376
+ bestScore,
377
+ scoreExactPrefixSubstring(word, keyword, {
378
+ exact: 11600,
379
+ prefix: 9800,
380
+ substring: 8200,
381
+ })
382
+ )
383
+ }
384
+
385
+ if (initials && keyword.length >= 2 && initials.startsWith(keyword)) {
386
+ bestScore = Math.max(bestScore, 6000 - initials.length * 10)
387
+ }
388
+
389
+ bestScore = Math.max(bestScore, scoreCompactSubsequence(compact || bareSegment, keyword))
390
+ } else {
391
+ bestScore = Math.max(bestScore, scoreCompactSubsequence(bareSegment || normalizedSegment, keyword))
392
+ }
393
+
394
+ return bestScore
395
+ }
396
+
397
+ function scoreWorkspaceMatch(relativePath = '', query = '') {
398
+ const normalizedPath = toPosixPath(relativePath)
399
+ const keyword = String(query || '').trim().toLowerCase()
400
+
401
+ if (!normalizedPath || !keyword) {
402
+ return 0
403
+ }
404
+
405
+ const pathScore = scorePathMatch(normalizedPath, keyword)
406
+ if (keyword.includes('/')) {
407
+ return pathScore
408
+ }
409
+
410
+ const segments = normalizedPath.split('/').filter(Boolean)
411
+ const fileName = segments.at(-1) || normalizedPath
412
+ const nameScore = scoreSegmentMatch(fileName, keyword, { isFileName: true })
413
+ const segmentScore = segments.reduce((bestScore, segment, index) => Math.max(
414
+ bestScore,
415
+ scoreSegmentMatch(segment, keyword, { isFileName: index === segments.length - 1 })
416
+ ), 0)
417
+
418
+ return Math.max(pathScore, nameScore, segmentScore)
419
+ }
420
+
421
+ export function listWorkspaceTree(workspacePath, options = {}) {
422
+ const target = resolveWorkspaceTarget(workspacePath, options.path)
423
+ const type = getPathType(target.absolutePath)
424
+
425
+ if (!type) {
426
+ throw createHttpError('目标路径不存在。', 404)
427
+ }
428
+
429
+ if (type !== 'directory') {
430
+ throw createHttpError('只能展开目录。')
431
+ }
432
+
433
+ const limit = clampLimit(options.limit, DEFAULT_TREE_LIMIT, 500)
434
+ const entries = fs.readdirSync(target.absolutePath, { withFileTypes: true })
435
+ .filter((entry) => !shouldIgnoreDirectory(entry))
436
+ .map((entry) => buildWorkspaceItem(target.root, path.join(target.absolutePath, entry.name), entry))
437
+ .sort(compareWorkspaceEntries)
438
+
439
+ return {
440
+ cwd: target.root,
441
+ path: target.relativePath,
442
+ parentPath: target.relativePath.includes('/')
443
+ ? target.relativePath.slice(0, target.relativePath.lastIndexOf('/'))
444
+ : '',
445
+ items: entries.slice(0, limit),
446
+ truncated: entries.length > limit,
447
+ }
448
+ }
449
+
450
+ export function searchWorkspaceEntries(workspacePath, options = {}) {
451
+ const root = path.resolve(String(workspacePath || ''))
452
+ const query = String(options.query || '').trim()
453
+ const limit = clampLimit(options.limit, DEFAULT_SEARCH_LIMIT, 200)
454
+
455
+ if (!query) {
456
+ return {
457
+ cwd: root,
458
+ query: '',
459
+ items: [],
460
+ truncated: false,
461
+ }
462
+ }
463
+
464
+ const matches = []
465
+ let visited = 0
466
+ let truncated = false
467
+ const stack = ['']
468
+
469
+ while (stack.length) {
470
+ const currentRelativePath = stack.pop()
471
+ const currentAbsolutePath = currentRelativePath
472
+ ? path.join(root, currentRelativePath)
473
+ : root
474
+
475
+ let entries = []
476
+ try {
477
+ entries = fs.readdirSync(currentAbsolutePath, { withFileTypes: true })
478
+ } catch {
479
+ continue
480
+ }
481
+
482
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
483
+
484
+ for (const entry of entries) {
485
+ if (shouldIgnoreDirectory(entry)) {
486
+ continue
487
+ }
488
+
489
+ visited += 1
490
+ if (visited > MAX_SEARCH_VISITS) {
491
+ truncated = true
492
+ stack.length = 0
493
+ break
494
+ }
495
+
496
+ const relativePath = currentRelativePath
497
+ ? `${toPosixPath(currentRelativePath)}/${entry.name}`
498
+ : entry.name
499
+ const absolutePath = path.join(currentAbsolutePath, entry.name)
500
+ const type = entry.isDirectory() ? 'directory' : 'file'
501
+ const score = scoreWorkspaceMatch(relativePath, query)
502
+
503
+ if (score > 0) {
504
+ matches.push({
505
+ ...buildWorkspaceItem(root, absolutePath, entry, type),
506
+ score,
507
+ })
508
+ }
509
+
510
+ if (entry.isDirectory()) {
511
+ stack.push(path.join(currentRelativePath, entry.name))
512
+ }
513
+ }
514
+ }
515
+
516
+ matches.sort((left, right) => {
517
+ const scoreDiff = right.score - left.score
518
+ if (scoreDiff) {
519
+ return scoreDiff
520
+ }
521
+
522
+ const typeDiff = Number(left.type !== 'directory') - Number(right.type !== 'directory')
523
+ if (typeDiff) {
524
+ return typeDiff
525
+ }
526
+
527
+ const pathLengthDiff = left.path.length - right.path.length
528
+ if (pathLengthDiff) {
529
+ return pathLengthDiff
530
+ }
531
+
532
+ return left.path.localeCompare(right.path, 'zh-CN')
533
+ })
534
+
535
+ return {
536
+ cwd: root,
537
+ query,
538
+ items: matches.slice(0, limit).map(({ score, ...item }) => item),
539
+ truncated: truncated || matches.length > limit,
540
+ }
541
+ }
542
+
543
+ export function listDirectoryPickerTree(options = {}) {
544
+ const targetPath = normalizeDirectoryPickerPath(options.path) || getDirectoryPickerHomePath()
545
+
546
+ const limit = clampLimit(options.limit, DIRECTORY_PICKER_LIMIT, 600)
547
+ const entries = fs.readdirSync(targetPath, { withFileTypes: true })
548
+ .filter((entry) => entry.isDirectory() && !shouldIgnorePickerDirectory(entry))
549
+ .map((entry) => createDirectoryPickerItem(path.join(targetPath, entry.name), entry.name))
550
+ .sort(compareDirectoryEntries)
551
+
552
+ return {
553
+ path: targetPath,
554
+ parentPath: '',
555
+ items: entries.slice(0, limit),
556
+ truncated: entries.length > limit,
557
+ }
558
+ }
559
+
560
+ function scoreDirectoryPickerMatch(directoryPath = '', basePath = '', query = '') {
561
+ const normalizedQuery = String(query || '').trim().toLowerCase()
562
+ if (!directoryPath || !normalizedQuery) {
563
+ return 0
564
+ }
565
+
566
+ const absoluteScore = scoreWorkspaceMatch(toPosixPath(directoryPath), normalizedQuery)
567
+ const base = String(basePath || '').trim()
568
+ const relativePath = base ? toPosixPath(path.relative(base, directoryPath)) : toPosixPath(directoryPath)
569
+ const relativeScore = scoreWorkspaceMatch(relativePath, normalizedQuery)
570
+ const nameScore = scoreSegmentMatch(path.basename(directoryPath), normalizedQuery, { isFileName: false })
571
+ return Math.max(absoluteScore, relativeScore, nameScore)
572
+ }
573
+
574
+ export function searchDirectoryPickerEntries(options = {}) {
575
+ const query = String(options.query || '').trim()
576
+ const limit = clampLimit(options.limit, DEFAULT_SEARCH_LIMIT, 200)
577
+
578
+ if (!query) {
579
+ return {
580
+ path: '',
581
+ query: '',
582
+ items: [],
583
+ truncated: false,
584
+ }
585
+ }
586
+
587
+ const targetPath = normalizeDirectoryPickerPath(options.path) || getDirectoryPickerHomePath()
588
+ const roots = [targetPath]
589
+ const matches = []
590
+ let visited = 0
591
+ let truncated = false
592
+
593
+ for (const rootPath of roots) {
594
+ const stack = [rootPath]
595
+
596
+ while (stack.length) {
597
+ const currentPath = stack.pop()
598
+ let entries = []
599
+
600
+ try {
601
+ entries = fs.readdirSync(currentPath, { withFileTypes: true })
602
+ } catch {
603
+ continue
604
+ }
605
+
606
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
607
+
608
+ for (const entry of entries) {
609
+ if (!entry.isDirectory() || shouldIgnorePickerDirectory(entry)) {
610
+ continue
611
+ }
612
+
613
+ visited += 1
614
+ if (visited > MAX_SEARCH_VISITS) {
615
+ truncated = true
616
+ stack.length = 0
617
+ break
618
+ }
619
+
620
+ const absolutePath = path.join(currentPath, entry.name)
621
+ const score = scoreDirectoryPickerMatch(absolutePath, rootPath, query)
622
+ if (score > 0) {
623
+ matches.push({
624
+ ...createDirectoryPickerItem(absolutePath, entry.name),
625
+ score,
626
+ })
627
+ }
628
+
629
+ stack.push(absolutePath)
630
+ }
631
+
632
+ if (truncated) {
633
+ break
634
+ }
635
+ }
636
+
637
+ if (truncated) {
638
+ break
639
+ }
640
+ }
641
+
642
+ matches.sort((left, right) => {
643
+ const scoreDiff = right.score - left.score
644
+ if (scoreDiff) {
645
+ return scoreDiff
646
+ }
647
+
648
+ const depthDiff = left.path.split(path.sep).length - right.path.split(path.sep).length
649
+ if (depthDiff) {
650
+ return depthDiff
651
+ }
652
+
653
+ return String(left.path || '').localeCompare(String(right.path || ''), 'zh-CN')
654
+ })
655
+
656
+ return {
657
+ path: targetPath,
658
+ query,
659
+ items: matches.slice(0, limit).map(({ score, ...item }) => item),
660
+ truncated: truncated || matches.length > limit,
661
+ }
662
+ }