@opencode/ui 2.0.23 → 2.0.25

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 (82) hide show
  1. package/dist/context/marked-parser.d.ts +2 -0
  2. package/dist/i18n/en.d.ts +1 -0
  3. package/dist/icons/catalog.d.ts +36 -0
  4. package/dist/navigation/segmented-control/segmented-control.d.ts +21 -2
  5. package/package.json +2 -1
  6. package/src/actions/button/button.css +11 -9
  7. package/src/actions/button/button.tsx +1 -0
  8. package/src/actions/icon-button/icon-button.css +1 -5
  9. package/src/actions/icon-button/icon-button.tsx +1 -0
  10. package/src/actions/split-button/split-button.tsx +3 -0
  11. package/src/actions/submit.css +14 -12
  12. package/src/components/animated-number.tsx +13 -0
  13. package/src/components/app-icon.tsx +2 -0
  14. package/src/components/card.tsx +21 -0
  15. package/src/components/collapsible.tsx +1 -0
  16. package/src/components/context-menu.tsx +34 -0
  17. package/src/components/dock-surface.tsx +3 -0
  18. package/src/components/file-icon.tsx +12 -0
  19. package/src/components/image-preview.tsx +1 -0
  20. package/src/components/list.tsx +32 -0
  21. package/src/components/motion-spring.tsx +5 -0
  22. package/src/components/popover.tsx +12 -0
  23. package/src/components/provider-icon.tsx +1 -0
  24. package/src/components/resize-handle.tsx +8 -0
  25. package/src/components/scroll-view.tsx +39 -0
  26. package/src/components/spinner.tsx +2 -0
  27. package/src/components/text-field.tsx +6 -0
  28. package/src/components/text-reveal.tsx +19 -0
  29. package/src/components/text-strikethrough.tsx +8 -0
  30. package/src/components/typewriter.tsx +4 -0
  31. package/src/context/dialog.tsx +12 -0
  32. package/src/context/file.tsx +1 -0
  33. package/src/context/helper.tsx +4 -0
  34. package/src/context/i18n.tsx +20 -0
  35. package/src/context/index.ts +3 -0
  36. package/src/context/marked-base.tsx +9 -1
  37. package/src/context/marked-parser.tsx +57 -18
  38. package/src/context/marked.tsx +3 -0
  39. package/src/context/worker-pool.tsx +2 -0
  40. package/src/data-display/avatar/avatar.tsx +4 -0
  41. package/src/data-display/badge/badge.tsx +1 -0
  42. package/src/data-display/diff-changes/diff-changes.tsx +2 -0
  43. package/src/data-display/keybind/keybind.tsx +1 -0
  44. package/src/data-display/line-comment/line-comment.tsx +22 -0
  45. package/src/data-display/project-avatar/project-avatar.tsx +10 -0
  46. package/src/feedback/loader/loader.tsx +1 -0
  47. package/src/feedback/toast/description.ts +1 -0
  48. package/src/forms/checkbox/checkbox.tsx +1 -0
  49. package/src/forms/field/field.tsx +8 -0
  50. package/src/forms/inline-input/inline-input.tsx +4 -0
  51. package/src/forms/radio/radio.tsx +2 -0
  52. package/src/forms/select/select.tsx +11 -0
  53. package/src/forms/switch/switch.tsx +1 -0
  54. package/src/forms/text-input/text-input.tsx +3 -0
  55. package/src/hooks/create-auto-scroll.tsx +34 -0
  56. package/src/hooks/index.ts +1 -0
  57. package/src/hooks/use-filtered-list.tsx +14 -0
  58. package/src/i18n/en.ts +1 -0
  59. package/src/i18n/no.ts +1 -0
  60. package/src/icons/catalog.ts +36 -0
  61. package/src/icons/icon/icon.tsx +2 -0
  62. package/src/layout/divider/divider.tsx +1 -0
  63. package/src/navigation/menu/menu.tsx +13 -0
  64. package/src/navigation/segmented-control/segmented-control.css +37 -4
  65. package/src/navigation/segmented-control/segmented-control.tsx +210 -14
  66. package/src/navigation/tab-state-indicator.tsx +1 -0
  67. package/src/navigation/tabs/tabs-current.css +1 -5
  68. package/src/navigation/tabs/tabs.css +5 -22
  69. package/src/navigation/tabs/tabs.tsx +18 -0
  70. package/src/overlays/dialog/dialog.tsx +3 -0
  71. package/src/overlays/tooltip/tooltip.tsx +15 -0
  72. package/src/storybook/scaffold.tsx +3 -0
  73. package/src/styles/theme.css +1 -1
  74. package/src/theme/color.ts +27 -0
  75. package/src/theme/context.tsx +58 -0
  76. package/src/theme/default-themes.ts +35 -0
  77. package/src/theme/index.ts +3 -0
  78. package/src/theme/loader.ts +11 -0
  79. package/src/theme/resolve.ts +28 -0
  80. package/src/theme/v2/foreground.ts +8 -0
  81. package/src/theme/v2/resolve.ts +10 -0
  82. package/src/typography/text-shimmer/text-shimmer.tsx +1 -0
