local.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { createStore, produce, reconcile } from "solid-js/store"
  2. import { batch, createMemo } from "solid-js"
  3. import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
  4. import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
  5. import { createSimpleContext } from "@opencode-ai/ui/context"
  6. import { useSDK } from "./sdk"
  7. import { useSync } from "./sync"
  8. import { base64Encode } from "@opencode-ai/util/encode"
  9. import { useProviders } from "@/hooks/use-providers"
  10. import { DateTime } from "luxon"
  11. import { persisted } from "@/utils/persist"
  12. import { showToast } from "@opencode-ai/ui/toast"
  13. export type LocalFile = FileNode &
  14. Partial<{
  15. loaded: boolean
  16. pinned: boolean
  17. expanded: boolean
  18. content: FileContent
  19. selection: { startLine: number; startChar: number; endLine: number; endChar: number }
  20. scrollTop: number
  21. view: "raw" | "diff-unified" | "diff-split"
  22. folded: string[]
  23. selectedChange: number
  24. status: FileStatus
  25. }>
  26. export type TextSelection = LocalFile["selection"]
  27. export type View = LocalFile["view"]
  28. export type LocalModel = Omit<Model, "provider"> & {
  29. provider: Provider
  30. latest?: boolean
  31. }
  32. export type ModelKey = { providerID: string; modelID: string }
  33. export type FileContext = { type: "file"; path: string; selection?: TextSelection }
  34. export type ContextItem = FileContext
  35. export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
  36. name: "Local",
  37. init: () => {
  38. const sdk = useSDK()
  39. const sync = useSync()
  40. const providers = useProviders()
  41. function isModelValid(model: ModelKey) {
  42. const provider = providers.all().find((x) => x.id === model.providerID)
  43. return (
  44. !!provider?.models[model.modelID] &&
  45. providers
  46. .connected()
  47. .map((p) => p.id)
  48. .includes(model.providerID)
  49. )
  50. }
  51. function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
  52. for (const modelFn of modelFns) {
  53. const model = modelFn()
  54. if (!model) continue
  55. if (isModelValid(model)) return model
  56. }
  57. }
  58. const agent = (() => {
  59. const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
  60. const [store, setStore] = createStore<{
  61. current: string
  62. }>({
  63. current: list()[0].name,
  64. })
  65. return {
  66. list,
  67. current() {
  68. return list().find((x) => x.name === store.current)!
  69. },
  70. set(name: string | undefined) {
  71. setStore("current", name ?? list()[0].name)
  72. },
  73. move(direction: 1 | -1) {
  74. let next = list().findIndex((x) => x.name === store.current) + direction
  75. if (next < 0) next = list().length - 1
  76. if (next >= list().length) next = 0
  77. const value = list()[next]
  78. setStore("current", value.name)
  79. if (value.model)
  80. model.set({
  81. providerID: value.model.providerID,
  82. modelID: value.model.modelID,
  83. })
  84. },
  85. }
  86. })()
  87. const model = (() => {
  88. const [store, setStore, _, modelReady] = persisted(
  89. "model.v1",
  90. createStore<{
  91. user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
  92. recent: ModelKey[]
  93. }>({
  94. user: [],
  95. recent: [],
  96. }),
  97. )
  98. const [ephemeral, setEphemeral] = createStore<{
  99. model: Record<string, ModelKey>
  100. }>({
  101. model: {},
  102. })
  103. const available = createMemo(() =>
  104. providers.connected().flatMap((p) =>
  105. Object.values(p.models).map((m) => ({
  106. ...m,
  107. provider: p,
  108. })),
  109. ),
  110. )
  111. const latest = createMemo(() =>
  112. pipe(
  113. available(),
  114. filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
  115. groupBy((x) => x.provider.id),
  116. mapValues((models) =>
  117. pipe(
  118. models,
  119. groupBy((x) => x.family),
  120. values(),
  121. (groups) =>
  122. groups.flatMap((g) => {
  123. const first = firstBy(g, [(x) => x.release_date, "desc"])
  124. return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
  125. }),
  126. ),
  127. ),
  128. values(),
  129. flat(),
  130. ),
  131. )
  132. const list = createMemo(() =>
  133. available().map((m) => ({
  134. ...m,
  135. name: m.name.replace("(latest)", "").trim(),
  136. latest: m.name.includes("(latest)"),
  137. })),
  138. )
  139. const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
  140. const fallbackModel = createMemo(() => {
  141. if (sync.data.config.model) {
  142. const [providerID, modelID] = sync.data.config.model.split("/")
  143. if (isModelValid({ providerID, modelID })) {
  144. return {
  145. providerID,
  146. modelID,
  147. }
  148. }
  149. }
  150. for (const item of store.recent) {
  151. if (isModelValid(item)) {
  152. return item
  153. }
  154. }
  155. for (const p of providers.connected()) {
  156. if (p.id in providers.default()) {
  157. return {
  158. providerID: p.id,
  159. modelID: providers.default()[p.id],
  160. }
  161. }
  162. }
  163. throw new Error("No default model found")
  164. })
  165. const current = createMemo(() => {
  166. const a = agent.current()
  167. const key = getFirstValidModel(
  168. () => ephemeral.model[a.name],
  169. () => a.model,
  170. fallbackModel,
  171. )!
  172. return find(key)
  173. })
  174. const recent = createMemo(() => store.recent.map(find).filter(Boolean))
  175. const cycle = (direction: 1 | -1) => {
  176. const recentList = recent()
  177. const currentModel = current()
  178. if (!currentModel) return
  179. const index = recentList.findIndex(
  180. (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
  181. )
  182. if (index === -1) return
  183. let next = index + direction
  184. if (next < 0) next = recentList.length - 1
  185. if (next >= recentList.length) next = 0
  186. const val = recentList[next]
  187. if (!val) return
  188. model.set({
  189. providerID: val.provider.id,
  190. modelID: val.id,
  191. })
  192. }
  193. function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
  194. const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
  195. if (index >= 0) {
  196. setStore("user", index, { visibility })
  197. } else {
  198. setStore("user", store.user.length, { ...model, visibility })
  199. }
  200. }
  201. return {
  202. ready: modelReady,
  203. current,
  204. recent,
  205. list,
  206. cycle,
  207. set(model: ModelKey | undefined, options?: { recent?: boolean }) {
  208. batch(() => {
  209. setEphemeral("model", agent.current().name, model ?? fallbackModel())
  210. if (model) updateVisibility(model, "show")
  211. if (options?.recent && model) {
  212. const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
  213. if (uniq.length > 5) uniq.pop()
  214. setStore("recent", uniq)
  215. }
  216. })
  217. },
  218. visible(model: ModelKey) {
  219. const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
  220. return (
  221. user?.visibility !== "hide" &&
  222. (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
  223. user?.visibility === "show")
  224. )
  225. },
  226. setVisibility(model: ModelKey, visible: boolean) {
  227. updateVisibility(model, visible ? "show" : "hide")
  228. },
  229. }
  230. })()
  231. const file = (() => {
  232. const [store, setStore] = createStore<{
  233. node: Record<string, LocalFile>
  234. }>({
  235. node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
  236. })
  237. // const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
  238. // const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
  239. // createEffect((prev: FileStatus[]) => {
  240. // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
  241. // for (const p of removed) {
  242. // setStore(
  243. // "node",
  244. // p.path,
  245. // produce((draft) => {
  246. // draft.status = undefined
  247. // draft.view = "raw"
  248. // }),
  249. // )
  250. // load(p.path)
  251. // }
  252. // for (const p of sync.data.changes) {
  253. // if (store.node[p.path] === undefined) {
  254. // fetch(p.path).then(() => {
  255. // if (store.node[p.path] === undefined) return
  256. // setStore("node", p.path, "status", p)
  257. // })
  258. // } else {
  259. // setStore("node", p.path, "status", p)
  260. // }
  261. // }
  262. // return sync.data.changes
  263. // }, sync.data.changes)
  264. // const changed = (path: string) => {
  265. // const node = store.node[path]
  266. // if (node?.status) return true
  267. // const set = changeset()
  268. // if (set.has(path)) return true
  269. // for (const p of set) {
  270. // if (p.startsWith(path ? path + "/" : "")) return true
  271. // }
  272. // return false
  273. // }
  274. // const resetNode = (path: string) => {
  275. // setStore("node", path, {
  276. // loaded: undefined,
  277. // pinned: undefined,
  278. // content: undefined,
  279. // selection: undefined,
  280. // scrollTop: undefined,
  281. // folded: undefined,
  282. // view: undefined,
  283. // selectedChange: undefined,
  284. // })
  285. // }
  286. const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
  287. const load = async (path: string) => {
  288. const relativePath = relative(path)
  289. await sdk.client.file
  290. .read({ path: relativePath })
  291. .then((x) => {
  292. if (!store.node[relativePath]) return
  293. setStore(
  294. "node",
  295. relativePath,
  296. produce((draft) => {
  297. draft.loaded = true
  298. draft.content = x.data
  299. }),
  300. )
  301. })
  302. .catch((e) => {
  303. showToast({
  304. variant: "error",
  305. title: "Failed to load file",
  306. description: e.message,
  307. })
  308. })
  309. }
  310. const fetch = async (path: string) => {
  311. const relativePath = relative(path)
  312. const parent = relativePath.split("/").slice(0, -1).join("/")
  313. if (parent) {
  314. await list(parent)
  315. }
  316. }
  317. const init = async (path: string) => {
  318. const relativePath = relative(path)
  319. if (!store.node[relativePath]) await fetch(path)
  320. if (store.node[relativePath]?.loaded) return
  321. return load(relativePath)
  322. }
  323. const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
  324. const relativePath = relative(path)
  325. if (!store.node[relativePath]) await fetch(path)
  326. // setStore("opened", (x) => {
  327. // if (x.includes(relativePath)) return x
  328. // return [
  329. // ...opened()
  330. // .filter((x) => x.pinned)
  331. // .map((x) => x.path),
  332. // relativePath,
  333. // ]
  334. // })
  335. // setStore("active", relativePath)
  336. context.addActive()
  337. if (options?.pinned) setStore("node", path, "pinned", true)
  338. if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
  339. if (store.node[relativePath]?.loaded) return
  340. return load(relativePath)
  341. }
  342. const list = async (path: string) => {
  343. return sdk.client.file
  344. .list({ path: path + "/" })
  345. .then((x) => {
  346. setStore(
  347. "node",
  348. produce((draft) => {
  349. x.data!.forEach((node) => {
  350. if (node.path in draft) return
  351. draft[node.path] = node
  352. })
  353. }),
  354. )
  355. })
  356. .catch(() => {})
  357. }
  358. const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
  359. const searchFilesAndDirectories = (query: string) =>
  360. sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
  361. sdk.event.listen((e) => {
  362. const event = e.details
  363. switch (event.type) {
  364. case "file.watcher.updated":
  365. const relativePath = relative(event.properties.file)
  366. if (relativePath.startsWith(".git/")) return
  367. if (store.node[relativePath]) load(relativePath)
  368. break
  369. }
  370. })
  371. return {
  372. node: async (path: string) => {
  373. if (!store.node[path] || !store.node[path].loaded) {
  374. await init(path)
  375. }
  376. return store.node[path]
  377. },
  378. update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
  379. open,
  380. load,
  381. init,
  382. expand(path: string) {
  383. setStore("node", path, "expanded", true)
  384. if (store.node[path]?.loaded) return
  385. setStore("node", path, "loaded", true)
  386. list(path)
  387. },
  388. collapse(path: string) {
  389. setStore("node", path, "expanded", false)
  390. },
  391. select(path: string, selection: TextSelection | undefined) {
  392. setStore("node", path, "selection", selection)
  393. },
  394. scroll(path: string, scrollTop: number) {
  395. setStore("node", path, "scrollTop", scrollTop)
  396. },
  397. view(path: string): View {
  398. const n = store.node[path]
  399. return n && n.view ? n.view : "raw"
  400. },
  401. setView(path: string, view: View) {
  402. setStore("node", path, "view", view)
  403. },
  404. unfold(path: string, key: string) {
  405. setStore("node", path, "folded", (xs) => {
  406. const a = xs ?? []
  407. if (a.includes(key)) return a
  408. return [...a, key]
  409. })
  410. },
  411. fold(path: string, key: string) {
  412. setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
  413. },
  414. folded(path: string) {
  415. const n = store.node[path]
  416. return n && n.folded ? n.folded : []
  417. },
  418. changeIndex(path: string) {
  419. return store.node[path]?.selectedChange
  420. },
  421. setChangeIndex(path: string, index: number | undefined) {
  422. setStore("node", path, "selectedChange", index)
  423. },
  424. // changes,
  425. // changed,
  426. children(path: string) {
  427. return Object.values(store.node).filter(
  428. (x) =>
  429. x.path.startsWith(path) &&
  430. x.path !== path &&
  431. !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
  432. )
  433. },
  434. searchFiles,
  435. searchFilesAndDirectories,
  436. relative,
  437. }
  438. })()
  439. const context = (() => {
  440. const [store, setStore] = createStore<{
  441. activeTab: boolean
  442. files: string[]
  443. activeFile?: string
  444. items: (ContextItem & { key: string })[]
  445. }>({
  446. activeTab: true,
  447. files: [],
  448. items: [],
  449. })
  450. const files = createMemo(() => store.files.map((x) => file.node(x)))
  451. const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
  452. return {
  453. all() {
  454. return store.items
  455. },
  456. // active() {
  457. // return store.activeTab ? file.active() : undefined
  458. // },
  459. addActive() {
  460. setStore("activeTab", true)
  461. },
  462. removeActive() {
  463. setStore("activeTab", false)
  464. },
  465. add(item: ContextItem) {
  466. let key = item.type
  467. switch (item.type) {
  468. case "file":
  469. key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
  470. break
  471. }
  472. if (store.items.find((x) => x.key === key)) return
  473. setStore("items", (x) => [...x, { key, ...item }])
  474. },
  475. remove(key: string) {
  476. setStore("items", (x) => x.filter((x) => x.key !== key))
  477. },
  478. files,
  479. openFile(path: string) {
  480. file.init(path).then(() => {
  481. setStore("files", (x) => [...x, path])
  482. setStore("activeFile", path)
  483. })
  484. },
  485. activeFile,
  486. setActiveFile(path: string | undefined) {
  487. setStore("activeFile", path)
  488. },
  489. }
  490. })()
  491. const result = {
  492. slug: createMemo(() => base64Encode(sdk.directory)),
  493. model,
  494. agent,
  495. file,
  496. context,
  497. }
  498. return result
  499. },
  500. })