@jetcrab/pi-desk 1.1.18-dev → 1.1.20-dev

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.
Files changed (105) hide show
  1. package/docs/plugins.md +1 -1
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  4. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +1 -1
  5. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-reload.ts +42 -0
  6. package/src/client/l2_biz/workbench/l2-work-session-sidebar.tsx +19 -1
  7. package/src/client/l2_biz/workbench/l2-workbench-chat.ts +45 -0
  8. package/src/client/l2_biz/workbench/l2-workbench.tsx +4 -0
  9. package/src/client/l3_modules/conversation/l3-conversation-markdown.tsx +10 -1
  10. package/src/client/l3_modules/project-files/l3-markdown-preview.tsx +10 -1
  11. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +2 -0
  12. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +171 -229
  13. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +133 -70
  14. package/src/client/l3_modules/project-files/l3-project-reader.ts +4 -3
  15. package/src/client/l3_modules/project-files/views/l3-project-search-controls.tsx +201 -0
  16. package/src/client/l3_modules/project-files/views/l3-project-search-results.tsx +333 -0
  17. package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +47 -3
  18. package/src/client/l4_foundation/ui/markdown/l4-markdown-remark.ts +89 -0
  19. package/src/client/l4_foundation/ui/markdown/l4-markdown.module.css +58 -0
  20. package/src/client/l4_foundation/ui/markdown/l4-markdown.tsx +108 -0
  21. package/src/common/l2_biz/work-session/l2-work-session-file-contract.ts +4 -3
  22. package/src/common/l2_biz/workbench/l2-workbench-locale-messages.ts +6 -0
  23. package/src/common/l3_modules/project-files/l3-project-files-contract.ts +2 -3
  24. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +56 -10
  25. package/src/common/l4_foundation/file/l4-project-file-contract.ts +51 -1
  26. package/src/common/l4_foundation/locale/l4-host-error-messages.ts +11 -0
  27. package/src/common/l4_foundation/locale/l4-locale-messages.ts +16 -0
  28. package/src/instrumentation.ts +0 -2
  29. package/src/server/l1_entry/api/l1-project-read-routes.ts +6 -4
  30. package/src/server/l1_entry/api/l1-project-route.ts +9 -8
  31. package/src/server/l1_entry/api/l1-work-session-file-error.ts +23 -0
  32. package/src/server/l2_biz/plugin-management/l2-plugin-management.ts +0 -3
  33. package/src/server/l2_biz/work-session/l2-work-session-chat-runtime.ts +30 -58
  34. package/src/server/l2_biz/work-session/l2-work-session-manage.ts +0 -4
  35. package/src/server/l4_foundation/file/l4-project-file-index.ts +276 -0
  36. package/src/server/l4_foundation/file/l4-project-search.ts +399 -0
  37. package/src/server/l4_foundation/file/l4-ripgrep-search.ts +192 -0
  38. package/src/server/l4_foundation/file/l4-work-session-project-file.ts +8 -332
  39. package/src/server/l4_foundation/pi/l4-pi-package-maintenance.ts +0 -6
  40. package/src/server/l4_foundation/pi/l4-pi-plugin-capability-probe.ts +1 -3
  41. package/src/server/l4_foundation/pi/l4-pi-plugin-probe.ts +1 -3
  42. package/src/server/l4_foundation/pi/l4-pi-runner-process.ts +2 -2
  43. package/src/server/l4_foundation/process/l4-process-tree.js +23 -4
  44. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  45. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  46. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  47. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  48. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -2
  49. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -2
  50. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  51. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  52. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  53. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  54. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  55. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  56. package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
  57. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  58. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/app/api/project-files/get/route.js +2 -1
  60. package/temp/build/pi-desk/release/.next/server/app/api/project-files/list/route.js +2 -1
  61. package/temp/build/pi-desk/release/.next/server/app/api/project-files/reveal/route.js +1 -1
  62. package/temp/build/pi-desk/release/.next/server/app/api/project-files/search/route.js +2 -1
  63. package/temp/build/pi-desk/release/.next/server/app/api/project-git/get/route.js +2 -1
  64. package/temp/build/pi-desk/release/.next/server/app/api/project-git-branches/list/route.js +2 -1
  65. package/temp/build/pi-desk/release/.next/server/app/api/project-git-changes/list/route.js +2 -1
  66. package/temp/build/pi-desk/release/.next/server/app/api/project-git-diff/get/route.js +2 -1
  67. package/temp/build/pi-desk/release/.next/server/app/api/project-git-heads/get/route.js +2 -1
  68. package/temp/build/pi-desk/release/.next/server/app/api/project-git-log/list/route.js +2 -1
  69. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  70. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  71. package/temp/build/pi-desk/release/.next/server/app/page.js +77 -77
  72. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/temp/build/pi-desk/release/.next/server/chunks/{33658.js → 10881.js} +8 -8
  74. package/temp/build/pi-desk/release/.next/server/chunks/23054.js +1 -1
  75. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +3 -3
  76. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +5 -5
  77. package/temp/build/pi-desk/release/.next/server/chunks/50520.js +2 -4
  78. package/temp/build/pi-desk/release/.next/server/chunks/6917.js +1 -1
  79. package/temp/build/pi-desk/release/.next/server/chunks/70780.js +1 -0
  80. package/temp/build/pi-desk/release/.next/server/chunks/74528.js +1 -1
  81. package/temp/build/pi-desk/release/.next/server/chunks/92386.js +1 -1
  82. package/temp/build/pi-desk/release/.next/server/instrumentation.js +1 -1
  83. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  84. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  85. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  86. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  87. package/temp/build/pi-desk/release/.next/static/chunks/27611-0890482fe76bd9d6.js +238 -0
  88. package/temp/build/pi-desk/release/.next/static/chunks/{31087-ba426d09d6f68bfd.js → 31087-d8e5765d541d5c26.js} +1 -1
  89. package/temp/build/pi-desk/release/.next/static/chunks/{49516-cc4dcd84d64ab4fe.js → 51474-8dc714cb76b2e60a.js} +2 -2
  90. package/temp/build/pi-desk/release/.next/static/chunks/77592-5bef6acb8e7c4b96.js +13 -0
  91. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-a50c456648537afb.js +3 -0
  92. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-a984139e99586041.js → page-0789c43551b29dd2.js} +4 -4
  93. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-40a782fc8a3535b9.js → webpack-d7248206b7fc7ba8.js} +1 -1
  94. package/temp/build/pi-desk/release/.next/static/css/250a6b93cc2222d1.css +1 -0
  95. package/temp/build/pi-desk/release/.next/static/css/6dbae2922f88ca31.css +1 -0
  96. package/temp/build/pi-desk/release/.next/static/css/a4cf28fd76c70165.css +1 -0
  97. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  98. package/temp/build/pi-desk/release/.next/server/chunks/8584.js +0 -2
  99. package/temp/build/pi-desk/release/.next/static/chunks/39654-b5fedd762ea0312e.js +0 -238
  100. package/temp/build/pi-desk/release/.next/static/chunks/75130-d2ca74894aff8085.js +0 -13
  101. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-c0e1c5cef8620cf8.js +0 -3
  102. package/temp/build/pi-desk/release/.next/static/css/6427d02b6edf406f.css +0 -1
  103. package/temp/build/pi-desk/release/.next/static/css/79e06f05df9edff0.css +0 -1
  104. /package/temp/build/pi-desk/release/.next/static/{n2LrXQgq8UkbH_AzhR3fw → RLKcK-RQF5FvEeDmS9uih}/_buildManifest.js +0 -0
  105. /package/temp/build/pi-desk/release/.next/static/{n2LrXQgq8UkbH_AzhR3fw → RLKcK-RQF5FvEeDmS9uih}/_ssgManifest.js +0 -0