@@ -21,15 +21,18 @@ export function TextStrikethrough(props: {
21
21
 
22
22
  let baseRef: HTMLSpanElement | undefined
23
23
  let containerRef: HTMLSpanElement | undefined
24
+
24
25
  const [state, setState] = createStore({
25
26
  textWidth: 0,
26
27
  containerWidth: 0,
27
28
  })
29
+
28
30
  const textWidth = () => state.textWidth
29
31
  const containerWidth = () => state.containerWidth
30
32
 
31
33
  const measure = () => {
32
34
  if (baseRef) setState("textWidth", baseRef.scrollWidth)
35
+
33
36
  if (containerRef) setState("containerWidth", containerRef.offsetWidth)
34
37
  }
35
38
 
@@ -39,6 +42,7 @@ export function TextStrikethrough(props: {
39
42
  // Revealed pixels from left = progress * textWidth
40
43
  const revealedPx = () => {
41
44
  const tw = textWidth()
45
+
42
46
  return tw > 0 ? progress() * tw : 0
43
47
  }
44
48
 
@@ -46,15 +50,19 @@ export function TextStrikethrough(props: {
46
50
  const overlayClip = () => {
47
51
  const cw = containerWidth()
48
52
  const tw = textWidth()
53
+
49
54
  if (cw <= 0 || tw <= 0) return `inset(0 ${(1 - progress()) * 100}% 0 0)`
50
55
  const remaining = Math.max(0, cw - revealedPx())
56
+
51
57
  return `inset(0 ${remaining}px 0 0)`
52
58
  }
53
59
 
54
60
  // Base clip: hide everything to the left of revealed area (complementary)
55
61
  const baseClip = () => {
56
62
  const px = revealedPx()
63
+
57
64
  if (px <= 0.5) return "none"
65
+
58
66
  return `inset(0 0 0 ${px}px)`
59
67
  }
60
68
 
@@ -11,6 +11,7 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
11
11
 
12
12
  createEffect(() => {
13
13
  const text = props.text
14
+
14
15
  if (!text) return
15
16
 
16
17
  let i = 0
@@ -21,8 +22,11 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
21
22
 
22
23
  const getTypingDelay = () => {
23
24
  const random = Math.random()
25
+
24
26
  if (random < 0.05) return 150 + Math.random() * 100
27
+
25
28
  if (random < 0.15) return 80 + Math.random() * 60
29
+
26
30
  return 30 + Math.random() * 50
27
31
  }
28
32
 
@@ -28,6 +28,7 @@ type Active = {
28
28
  }
29
29
 
30
30
  const Context = createContext<ReturnType<typeof init>>()
31
+
31
32
  // Lets the dialog rendered in a layer opt out of closing on a backdrop click.
32
33
  const LayerContext = createContext<{ setBackdropDismiss: (value: boolean) => void }>()
33
34
 
@@ -70,6 +71,7 @@ function init() {
70
71
  current.id,
71
72
  setTimeout(() => {
72
73
  closing.delete(current.id)
74
+
73
75
  if (closing.size === 0) lock.value = false
74
76
  setExiting((ids) => new Set([...ids].filter((id) => id !== current.id)))
75
77
  setStack((items) => items.filter((item) => item.id !== current.id))
@@ -84,6 +86,7 @@ function init() {
84
86
  /** Programmatic close. Without an id it closes the top dialog, one at a time; with an id it never waits. */
85
87
  const close = (id?: string) => {
86
88
  const current = id ? stack().find((item) => item.id === id) : stack().at(-1)
89
+
87
90
  if (!current || closing.has(current.id) || (!id && lock.value)) return
88
91
  closing.set(current.id, undefined)
89
92
  lock.value = true
@@ -93,6 +96,7 @@ function init() {
93
96
  /** Escape, a backdrop click, or Kobalte dismissing: only the top dialog, and one per exit animation. */
94
97
  const dismiss = (id?: string) => {
95
98
  const current = stack().at(-1)
99
+
96
100
  if (!current || (id && current.id !== id) || closing.has(current.id) || lock.value) return
97
101
  closing.set(current.id, undefined)
98
102
  lock.value = true
@@ -116,12 +120,14 @@ function init() {
116
120
  // A deferred open (e.g. from a focus callback) must not mount after the provider is gone.
117
121
  if (state.disposed) return
118
122
  const id = key ?? Math.random().toString(36).slice(2)
123
+
119
124
  // The layer follows the dialog's current place in the stack, so a new top dialog always renders above.
120
125
  const layer = () =>
121
126
  Math.max(
122
127
  0,
123
128
  stack().findIndex((item) => item.id === id),
124
129
  )
130
+
125
131
  const zIndex = () => String(50 + layer() * 10)
126
132
  let dispose: (() => void) | undefined
127
133
  let setClosing: ((closing: boolean) => void) | undefined
@@ -133,6 +139,7 @@ function init() {
133
139
  const [closing, setClosingSignal] = createSignal(false)
134
140
  const [backdropDismiss, setBackdropDismiss] = createSignal(true)
135
141
  setClosing = setClosingSignal
142
+
136
143
  return (
137
144
  <Kobalte
138
145
  modal={stack().findLast((item) => !exiting().has(item.id))?.id === id}
@@ -193,6 +200,7 @@ function init() {
193
200
  push,
194
201
  }
195
202
  }
203
+
196
204
  /** Runs a dialog callback so its throw is reported without stopping the caller's cleanup. */
197
205
  function isolate(fn: () => void) {
198
206
  try {
@@ -204,6 +212,7 @@ function isolate(fn: () => void) {
204
212
 
205
213
  export function DialogProvider(props: ParentProps) {
206
214
  const ctx = init()
215
+
207
216
  return (
208
217
  <Context.Provider value={ctx}>
209
218
  {props.children}
@@ -221,6 +230,7 @@ export function useDialog() {
221
230
  if (!owner) {
222
231
  throw new Error("useDialog must be used within a DialogProvider")
223
232
  }
233
+
224
234
  if (!ctx) {
225
235
  throw new Error("useDialog must be used within a DialogProvider")
226
236
  }
@@ -235,6 +245,7 @@ export function useDialog() {
235
245
  */
236
246
  show(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal) {
237
247
  const base = ctx.stack().at(-1)?.owner ?? owner
248
+
238
249
  return startTransition(() => {
239
250
  if (signal?.aborted) return isolate(() => onClose?.())
240
251
  ctx.show(element, base, onClose, id)
@@ -242,6 +253,7 @@ export function useDialog() {
242
253
  },
243
254
  push(element: DialogElement, onClose?: () => void, id?: string, signal?: AbortSignal) {
244
255
  const base = ctx.stack().at(-1)?.owner ?? owner
256
+
245
257
  return startTransition(() => {
246
258
  if (signal?.aborted) return isolate(() => onClose?.())
247
259
  ctx.push(element, base, onClose, id)
@@ -7,4 +7,5 @@ const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
7
7
  })
8
8
 
9
9
  export const FileComponentProvider = ctx.provider
10
+
10
11
  export const useFileComponent = ctx.use
@@ -21,8 +21,10 @@ export function createSimpleContext<T, Props extends Record<string, any>>(
21
21
  const isReady = createMemo(() => {
22
22
  // @ts-expect-error
23
23
  const ready = init.ready as Accessor<boolean> | boolean | undefined
24
+
24
25
  return ready === undefined || (typeof ready === "function" ? ready() : ready)
25
26
  })
27
+
26
28
  return (
27
29
  <Show when={isReady()}>
28
30
  <ctx.Provider value={init}>{props.children}</ctx.Provider>
@@ -31,7 +33,9 @@ export function createSimpleContext<T, Props extends Record<string, any>>(
31
33
  },
32
34
  use() {
33
35
  const value = useContext(ctx)
36
+
34
37
  if (!value) throw new Error(`${input.name} context must be used within a context provider`)
38
+
35
39
  return value
36
40
  },
37
41
  }
@@ -4,14 +4,21 @@ import { dict as en } from "../i18n/en"
4
4
  import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en"
5
5
 
6
6
  export type UiI18nKey = Key
7
+
7
8
  export type UiI18nPluralKey = PluralKey
9
+
8
10
  export type UiPluralCategory = PluralCategory
11
+
9
12
  export type UiI18nPluralLookupKey = PluralLookupKey
13
+
10
14
  export type UiI18nLocaleKey = LocaleKey
15
+
11
16
  type UiTranslationKey<Value extends string> = Value extends UiI18nPluralLookupKey ? never : Value
17
+
12
18
  export type UiI18nOrdinaryKey = UiTranslationKey<UiI18nKey>
13
19
 
14
20
  export type UiI18nParams = Record<string, string | number | boolean>
21
+
15
22
  export type UiTranslate = <Value extends string>(key: UiTranslationKey<Value>, params?: UiI18nParams) => string
16
23
 
17
24
  export type UiI18nSource = {
@@ -33,10 +40,13 @@ const rules = new Map<string, Intl.PluralRules>()
33
40
 
34
41
  export function pluralCategory(locale: string, count: number): UiPluralCategory {
35
42
  const cached = rules.get(locale)
43
+
36
44
  if (cached) return cached.select(count)
37
45
  const next = new Intl.PluralRules(locale)
46
+
38
47
  if (rules.size >= 32) rules.delete(rules.keys().next().value!)
39
48
  rules.set(locale, next)
49
+
40
50
  return next.select(count)
41
51
  }
42
52
 
@@ -46,13 +56,17 @@ export function pluralKey(key: UiI18nPluralKey, category: UiPluralCategory) {
46
56
 
47
57
  export function localizedListSeparator(locale: string, index: number, count: number) {
48
58
  if (index <= 0 || index >= count) return ""
59
+
49
60
  const parts = new Intl.ListFormat(locale, { style: "long", type: "conjunction" }).formatToParts(
50
61
  Array.from({ length: count }, (_, item) => String(item)),
51
62
  )
63
+
52
64
  const current = parts.findIndex((part) => part.type === "element" && part.value === String(index))
65
+
53
66
  const previous = parts.findLastIndex(
54
67
  (part, partIndex) => partIndex < current && part.type === "element" && part.value === String(index - 1),
55
68
  )
69
+
56
70
  return parts
57
71
  .slice(previous + 1, current)
58
72
  .map((part) => part.value)
@@ -61,9 +75,11 @@ export function localizedListSeparator(locale: string, index: number, count: num
61
75
 
62
76
  function resolveTemplate(text: string, params?: UiI18nParams) {
63
77
  if (!params) return text
78
+
64
79
  return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => {
65
80
  const key = String(rawKey)
66
81
  const value = params[key]
82
+
67
83
  return value === undefined ? "" : String(value)
68
84
  })
69
85
  }
@@ -91,6 +107,7 @@ const fallbackSource: UiI18nSource = {
91
107
  locale: () => "en",
92
108
  t: (key, params) => {
93
109
  const value = en[key as UiI18nKey] ?? String(key)
110
+
94
111
  return resolveTemplate(value, params)
95
112
  },
96
113
  plural: (key, count, params) =>
@@ -98,15 +115,18 @@ const fallbackSource: UiI18nSource = {
98
115
  pluralForm: (key, category, params) => {
99
116
  const values = en as Partial<Record<UiI18nLocaleKey, string>>
100
117
  const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other`
118
+
101
119
  return resolveTemplate(value, params)
102
120
  },
103
121
  }
122
+
104
123
  const fallback = createUiI18n(fallbackSource)
105
124
 
106
125
  const Context = createContext<UiI18n>(fallback)
107
126
 
108
127
  function UiI18nProvider(props: ParentProps<{ value: UiI18nSource }>) {
109
128
  const value = createUiI18n(props.value)
129
+
110
130
  return (
111
131
  <I18nProvider locale={(value.layoutLocale ?? value.locale)()}>
112
132
  <Context.Provider value={value}>{props.children}</Context.Provider>
@@ -1,4 +1,7 @@
1
1
  export * from "./helper"
2
+
2
3
  export * from "./file"
4
+
3
5
  export * from "./dialog"
6
+
4
7
  export * from "./i18n"
@@ -5,6 +5,7 @@ export function createMarkdownBase() {
5
5
  renderer: {
6
6
  link({ href, title, text }) {
7
7
  const titleAttr = title ? ` title="${title}"` : ""
8
+
8
9
  return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
9
10
  },
10
11
  },
@@ -15,7 +16,9 @@ let smallParser: Marked | undefined
15
16
 
16
17
  export function parseSmallMarkdown(text: string) {
17
18
  // Any possible KaTeX delimiter stays on the worker, including escaped ones.
18
- if (text.length > 1024 || text.includes("\\(") || text.includes("$$")) return
19
+ // Dollar math needs a pair, so a lone price such as `$5` can stay on this path.
20
+ if (text.length > 1024 || text.includes("\\(") || text.indexOf("$") !== text.lastIndexOf("$")) return
21
+
19
22
  // Ordinary prose does not need the block/inline lexer's cold regular expressions.
20
23
  // Keep every possible Markdown construct, autolink, and hard break on that lexer.
21
24
  if (
@@ -30,8 +33,10 @@ export function parseSmallMarkdown(text: string) {
30
33
  .map((paragraph) => `<p>${paragraph.replace(/["']/g, (value) => (value === '"' ? "&quot;" : "&#39;"))}</p>\n`)
31
34
  .join("")
32
35
  }
36
+
33
37
  const parser = (smallParser ??= createMarkdownBase())
34
38
  const paragraphs = text.replace(/\r\n?/g, "\n")
39
+
35
40
  // Inline formatting in ordinary paragraphs does not need the block lexer.
36
41
  // Any possible block opener, HTML, table, or reference definition stays on it.
37
42
  if (
@@ -45,11 +50,14 @@ export function parseSmallMarkdown(text: string) {
45
50
  .map((paragraph) => `<p>${parser.parseInline(paragraph, { async: false })}</p>\n`)
46
51
  .join("")
47
52
  }
53
+
48
54
  const tokens = parser.lexer(text)
49
55
  let code = false
50
56
  parser.walkTokens(tokens, (token) => {
51
57
  if (token.type === "code") code = true
52
58
  })
59
+
53
60
  if (code) return
61
+
54
62
  return parser.parser(tokens)
55
63
  }
@@ -4,55 +4,94 @@ import markedShiki from "marked-shiki"
4
4
  import { createMarkdownBase } from "./marked-base"
5
5
 
6
6
  export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise<string>) {
7
- return createMarkdownBase().use(katexExtension, markedShiki({ highlight }))
7
+ return createMarkdownBase().use(markdownMath, markedShiki({ highlight }))
8
8
  }
9
9
 
10
- const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
11
- const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/
10
+ // `$$...$$` renders as display math anywhere in a line.
11
+ // `$...$` follows Pandoc's boundaries so prices and shell variables stay text: the opening `$` needs a non-space
12
+ // after it, the closing `$` needs a non-space before it and no letter, digit, or underscore after it.
13
+ // Neither form spans a newline, and `$...$` cannot contain an unescaped `$`, so a failed `$...$` attempt stops at
14
+ // the next `$` and a failed `$$...$$` attempt stops at the end of the line.
15
+ const inlineDollarMathRegex =
16
+ /\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$|\$(?=[^\s$])((?:\\.|[^\\\n$])*?(?:\\\S|[^\s\\$]))\$(?!\w)/y
12
17
 
13
- const katexExtension: MarkedExtension = {
18
+ const inlineParenMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
19
+
20
+ const blockMathRegex = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/
21
+
22
+ function renderMath(token: Tokens.Generic) {
23
+ return katex.renderToString(token.text, { throwOnError: false, displayMode: token.displayMode })
24
+ }
25
+
26
+ export const markdownMath: MarkedExtension = {
14
27
  extensions: [
15
28
  {
16
- name: "inlineKatex",
29
+ name: "inlineDollarKatex",
30
+ level: "inline",
31
+ // marked calls `start` again for every text token with the rest of the paragraph, so it must not try the
32
+ // regex at each `$`; a `$` that fails the tokenizer just stays in the merged text.
33
+ start(src) {
34
+ const index = src.indexOf("$")
35
+
36
+ if (index === -1) return
37
+
38
+ return index
39
+ },
40
+ tokenizer(src) {
41
+ inlineDollarMathRegex.lastIndex = 0
42
+ const match = inlineDollarMathRegex.exec(src)
43
+
44
+ if (!match) return
45
+
46
+ return {
47
+ type: "inlineDollarKatex",
48
+ raw: match[0],
49
+ text: (match[1] ?? match[2]).trim(),
50
+ displayMode: match[1] !== undefined,
51
+ }
52
+ },
53
+ renderer: renderMath,
54
+ },
55
+ {
56
+ name: "inlineParenKatex",
17
57
  level: "inline",
18
58
  start(src) {
19
59
  const index = src.indexOf("\\(")
60
+
20
61
  if (index === -1) return
62
+
21
63
  return index
22
64
  },
23
65
  tokenizer(src) {
24
- const match = src.match(inlineMathRegex)
66
+ const match = src.match(inlineParenMathRegex)
67
+
25
68
  if (!match) return
69
+
26
70
  return {
27
- type: "inlineKatex",
71
+ type: "inlineParenKatex",
28
72
  raw: match[0],
29
73
  text: match[1].trim(),
30
74
  displayMode: false,
31
75
  }
32
76
  },
33
- renderer: renderKatexToken,
77
+ renderer: renderMath,
34
78
  },
35
79
  {
36
80
  name: "blockKatex",
37
81
  level: "block",
38
82
  tokenizer(src) {
39
83
  const match = src.match(blockMathRegex)
84
+
40
85
  if (!match) return
86
+
41
87
  return {
42
88
  type: "blockKatex",
43
89
  raw: match[0],
44
- text: match[1].trim(),
45
- displayMode: true,
90
+ text: match[2].trim(),
91
+ displayMode: match[1].length === 2,
46
92
  }
47
93
  },
48
- renderer: renderKatexToken,
94
+ renderer: (token) => renderMath(token) + "\n",
49
95
  },
50
96
  ],
51
97
  }
52
-
53
- function renderKatexToken(token: Tokens.Generic) {
54
- return katex.renderToString(typeof token.text === "string" ? token.text : "", {
55
- displayMode: token.displayMode === true,
56
- throwOnError: false,
57
- })
58
- }
@@ -17,8 +17,11 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
17
17
  langs: [],
18
18
  preferredHighlighter: "shiki-wasm",
19
19
  })
20
+
20
21
  const name = language in bundledLanguages ? language : "text"
22
+
21
23
  if (!highlighter.getLoadedLanguages().includes(name)) await highlighter.loadLanguage(name as BundledLanguage)
24
+
22
25
  return highlighter.codeToHtml(code, {
23
26
  lang: name,
24
27
  theme: "OpenCode",
@@ -15,6 +15,8 @@ export const WorkerPoolProvider = ctx.provider
15
15
 
16
16
  export function useWorkerPool(diffStyle: "unified" | "split" | undefined) {
17
17
  const pools = ctx.use()
18
+
18
19
  if (diffStyle === "split") return pools.split
20
+
19
21
  return pools.unified
20
22
  }
@@ -8,7 +8,9 @@ const segmenter =
8
8
 
9
9
  function first(value: string) {
10
10
  if (!value) return ""
11
+
11
12
  if (!segmenter) return Array.from(value)[0] ?? ""
13
+
12
14
  return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? ""
13
15
  }
14
16
 
@@ -33,7 +35,9 @@ export function Avatar(props: AvatarProps) {
33
35
  "classList",
34
36
  "style",
35
37
  ])
38
+
36
39
  const src = split.src // did this so i can zero it out to test fallback
40
+
37
41
  return (
38
42
  <div
39
43
  {...rest}
@@ -8,6 +8,7 @@ export interface BadgeProps extends ComponentProps<"span"> {
8
8
 
9
9
  export function Badge(props: BadgeProps) {
10
10
  const [split, rest] = splitProps(props, ["class", "classList", "children", "appearance", "variant"])
11
+
11
12
  return (
12
13
  <span
13
14
  {...rest}
@@ -11,11 +11,13 @@ export function DiffChanges(props: {
11
11
  ? props.changes.reduce((acc, diff) => acc + (diff.additions ?? 0), 0)
12
12
  : props.changes.additions,
13
13
  )
14
+
14
15
  const deletions = createMemo(() =>
15
16
  Array.isArray(props.changes)
16
17
  ? props.changes.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0)
17
18
  : props.changes.deletions,
18
19
  )
20
+
19
21
  const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
20
22
 
21
23
  return (
@@ -8,6 +8,7 @@ export interface KeybindProps extends ComponentProps<"div"> {
8
8
 
9
9
  export function Keybind(props: KeybindProps) {
10
10
  const [local, rest] = splitProps(props, ["keys", "variant", "class", "classList"])
11
+
11
12
  return (
12
13
  <div
13
14
  {...rest}
@@ -35,6 +35,7 @@ export interface LineCommentProps extends ComponentProps<"div"> {
35
35
 
36
36
  export function LineComment(props: LineCommentProps) {
37
37
  const [local, rest] = splitProps(props, ["comment", "selection", "actions", "class", "classList"])
38
+
38
39
  return (
39
40
  <div
40
41
  {...rest}
@@ -78,11 +79,13 @@ export interface LineCommentEditorProps extends Omit<ComponentProps<"div">, "chi
78
79
 
79
80
  function pathFilename(path: string) {
80
81
  const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
82
+
81
83
  return index === -1 ? path : path.slice(index + 1)
82
84
  }
83
85
 
84
86
  function pathDirectory(path: string) {
85
87
  const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
88
+
86
89
  return index === -1 ? "" : path.slice(0, index + 1)
87
90
  }
88
91
 
@@ -118,12 +121,16 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
118
121
 
119
122
  const currentMention = () => {
120
123
  const textarea = textareaRef
124
+
121
125
  if (!textarea) return
126
+
122
127
  if (!local.mention) return
128
+
123
129
  if (textarea.selectionStart !== textarea.selectionEnd) return
124
130
 
125
131
  const end = textarea.selectionStart
126
132
  const match = textarea.value.slice(0, end).match(/@(\S*)$/)
133
+
127
134
  if (!match) return
128
135
 
129
136
  return {
@@ -138,6 +145,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
138
145
 
139
146
  const textarea = textareaRef
140
147
  const query = currentMention()
148
+
141
149
  if (!textarea || !query) return
142
150
 
143
151
  const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
@@ -155,8 +163,10 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
155
163
  const mention = useFilteredList<{ path: string }>({
156
164
  items: async (query) => {
157
165
  if (!local.mention) return []
166
+
158
167
  if (!query.trim()) return []
159
168
  const paths = await local.mention.items(query)
169
+
160
170
  return paths.map((path) => ({ path }))
161
171
  },
162
172
  key: (item) => item.path,
@@ -167,8 +177,10 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
167
177
 
168
178
  const syncMention = () => {
169
179
  const item = currentMention()
180
+
170
181
  if (!item) {
171
182
  closeMention()
183
+
172
184
  return
173
185
  }
174
186
 
@@ -178,6 +190,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
178
190
 
179
191
  const selectActiveMention = () => {
180
192
  const items = mention.flat()
193
+
181
194
  if (items.length === 0) return
182
195
  const active = mention.active()
183
196
  selectMention(items.find((item) => item.path === active) ?? items[0])
@@ -185,6 +198,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
185
198
 
186
199
  const submit = () => {
187
200
  const v = local.value.trim()
201
+
188
202
  if (!v) return
189
203
  local.onSubmit(v)
190
204
  }
@@ -225,12 +239,14 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
225
239
  onSelect={() => syncMention()}
226
240
  onKeyDown={(e) => {
227
241
  e.stopPropagation()
242
+
228
243
  if (e.isComposing || e.keyCode === 229) return
229
244
 
230
245
  if (mentionOpen()) {
231
246
  if (e.key === "Escape") {
232
247
  e.preventDefault()
233
248
  closeMention()
249
+
234
250
  return
235
251
  }
236
252
 
@@ -238,14 +254,17 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
238
254
  if (mention.flat().length === 0) return
239
255
  e.preventDefault()
240
256
  selectActiveMention()
257
+
241
258
  return
242
259
  }
243
260
 
244
261
  const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
245
262
  const ctrlNav = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")
263
+
246
264
  if ((nav || ctrlNav) && mention.flat().length > 0) {
247
265
  mention.onKeyDown(e)
248
266
  e.preventDefault()
267
+
249
268
  return
250
269
  }
251
270
  }
@@ -254,8 +273,10 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
254
273
  e.preventDefault()
255
274
  e.currentTarget.blur()
256
275
  local.onCancel()
276
+
257
277
  return
258
278
  }
279
+
259
280
  if (e.key === "Enter" && !e.shiftKey) {
260
281
  e.preventDefault()
261
282
  submit()
@@ -268,6 +289,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
268
289
  {(item) => {
269
290
  const directory = item.path.endsWith("/") ? item.path : pathDirectory(item.path)
270
291
  const name = item.path.endsWith("/") ? "" : pathFilename(item.path)
292
+
271
293
  return (
272
294
  <button
273
295
  type="button"