reigncode-app 1.3.2

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 (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,151 @@
1
+ export function stripFileProtocol(input: string) {
2
+ if (!input.startsWith("file://")) return input
3
+ return input.slice("file://".length)
4
+ }
5
+
6
+ export function stripQueryAndHash(input: string) {
7
+ const hashIndex = input.indexOf("#")
8
+ const queryIndex = input.indexOf("?")
9
+
10
+ if (hashIndex !== -1 && queryIndex !== -1) {
11
+ return input.slice(0, Math.min(hashIndex, queryIndex))
12
+ }
13
+
14
+ if (hashIndex !== -1) return input.slice(0, hashIndex)
15
+ if (queryIndex !== -1) return input.slice(0, queryIndex)
16
+ return input
17
+ }
18
+
19
+ export function unquoteGitPath(input: string) {
20
+ if (!input.startsWith('"')) return input
21
+ if (!input.endsWith('"')) return input
22
+ const body = input.slice(1, -1)
23
+ const bytes: number[] = []
24
+
25
+ for (let i = 0; i < body.length; i++) {
26
+ const char = body[i]!
27
+ if (char !== "\\") {
28
+ bytes.push(char.charCodeAt(0))
29
+ continue
30
+ }
31
+
32
+ const next = body[i + 1]
33
+ if (!next) {
34
+ bytes.push("\\".charCodeAt(0))
35
+ continue
36
+ }
37
+
38
+ if (next >= "0" && next <= "7") {
39
+ const chunk = body.slice(i + 1, i + 4)
40
+ const match = chunk.match(/^[0-7]{1,3}/)
41
+ if (!match) {
42
+ bytes.push(next.charCodeAt(0))
43
+ i++
44
+ continue
45
+ }
46
+ bytes.push(parseInt(match[0], 8))
47
+ i += match[0].length
48
+ continue
49
+ }
50
+
51
+ const escaped =
52
+ next === "n"
53
+ ? "\n"
54
+ : next === "r"
55
+ ? "\r"
56
+ : next === "t"
57
+ ? "\t"
58
+ : next === "b"
59
+ ? "\b"
60
+ : next === "f"
61
+ ? "\f"
62
+ : next === "v"
63
+ ? "\v"
64
+ : next === "\\" || next === '"'
65
+ ? next
66
+ : undefined
67
+
68
+ bytes.push((escaped ?? next).charCodeAt(0))
69
+ i++
70
+ }
71
+
72
+ return new TextDecoder().decode(new Uint8Array(bytes))
73
+ }
74
+
75
+ export function decodeFilePath(input: string) {
76
+ try {
77
+ return decodeURIComponent(input)
78
+ } catch {
79
+ return input
80
+ }
81
+ }
82
+
83
+ export function encodeFilePath(filepath: string): string {
84
+ // Normalize Windows paths: convert backslashes to forward slashes
85
+ let normalized = filepath.replace(/\\/g, "/")
86
+
87
+ // Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
88
+ if (/^[A-Za-z]:/.test(normalized)) {
89
+ normalized = "/" + normalized
90
+ }
91
+
92
+ // Encode each path segment (preserving forward slashes as path separators)
93
+ // Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
94
+ // can reliably detect drives.
95
+ return normalized
96
+ .split("/")
97
+ .map((segment, index) => {
98
+ if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
99
+ return encodeURIComponent(segment)
100
+ })
101
+ .join("/")
102
+ }
103
+
104
+ export function createPathHelpers(scope: () => string) {
105
+ const normalize = (input: string) => {
106
+ const root = scope()
107
+
108
+ let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
109
+
110
+ // Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
111
+ // Only case-insensitive on Windows (drive letter or UNC paths)
112
+ const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\")
113
+ const canonRoot = windows ? root.replace(/\\/g, "/").toLowerCase() : root.replace(/\\/g, "/")
114
+ const canonPath = windows ? path.replace(/\\/g, "/").toLowerCase() : path.replace(/\\/g, "/")
115
+ if (
116
+ canonPath.startsWith(canonRoot) &&
117
+ (canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
118
+ ) {
119
+ // Slice from original path to preserve native separators
120
+ path = path.slice(root.length)
121
+ }
122
+
123
+ if (path.startsWith("./") || path.startsWith(".\\")) {
124
+ path = path.slice(2)
125
+ }
126
+
127
+ if (path.startsWith("/") || path.startsWith("\\")) {
128
+ path = path.slice(1)
129
+ }
130
+ return path
131
+ }
132
+
133
+ const tab = (input: string) => {
134
+ const path = normalize(input)
135
+ return `file://${encodeFilePath(path)}`
136
+ }
137
+
138
+ const pathFromTab = (tabValue: string) => {
139
+ if (!tabValue.startsWith("file://")) return
140
+ return normalize(tabValue)
141
+ }
142
+
143
+ const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
144
+
145
+ return {
146
+ normalize,
147
+ tab,
148
+ pathFromTab,
149
+ normalizeDir,
150
+ }
151
+ }
@@ -0,0 +1,170 @@
1
+ import { createStore, produce, reconcile } from "solid-js/store"
2
+ import type { FileNode } from "@reign-labs/sdk/v2"
3
+
4
+ type DirectoryState = {
5
+ expanded: boolean
6
+ loaded?: boolean
7
+ loading?: boolean
8
+ error?: string
9
+ children?: string[]
10
+ }
11
+
12
+ type TreeStoreOptions = {
13
+ scope: () => string
14
+ normalizeDir: (input: string) => string
15
+ list: (input: string) => Promise<FileNode[]>
16
+ onError: (message: string) => void
17
+ }
18
+
19
+ export function createFileTreeStore(options: TreeStoreOptions) {
20
+ const [tree, setTree] = createStore<{
21
+ node: Record<string, FileNode>
22
+ dir: Record<string, DirectoryState>
23
+ }>({
24
+ node: {},
25
+ dir: { "": { expanded: true } },
26
+ })
27
+
28
+ const inflight = new Map<string, Promise<void>>()
29
+
30
+ const reset = () => {
31
+ inflight.clear()
32
+ setTree("node", reconcile({}))
33
+ setTree("dir", reconcile({}))
34
+ setTree("dir", "", { expanded: true })
35
+ }
36
+
37
+ const ensureDir = (path: string) => {
38
+ if (tree.dir[path]) return
39
+ setTree("dir", path, { expanded: false })
40
+ }
41
+
42
+ const listDir = (input: string, opts?: { force?: boolean }) => {
43
+ const dir = options.normalizeDir(input)
44
+ ensureDir(dir)
45
+
46
+ const current = tree.dir[dir]
47
+ if (!opts?.force && current?.loaded) return Promise.resolve()
48
+
49
+ const pending = inflight.get(dir)
50
+ if (pending) return pending
51
+
52
+ setTree(
53
+ "dir",
54
+ dir,
55
+ produce((draft) => {
56
+ draft.loading = true
57
+ draft.error = undefined
58
+ }),
59
+ )
60
+
61
+ const directory = options.scope()
62
+
63
+ const promise = options
64
+ .list(dir)
65
+ .then((nodes) => {
66
+ if (options.scope() !== directory) return
67
+ const prevChildren = tree.dir[dir]?.children ?? []
68
+ const nextChildren = nodes.map((node) => node.path)
69
+ const nextSet = new Set(nextChildren)
70
+
71
+ setTree(
72
+ "node",
73
+ produce((draft) => {
74
+ const removedDirs: string[] = []
75
+
76
+ for (const child of prevChildren) {
77
+ if (nextSet.has(child)) continue
78
+ const existing = draft[child]
79
+ if (existing?.type === "directory") removedDirs.push(child)
80
+ delete draft[child]
81
+ }
82
+
83
+ if (removedDirs.length > 0) {
84
+ const keys = Object.keys(draft)
85
+ for (const key of keys) {
86
+ for (const removed of removedDirs) {
87
+ if (!key.startsWith(removed + "/")) continue
88
+ delete draft[key]
89
+ break
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const node of nodes) {
95
+ draft[node.path] = node
96
+ }
97
+ }),
98
+ )
99
+
100
+ setTree(
101
+ "dir",
102
+ dir,
103
+ produce((draft) => {
104
+ draft.loaded = true
105
+ draft.loading = false
106
+ draft.children = nextChildren
107
+ }),
108
+ )
109
+ })
110
+ .catch((e) => {
111
+ if (options.scope() !== directory) return
112
+ setTree(
113
+ "dir",
114
+ dir,
115
+ produce((draft) => {
116
+ draft.loading = false
117
+ draft.error = e.message
118
+ }),
119
+ )
120
+ options.onError(e.message)
121
+ })
122
+ .finally(() => {
123
+ inflight.delete(dir)
124
+ })
125
+
126
+ inflight.set(dir, promise)
127
+ return promise
128
+ }
129
+
130
+ const expandDir = (input: string) => {
131
+ const dir = options.normalizeDir(input)
132
+ ensureDir(dir)
133
+ setTree("dir", dir, "expanded", true)
134
+ void listDir(dir)
135
+ }
136
+
137
+ const collapseDir = (input: string) => {
138
+ const dir = options.normalizeDir(input)
139
+ ensureDir(dir)
140
+ setTree("dir", dir, "expanded", false)
141
+ }
142
+
143
+ const dirState = (input: string) => {
144
+ const dir = options.normalizeDir(input)
145
+ return tree.dir[dir]
146
+ }
147
+
148
+ const children = (input: string) => {
149
+ const dir = options.normalizeDir(input)
150
+ const ids = tree.dir[dir]?.children
151
+ if (!ids) return []
152
+ const out: FileNode[] = []
153
+ for (const id of ids) {
154
+ const node = tree.node[id]
155
+ if (node) out.push(node)
156
+ }
157
+ return out
158
+ }
159
+
160
+ return {
161
+ listDir,
162
+ expandDir,
163
+ collapseDir,
164
+ dirState,
165
+ children,
166
+ node: (path: string) => tree.node[path],
167
+ isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
168
+ reset,
169
+ }
170
+ }
@@ -0,0 +1,41 @@
1
+ import type { FileContent } from "@reign-labs/sdk/v2"
2
+
3
+ export type FileSelection = {
4
+ startLine: number
5
+ startChar: number
6
+ endLine: number
7
+ endChar: number
8
+ }
9
+
10
+ export type SelectedLineRange = {
11
+ start: number
12
+ end: number
13
+ side?: "additions" | "deletions"
14
+ endSide?: "additions" | "deletions"
15
+ }
16
+
17
+ export type FileViewState = {
18
+ scrollTop?: number
19
+ scrollLeft?: number
20
+ selectedLines?: SelectedLineRange | null
21
+ }
22
+
23
+ export type FileState = {
24
+ path: string
25
+ name: string
26
+ loaded?: boolean
27
+ loading?: boolean
28
+ error?: string
29
+ content?: FileContent
30
+ }
31
+
32
+ export function selectionFromLines(range: SelectedLineRange): FileSelection {
33
+ const startLine = Math.min(range.start, range.end)
34
+ const endLine = Math.max(range.start, range.end)
35
+ return {
36
+ startLine,
37
+ endLine,
38
+ startChar: 0,
39
+ endChar: 0,
40
+ }
41
+ }
@@ -0,0 +1,146 @@
1
+ import { createEffect, createRoot } from "solid-js"
2
+ import { createStore, produce } from "solid-js/store"
3
+ import { Persist, persisted } from "@/utils/persist"
4
+ import { createScopedCache } from "@/utils/scoped-cache"
5
+ import type { FileViewState, SelectedLineRange } from "./types"
6
+
7
+ const WORKSPACE_KEY = "__workspace__"
8
+ const MAX_FILE_VIEW_SESSIONS = 20
9
+ const MAX_VIEW_FILES = 500
10
+
11
+ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
12
+ if (range.start <= range.end) return { ...range }
13
+
14
+ const startSide = range.side
15
+ const endSide = range.endSide ?? startSide
16
+
17
+ return {
18
+ ...range,
19
+ start: range.end,
20
+ end: range.start,
21
+ side: endSide,
22
+ endSide: startSide !== endSide ? startSide : undefined,
23
+ }
24
+ }
25
+
26
+ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) {
27
+ if (!a && !b) return true
28
+ if (!a || !b) return false
29
+ const left = normalizeSelectedLines(a)
30
+ const right = normalizeSelectedLines(b)
31
+ return (
32
+ left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide
33
+ )
34
+ }
35
+
36
+ function createViewSession(dir: string, id: string | undefined) {
37
+ const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
38
+
39
+ const [view, setView, _, ready] = persisted(
40
+ Persist.scoped(dir, id, "file-view", [legacyViewKey]),
41
+ createStore<{
42
+ file: Record<string, FileViewState>
43
+ }>({
44
+ file: {},
45
+ }),
46
+ )
47
+
48
+ const meta = { pruned: false }
49
+
50
+ const pruneView = (keep?: string) => {
51
+ const keys = Object.keys(view.file)
52
+ if (keys.length <= MAX_VIEW_FILES) return
53
+
54
+ const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
55
+ if (drop.length === 0) return
56
+
57
+ setView(
58
+ produce((draft) => {
59
+ for (const key of drop) {
60
+ delete draft.file[key]
61
+ }
62
+ }),
63
+ )
64
+ }
65
+
66
+ createEffect(() => {
67
+ if (!ready()) return
68
+ if (meta.pruned) return
69
+ meta.pruned = true
70
+ pruneView()
71
+ })
72
+
73
+ const scrollTop = (path: string) => view.file[path]?.scrollTop
74
+ const scrollLeft = (path: string) => view.file[path]?.scrollLeft
75
+ const selectedLines = (path: string) => view.file[path]?.selectedLines
76
+
77
+ const setScrollTop = (path: string, top: number) => {
78
+ setView(
79
+ produce((draft) => {
80
+ const file = draft.file[path] ?? (draft.file[path] = {})
81
+ if (file.scrollTop === top) return
82
+ file.scrollTop = top
83
+ }),
84
+ )
85
+ pruneView(path)
86
+ }
87
+
88
+ const setScrollLeft = (path: string, left: number) => {
89
+ setView(
90
+ produce((draft) => {
91
+ const file = draft.file[path] ?? (draft.file[path] = {})
92
+ if (file.scrollLeft === left) return
93
+ file.scrollLeft = left
94
+ }),
95
+ )
96
+ pruneView(path)
97
+ }
98
+
99
+ const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
100
+ const next = range ? normalizeSelectedLines(range) : null
101
+ setView(
102
+ produce((draft) => {
103
+ const file = draft.file[path] ?? (draft.file[path] = {})
104
+ if (equalSelectedLines(file.selectedLines, next)) return
105
+ file.selectedLines = next
106
+ }),
107
+ )
108
+ pruneView(path)
109
+ }
110
+
111
+ return {
112
+ ready,
113
+ scrollTop,
114
+ scrollLeft,
115
+ selectedLines,
116
+ setScrollTop,
117
+ setScrollLeft,
118
+ setSelectedLines,
119
+ }
120
+ }
121
+
122
+ export function createFileViewCache() {
123
+ const cache = createScopedCache(
124
+ (key) => {
125
+ const split = key.lastIndexOf("\n")
126
+ const dir = split >= 0 ? key.slice(0, split) : key
127
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
128
+ return createRoot((dispose) => ({
129
+ value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
130
+ dispose,
131
+ }))
132
+ },
133
+ {
134
+ maxEntries: MAX_FILE_VIEW_SESSIONS,
135
+ dispose: (entry) => entry.dispose(),
136
+ },
137
+ )
138
+
139
+ return {
140
+ load: (dir: string, id: string | undefined) => {
141
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
142
+ return cache.get(key).value
143
+ },
144
+ clear: () => cache.clear(),
145
+ }
146
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { invalidateFromWatcher } from "./watcher"
3
+
4
+ describe("file watcher invalidation", () => {
5
+ test("reloads open files and refreshes loaded parent on add", () => {
6
+ const loads: string[] = []
7
+ const refresh: string[] = []
8
+ invalidateFromWatcher(
9
+ {
10
+ type: "file.watcher.updated",
11
+ properties: {
12
+ file: "src/new.ts",
13
+ event: "add",
14
+ },
15
+ },
16
+ {
17
+ normalize: (input) => input,
18
+ hasFile: (path) => path === "src/new.ts",
19
+ loadFile: (path) => loads.push(path),
20
+ node: () => undefined,
21
+ isDirLoaded: (path) => path === "src",
22
+ refreshDir: (path) => refresh.push(path),
23
+ },
24
+ )
25
+
26
+ expect(loads).toEqual(["src/new.ts"])
27
+ expect(refresh).toEqual(["src"])
28
+ })
29
+
30
+ test("reloads files that are open in tabs", () => {
31
+ const loads: string[] = []
32
+
33
+ invalidateFromWatcher(
34
+ {
35
+ type: "file.watcher.updated",
36
+ properties: {
37
+ file: "src/open.ts",
38
+ event: "change",
39
+ },
40
+ },
41
+ {
42
+ normalize: (input) => input,
43
+ hasFile: () => false,
44
+ isOpen: (path) => path === "src/open.ts",
45
+ loadFile: (path) => loads.push(path),
46
+ node: () => ({
47
+ path: "src/open.ts",
48
+ type: "file",
49
+ name: "open.ts",
50
+ absolute: "/repo/src/open.ts",
51
+ ignored: false,
52
+ }),
53
+ isDirLoaded: () => false,
54
+ refreshDir: () => {},
55
+ },
56
+ )
57
+
58
+ expect(loads).toEqual(["src/open.ts"])
59
+ })
60
+
61
+ test("refreshes only changed loaded directory nodes", () => {
62
+ const refresh: string[] = []
63
+
64
+ invalidateFromWatcher(
65
+ {
66
+ type: "file.watcher.updated",
67
+ properties: {
68
+ file: "src",
69
+ event: "change",
70
+ },
71
+ },
72
+ {
73
+ normalize: (input) => input,
74
+ hasFile: () => false,
75
+ loadFile: () => {},
76
+ node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
77
+ isDirLoaded: (path) => path === "src",
78
+ refreshDir: (path) => refresh.push(path),
79
+ },
80
+ )
81
+
82
+ invalidateFromWatcher(
83
+ {
84
+ type: "file.watcher.updated",
85
+ properties: {
86
+ file: "src/file.ts",
87
+ event: "change",
88
+ },
89
+ },
90
+ {
91
+ normalize: (input) => input,
92
+ hasFile: () => false,
93
+ loadFile: () => {},
94
+ node: () => ({
95
+ path: "src/file.ts",
96
+ type: "file",
97
+ name: "file.ts",
98
+ absolute: "/repo/src/file.ts",
99
+ ignored: false,
100
+ }),
101
+ isDirLoaded: () => true,
102
+ refreshDir: (path) => refresh.push(path),
103
+ },
104
+ )
105
+
106
+ expect(refresh).toEqual(["src"])
107
+ })
108
+
109
+ test("ignores invalid or git watcher updates", () => {
110
+ const refresh: string[] = []
111
+
112
+ invalidateFromWatcher(
113
+ {
114
+ type: "file.watcher.updated",
115
+ properties: {
116
+ file: ".git/index.lock",
117
+ event: "change",
118
+ },
119
+ },
120
+ {
121
+ normalize: (input) => input,
122
+ hasFile: () => true,
123
+ loadFile: () => {
124
+ throw new Error("should not load")
125
+ },
126
+ node: () => undefined,
127
+ isDirLoaded: () => true,
128
+ refreshDir: (path) => refresh.push(path),
129
+ },
130
+ )
131
+
132
+ invalidateFromWatcher(
133
+ {
134
+ type: "project.updated",
135
+ properties: {},
136
+ },
137
+ {
138
+ normalize: (input) => input,
139
+ hasFile: () => false,
140
+ loadFile: () => {},
141
+ node: () => undefined,
142
+ isDirLoaded: () => true,
143
+ refreshDir: (path) => refresh.push(path),
144
+ },
145
+ )
146
+
147
+ expect(refresh).toEqual([])
148
+ })
149
+ })
@@ -0,0 +1,53 @@
1
+ import type { FileNode } from "@reign-labs/sdk/v2"
2
+
3
+ type WatcherEvent = {
4
+ type: string
5
+ properties: unknown
6
+ }
7
+
8
+ type WatcherOps = {
9
+ normalize: (input: string) => string
10
+ hasFile: (path: string) => boolean
11
+ isOpen?: (path: string) => boolean
12
+ loadFile: (path: string) => void
13
+ node: (path: string) => FileNode | undefined
14
+ isDirLoaded: (path: string) => boolean
15
+ refreshDir: (path: string) => void
16
+ }
17
+
18
+ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
19
+ if (event.type !== "file.watcher.updated") return
20
+ const props =
21
+ typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
22
+ const rawPath = typeof props?.file === "string" ? props.file : undefined
23
+ const kind = typeof props?.event === "string" ? props.event : undefined
24
+ if (!rawPath) return
25
+ if (!kind) return
26
+
27
+ const path = ops.normalize(rawPath)
28
+ if (!path) return
29
+ if (path.startsWith(".git/")) return
30
+
31
+ if (ops.hasFile(path) || ops.isOpen?.(path)) {
32
+ ops.loadFile(path)
33
+ }
34
+
35
+ if (kind === "change") {
36
+ const dir = (() => {
37
+ if (path === "") return ""
38
+ const node = ops.node(path)
39
+ if (node?.type !== "directory") return
40
+ return path
41
+ })()
42
+ if (dir === undefined) return
43
+ if (!ops.isDirLoaded(dir)) return
44
+ ops.refreshDir(dir)
45
+ return
46
+ }
47
+ if (kind !== "add" && kind !== "unlink") return
48
+
49
+ const parent = path.split("/").slice(0, -1).join("/")
50
+ if (!ops.isDirLoaded(parent)) return
51
+
52
+ ops.refreshDir(parent)
53
+ }