@@ -0,0 +1,333 @@
1
+ 'use client'
2
+
3
+ import { ChevronRightIcon, FileIcon, LoaderCircleIcon, SearchIcon } from 'lucide-react'
4
+ import { useId, useState, type ReactElement, type ReactNode, type Ref } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import type {
7
+ L4ProjectContentMatch,
8
+ L4ProjectFileSearch,
9
+ L4ProjectSearchInput,
10
+ L4ProjectSearchRange
11
+ } from '@common/l4_foundation/file/l4-project-file-contract'
12
+ import { cn } from '@client/l4_foundation/lib/l4-utils'
13
+ import { L4ScrollArea } from '@client/l4_foundation/ui/l4-scroll-area'
14
+ import { Button } from '@client/l4_foundation/ui/shadcn/button'
15
+
16
+ interface L3ProjectSearchResultsProps {
17
+ search: L4ProjectSearchInput &
18
+ L4ProjectFileSearch & {
19
+ status: 'idle' | 'loading' | 'ready' | 'error'
20
+ error: string | null
21
+ }
22
+ resultsRef: Ref<HTMLDivElement>
23
+ activePath: string | null
24
+ onOpen: (path: string, line?: number) => void
25
+ wrapFile: (path: string, line: number | undefined, trigger: ReactElement) => ReactNode
26
+ renderFileIcon: (path: string) => ReactNode
27
+ fileDecoration: (path: string) => { className?: string; badge?: string }
28
+ }
29
+
30
+ function highlight(text: string, ranges: L4ProjectSearchRange[], offset = 0): ReactNode {
31
+ const parts: ReactNode[] = []
32
+ let cursor = 0
33
+ for (const range of ranges) {
34
+ const start = Math.max(cursor, range.start - offset, 0)
35
+ const end = Math.min(range.end - offset, text.length)
36
+ if (end <= start) continue
37
+ parts.push(text.slice(cursor, start))
38
+ parts.push(
39
+ <mark key={`${start}:${end}`} className="rounded-sm bg-foreground/15 text-inherit">
40
+ {text.slice(start, end)}
41
+ </mark>
42
+ )
43
+ cursor = end
44
+ }
45
+ parts.push(text.slice(cursor))
46
+ return parts
47
+ }
48
+
49
+ export function L3ProjectSearchResults({
50
+ search,
51
+ resultsRef,
52
+ activePath,
53
+ onOpen,
54
+ wrapFile,
55
+ renderFileIcon,
56
+ fileDecoration
57
+ }: L3ProjectSearchResultsProps): React.JSX.Element {
58
+ const { t } = useTranslation('projectFiles')
59
+ const id = useId()
60
+ const [expandedFiles, setExpandedFiles] = useState(false)
61
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
62
+ const [selected, setSelected] = useState<{ path: string; line: number } | null>(null)
63
+ const groups = new Map<string, L4ProjectContentMatch[]>()
64
+ for (const match of search.contentMatches) {
65
+ const group = groups.get(match.path)
66
+ if (group) group.push(match)
67
+ else groups.set(match.path, [match])
68
+ }
69
+ const hasQuery = Boolean(search.query.trim())
70
+ const ready = hasQuery && search.status === 'ready'
71
+ const fileCount = search.fileMatches.length
72
+ const lineCount = search.contentMatches.length
73
+ const compact = search.scope === 'all' && fileCount > 3 && lineCount > 0 && !expandedFiles
74
+ const files = compact ? search.fileMatches.slice(0, 3) : search.fileMatches
75
+ const matchedFileCount = new Set([
76
+ ...search.fileMatches.map((match) => match.path),
77
+ ...groups.keys()
78
+ ]).size
79
+ const count = t('searchCounts', { files: matchedFileCount, lines: lineCount })
80
+ const toggleGroup = (path: string): void => {
81
+ setCollapsed((previous) => {
82
+ const next = new Set(previous)
83
+ if (next.has(path)) next.delete(path)
84
+ else next.add(path)
85
+ return next
86
+ })
87
+ }
88
+
89
+ return (
90
+ <div className="flex min-h-0 flex-1 flex-col">
91
+ <div
92
+ className="flex min-h-8 flex-wrap items-center justify-between gap-x-2 px-3 text-xs text-muted-foreground"
93
+ role="status"
94
+ aria-live="polite"
95
+ >
96
+ <span>{t('searchResults')}</span>
97
+ {ready ? (
98
+ <span className="tabular-nums">
99
+ {search.truncated ? t('searchShownCounts', { counts: count }) : count}
100
+ </span>
101
+ ) : null}
102
+ </div>
103
+ <L4ScrollArea className="min-h-0 flex-1" viewportClassName="pb-2">
104
+ <div
105
+ ref={resultsRef}
106
+ role="region"
107
+ aria-label={t('searchResults')}
108
+ aria-busy={hasQuery && search.status === 'loading'}
109
+ onKeyDown={(event) => {
110
+ if (
111
+ event.key !== 'ArrowDown' &&
112
+ event.key !== 'ArrowUp' &&
113
+ event.key !== 'Home' &&
114
+ event.key !== 'End'
115
+ )
116
+ return
117
+ const buttons = [
118
+ ...event.currentTarget.querySelectorAll<HTMLButtonElement>('button')
119
+ ].filter((button) => !button.disabled && !button.closest('[hidden]'))
120
+ const current = buttons.indexOf(document.activeElement as HTMLButtonElement)
121
+ if (current < 0) return
122
+ event.preventDefault()
123
+ const next =
124
+ event.key === 'Home'
125
+ ? 0
126
+ : event.key === 'End'
127
+ ? buttons.length - 1
128
+ : Math.max(
129
+ 0,
130
+ Math.min(buttons.length - 1, current + (event.key === 'ArrowDown' ? 1 : -1))
131
+ )
132
+ buttons[next]?.focus()
133
+ }}
134
+ >
135
+ {!hasQuery ? (
136
+ <div className="px-5 py-9 text-center text-sm text-muted-foreground">
137
+ <SearchIcon className="mx-auto mb-3 size-6" />
138
+ <p>{t('searchStart')}</p>
139
+ <p className="mt-2 text-xs">
140
+ {t(
141
+ search.scope === 'files'
142
+ ? 'searchFiles'
143
+ : search.scope === 'content'
144
+ ? 'searchContent'
145
+ : 'searchAll'
146
+ )}
147
+ </p>
148
+ </div>
149
+ ) : search.status === 'loading' ? (
150
+ <div className="flex min-h-10 items-center gap-2 px-3 text-xs text-muted-foreground">
151
+ <LoaderCircleIcon className="size-3.5 animate-spin" />
152
+ {t('searching')}
153
+ </div>
154
+ ) : search.status === 'error' ? (
155
+ <p
156
+ role="alert"
157
+ className="mx-3 my-2 break-words rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
158
+ >
159
+ {search.error || t('searchFailed')}
160
+ </p>
161
+ ) : ready && fileCount === 0 && lineCount === 0 ? (
162
+ <div className="px-5 py-9 text-center text-sm text-muted-foreground">
163
+ <SearchIcon className="mx-auto mb-3 size-6" />
164
+ <p>{t(search.truncated ? 'resultLimit' : 'noMatches')}</p>
165
+ <p className="mt-2 text-xs">{t('searchTryOptions')}</p>
166
+ </div>
167
+ ) : ready ? (
168
+ <>
169
+ {fileCount > 0 ? (
170
+ <section className="pt-1">
171
+ <h3 className="flex min-h-8 items-center gap-2 px-3 text-xs text-muted-foreground">
172
+ {t('searchFileHeading')}
173
+ <span className="tabular-nums">{fileCount}</span>
174
+ </h3>
175
+ {files.map(({ path, ranges }) => {
176
+ const slash = path.lastIndexOf('/')
177
+ const decoration = fileDecoration(path)
178
+ return wrapFile(
179
+ path,
180
+ undefined,
181
+ <Button
182
+ key={path}
183
+ type="button"
184
+ variant="ghost"
185
+ data-testid={`project-search-result-${path}`}
186
+ data-search-result="true"
187
+ className={cn(
188
+ 'mx-1.5 flex min-h-14 w-[calc(100%-0.75rem)] justify-start gap-2 px-2 py-2 text-left font-normal',
189
+ activePath === path && 'bg-accent'
190
+ )}
191
+ title={path}
192
+ onClick={() => onOpen(path)}
193
+ >
194
+ <span className="shrink-0">{renderFileIcon(path)}</span>
195
+ <span className="min-w-0 flex-1">
196
+ <span className={cn('block truncate text-sm', decoration.className)}>
197
+ {highlight(path.slice(slash + 1), ranges, slash + 1)}
198
+ </span>
199
+ <span className="mt-0.5 block truncate font-mono text-xs text-muted-foreground">
200
+ {slash < 0
201
+ ? t('searchProjectRoot')
202
+ : highlight(path.slice(0, slash), ranges)}
203
+ </span>
204
+ </span>
205
+ {decoration.badge ? (
206
+ <span
207
+ className={cn('text-xs', decoration.className)}
208
+ aria-label={t('gitStatus', { status: decoration.badge })}
209
+ >
210
+ {decoration.badge}
211
+ </span>
212
+ ) : null}
213
+ </Button>
214
+ )
215
+ })}
216
+ {compact ? (
217
+ <Button
218
+ type="button"
219
+ variant="ghost"
220
+ size="sm"
221
+ className="ml-8 text-xs text-muted-foreground"
222
+ onClick={() => setExpandedFiles(true)}
223
+ >
224
+ {t('searchExpandFiles', { count: fileCount - 3 })}
225
+ </Button>
226
+ ) : null}
227
+ </section>
228
+ ) : null}
229
+ {lineCount > 0 ? (
230
+ <section className={cn('pt-1', fileCount > 0 && 'mt-2 border-t')}>
231
+ <h3 className="flex min-h-8 flex-wrap items-center gap-x-2 px-3 text-xs text-muted-foreground">
232
+ {t('searchContentHeading')}
233
+ <span>
234
+ {t('searchContentCounts', { lines: lineCount, files: groups.size })}
235
+ </span>
236
+ </h3>
237
+ {[...groups].map(([path, matches], index) => {
238
+ const slash = path.lastIndexOf('/')
239
+ const expanded = !collapsed.has(path)
240
+ const groupId = `${id}-${index}`
241
+ return (
242
+ <section key={path} className="mb-1">
243
+ <Button
244
+ type="button"
245
+ variant="ghost"
246
+ className="min-h-8 w-full justify-start gap-1.5 rounded-none px-3 text-left font-normal"
247
+ title={path}
248
+ aria-expanded={expanded}
249
+ aria-controls={groupId}
250
+ onClick={() => toggleGroup(path)}
251
+ onKeyDown={(event) => {
252
+ if (
253
+ (event.key === 'ArrowLeft' && expanded) ||
254
+ (event.key === 'ArrowRight' && !expanded)
255
+ ) {
256
+ event.preventDefault()
257
+ toggleGroup(path)
258
+ }
259
+ }}
260
+ >
261
+ <ChevronRightIcon
262
+ className={cn(
263
+ 'size-3 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none',
264
+ expanded && 'rotate-90'
265
+ )}
266
+ />
267
+ <FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
268
+ <span className="min-w-0 truncate text-sm">
269
+ {path.slice(slash + 1)}
270
+ <span className="ml-1.5 text-xs text-muted-foreground">
271
+ {path.slice(0, Math.max(0, slash))}
272
+ </span>
273
+ </span>
274
+ <span className="ml-auto shrink-0 rounded bg-muted px-1 text-xs text-muted-foreground tabular-nums">
275
+ {matches.length}
276
+ </span>
277
+ </Button>
278
+ <div id={groupId} hidden={!expanded}>
279
+ {matches.map(({ line, text, ranges }) =>
280
+ wrapFile(
281
+ path,
282
+ line,
283
+ <Button
284
+ key={`${path}:${line}`}
285
+ type="button"
286
+ variant="ghost"
287
+ data-testid={`project-search-line-${path}-${line}`}
288
+ data-search-result="true"
289
+ className={cn(
290
+ 'min-h-8 w-full justify-start gap-2 rounded-none border-l-2 border-transparent py-1 pr-3 pl-7 text-left font-mono font-normal',
291
+ selected?.path === path &&
292
+ selected.line === line &&
293
+ 'border-l-foreground/50 bg-accent'
294
+ )}
295
+ title={text}
296
+ aria-label={t('searchOpenLine', { path, line })}
297
+ onClick={() => {
298
+ setSelected({ path, line })
299
+ onOpen(path, line)
300
+ }}
301
+ >
302
+ <span className="min-w-5 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
303
+ {line}
304
+ </span>
305
+ <span className="min-w-0 truncate whitespace-pre text-sm">
306
+ {highlight(text, ranges)}
307
+ </span>
308
+ </Button>
309
+ )
310
+ )}
311
+ </div>
312
+ </section>
313
+ )
314
+ })}
315
+ </section>
316
+ ) : null}
317
+ {search.truncated ? (
318
+ <p className="px-3 py-2 text-xs leading-5 text-muted-foreground">
319
+ {t('resultLimit')}
320
+ </p>
321
+ ) : null}
322
+ </>
323
+ ) : null}
324
+ </div>
325
+ </L4ScrollArea>
326
+ {search.scope !== 'files' ? (
327
+ <p className="shrink-0 border-t px-3 py-1.5 text-xs leading-5 text-muted-foreground">
328
+ {t('searchTextLimit')}
329
+ </p>
330
+ ) : null}
331
+ </div>
332
+ )
333
+ }
@@ -3,6 +3,7 @@
3
3
  import { LoaderCircleIcon } from 'lucide-react'
4
4
  import {
5
5
  forwardRef,
6
+ useCallback,
6
7
  useEffect,
7
8
  useImperativeHandle,
8
9
  useRef,
@@ -28,6 +29,10 @@ import markerStyles from './l4-code-line-markers.module.css'
28
29
 
29
30
  export type L4CodePreviewState = editor.ICodeEditorViewState
30
31
 
32
+ export interface L4CodeLineNavigation {
33
+ readonly line: number
34
+ }
35
+
31
36
  export interface L4CodePreviewHandle {
32
37
  openSearch: () => void
33
38
  gotoLine: () => void
@@ -48,6 +53,8 @@ interface L4CodePreviewProps {
48
53
  originalContent?: string
49
54
  onChanges?: (count: number | null) => void
50
55
  onOpenDiff?: (lineNumber: number) => void
56
+ navigation?: L4CodeLineNavigation | null
57
+ onNavigation?: (target: L4CodeLineNavigation, exists: boolean) => void
51
58
  }
52
59
 
53
60
  function L4CodePreviewComponent(
@@ -60,7 +67,9 @@ function L4CodePreviewComponent(
60
67
  onEscape,
61
68
  originalContent,
62
69
  onChanges,
63
- onOpenDiff
70
+ onOpenDiff,
71
+ navigation,
72
+ onNavigation
64
73
  }: L4CodePreviewProps,
65
74
  ref: ForwardedRef<L4CodePreviewHandle>
66
75
  ): React.JSX.Element {
@@ -76,13 +85,29 @@ function L4CodePreviewComponent(
76
85
  const markersRef = useRef<L4CodeLineMarkers | null>(null)
77
86
  const changesRef = useRef(onChanges)
78
87
  const openDiffRef = useRef(onOpenDiff)
88
+ const navigationCompleteRef = useRef(onNavigation)
89
+ const navigationMarkersRef = useRef<editor.IEditorDecorationsCollection | null>(null)
90
+ const highlightedLineRef = useRef<number | null>(null)
91
+ const paintNavigation = useCallback((codeEditor: editor.IStandaloneCodeEditor): void => {
92
+ const line = highlightedLineRef.current
93
+ navigationMarkersRef.current?.clear()
94
+ if (line === null || line > (codeEditor.getModel()?.getLineCount() ?? 0)) return
95
+ navigationMarkersRef.current ??= codeEditor.createDecorationsCollection()
96
+ navigationMarkersRef.current.set([
97
+ {
98
+ range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
99
+ options: { isWholeLine: true, className: 'bg-accent' }
100
+ }
101
+ ])
102
+ }, [])
79
103
 
80
104
  useEffect(() => {
81
105
  savedStateRef.current = savedState
82
106
  escapeRef.current = onEscape
83
107
  changesRef.current = onChanges
84
108
  openDiffRef.current = onOpenDiff
85
- }, [onChanges, onEscape, onOpenDiff, savedState])
109
+ navigationCompleteRef.current = onNavigation
110
+ }, [onChanges, onEscape, onOpenDiff, onNavigation, savedState])
86
111
  const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
87
112
 
88
113
  useEffect(() => {
@@ -200,6 +225,7 @@ function L4CodePreviewComponent(
200
225
  stopTypographyObserver = observeL4MonacoTypography((typography) =>
201
226
  codeEditor?.updateOptions(typography)
202
227
  )
228
+ paintNavigation(codeEditor)
203
229
  setLoadState('ready')
204
230
  })
205
231
  .catch((error: unknown) => {
@@ -222,16 +248,34 @@ function L4CodePreviewComponent(
222
248
  savedStateRef.current = codeEditor.saveViewState()
223
249
  onSaveStateRef.current(path, savedStateRef.current)
224
250
  }
251
+ navigationMarkersRef.current?.clear()
252
+ navigationMarkersRef.current = null
225
253
  editorRef.current = null
226
254
  codeEditor?.dispose()
227
255
  model?.dispose()
228
256
  }
229
- }, [content, originalContent, path, projectFilesT, size, t])
257
+ }, [content, originalContent, paintNavigation, path, projectFilesT, size, t])
230
258
 
231
259
  useEffect(() => {
232
260
  editorRef.current?.updateOptions({ wordWrap: wrapLines ? 'on' : 'off' })
233
261
  }, [wrapLines])
234
262
 
263
+ useEffect(() => {
264
+ const codeEditor = editorRef.current
265
+ if (!navigation || loadState !== 'ready' || !codeEditor) return
266
+ const model = codeEditor.getModel()
267
+ const exists = Boolean(model && navigation.line >= 1 && navigation.line <= model.getLineCount())
268
+ highlightedLineRef.current = exists ? navigation.line : null
269
+ paintNavigation(codeEditor)
270
+ if (exists) {
271
+ codeEditor.setPosition({ lineNumber: navigation.line, column: 1 })
272
+ codeEditor.revealLineInCenter(navigation.line)
273
+ savedStateRef.current = codeEditor.saveViewState()
274
+ onSaveStateRef.current(path, savedStateRef.current)
275
+ }
276
+ navigationCompleteRef.current?.(navigation, exists)
277
+ }, [loadState, navigation, paintNavigation, path])
278
+
235
279
  return (
236
280
  <div
237
281
  data-testid="code-preview"
@@ -0,0 +1,89 @@
1
+ export const L4_MARKDOWN_ALLOWED_TAGS = {
2
+ input: ['dataPiMarkdownProgress'],
3
+ blockquote: ['dataPiMarkdownAlert']
4
+ }
5
+
6
+ // 仅描述本转换读取的 mdast 字段,不引入另一套解析器或运行依赖。
7
+ interface MarkdownNode {
8
+ type: string
9
+ value?: string
10
+ children?: MarkdownNode[]
11
+ position?: { start: { offset?: number } }
12
+ data?: {
13
+ hName?: string
14
+ hProperties?: Record<string, string | boolean | string[]>
15
+ }
16
+ }
17
+
18
+ function statusNode(marker: string): MarkdownNode {
19
+ return {
20
+ type: 'paragraph',
21
+ children: [],
22
+ data: {
23
+ hName: 'input',
24
+ hProperties: {
25
+ type: 'checkbox',
26
+ disabled: true,
27
+ checked: marker.toLowerCase() === '[x]',
28
+ ...(marker === '[-]' ? { dataPiMarkdownProgress: 'true' } : {})
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ export function remarkL4Markdown(): (tree: MarkdownNode, file: { value: unknown }) => void {
35
+ return (tree, file): void => {
36
+ const source = String(file.value)
37
+ const isLiteral = (node: MarkdownNode, marker: string): boolean => {
38
+ const start = node.position?.start.offset
39
+ return start !== undefined && source.slice(start, start + marker.length) === marker
40
+ }
41
+
42
+ const visit = (node: MarkdownNode): void => {
43
+ const first = node.children?.[0]
44
+ if (node.type === 'tableCell' && node.children?.length === 1 && first?.type === 'text') {
45
+ const marker = first.value ?? ''
46
+ if (/^\[[ xX-]\]$/.test(marker) && isLiteral(first, marker)) {
47
+ node.children = [statusNode(marker)]
48
+ }
49
+ } else if (node.type === 'listItem' && first?.type === 'paragraph') {
50
+ const text = first.children?.[0]
51
+ if (
52
+ text?.type === 'text' &&
53
+ /^\[-\](?:\s|$)/.test(text.value ?? '') &&
54
+ isLiteral(text, '[-]')
55
+ ) {
56
+ text.value = text.value!.slice(3)
57
+ first.children!.unshift(statusNode('[-]'))
58
+ node.data = { ...node.data, hProperties: { className: ['task-list-item'] } }
59
+ }
60
+ } else if (node.type === 'blockquote' && first?.type === 'paragraph') {
61
+ const text = first.children?.[0]
62
+ const match = text?.value?.match(
63
+ /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/
64
+ )
65
+ if (
66
+ text?.type === 'text' &&
67
+ match &&
68
+ isLiteral(text, `[!${match[1]}]`) &&
69
+ (match[0].endsWith('\n') ||
70
+ first.children!.length === 1 ||
71
+ first.children![1]?.type === 'break')
72
+ ) {
73
+ text.value = text.value!.slice(match[0].length)
74
+ if (!text.value) {
75
+ first.children!.shift()
76
+ if (first.children![0]?.type === 'break') first.children!.shift()
77
+ }
78
+ if (first.children!.length === 0) node.children!.shift()
79
+ node.data = {
80
+ ...node.data,
81
+ hProperties: { dataPiMarkdownAlert: match[1]!.toLowerCase() }
82
+ }
83
+ }
84
+ }
85
+ for (const child of node.children ?? []) visit(child)
86
+ }
87
+ visit(tree)
88
+ }
89
+ }
@@ -0,0 +1,58 @@
1
+ .markdown :global(.task-list-item) {
2
+ list-style: none;
3
+ }
4
+
5
+ .status {
6
+ display: inline-flex;
7
+ position: relative;
8
+ width: 1rem;
9
+ height: 1rem;
10
+ align-items: center;
11
+ justify-content: center;
12
+ border: 1px solid var(--muted-foreground);
13
+ border-radius: 0.25rem;
14
+ vertical-align: -0.125rem;
15
+ color: var(--foreground);
16
+ }
17
+
18
+ .status[data-state='completed'] {
19
+ border-color: var(--foreground);
20
+ background: var(--foreground);
21
+ color: var(--background);
22
+ }
23
+
24
+ .status svg {
25
+ width: 0.875rem;
26
+ height: 0.875rem;
27
+ stroke-width: 2;
28
+ }
29
+
30
+ .alert {
31
+ margin-block: 0.75rem;
32
+ border-left: 3px solid var(--muted-foreground);
33
+ padding: 0.5rem 0.875rem;
34
+ color: var(--foreground);
35
+ font-style: normal;
36
+ }
37
+
38
+ .alertTitle {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 0.5rem;
42
+ margin-bottom: 0.375rem;
43
+ font-weight: 600;
44
+ }
45
+
46
+ .alertTitle svg {
47
+ width: 1rem;
48
+ height: 1rem;
49
+ flex: none;
50
+ }
51
+
52
+ .alertBody > * {
53
+ margin-block: 0;
54
+ }
55
+
56
+ .alertBody > * + * {
57
+ margin-block-start: 0.5rem;
58
+ }