layout.tsx 84 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437
  1. import {
  2. createEffect,
  3. createMemo,
  4. createResource,
  5. For,
  6. on,
  7. onCleanup,
  8. onMount,
  9. ParentProps,
  10. Show,
  11. untrack,
  12. type Accessor,
  13. } from "solid-js"
  14. import { makeEventListener } from "@solid-primitives/event-listener"
  15. import { useNavigate, useParams } from "@solidjs/router"
  16. import { useLayout, LocalProject } from "@/context/layout"
  17. import { useServerSync } from "@/context/server-sync"
  18. import { Persist, persisted } from "@/utils/persist"
  19. import { base64Encode } from "@kirincode-ai/core/util/encode"
  20. import { decode64 } from "@/utils/base64"
  21. import { ResizeHandle } from "@kirincode-ai/ui/resize-handle"
  22. import { Button } from "@kirincode-ai/ui/button"
  23. import { Icon as IconV2 } from "@kirincode-ai/ui/v2/icon"
  24. import { IconButton } from "@kirincode-ai/ui/icon-button"
  25. import { Tooltip } from "@kirincode-ai/ui/tooltip"
  26. import { DropdownMenu } from "@kirincode-ai/ui/dropdown-menu"
  27. import { Dialog } from "@kirincode-ai/ui/dialog"
  28. import { getFilename } from "@kirincode-ai/core/util/path"
  29. import { Session } from "@kirincode-ai/sdk/v2/client"
  30. import { usePlatform } from "@/context/platform"
  31. import { useSettings } from "@/context/settings"
  32. import { createStore, produce, reconcile } from "solid-js/store"
  33. import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
  34. import type { DragEvent } from "@thisbeyond/solid-dnd"
  35. import { useProviders } from "@/hooks/use-providers"
  36. import { toaster } from "@kirincode-ai/ui/toast"
  37. import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
  38. import { useServerSDK } from "@/context/server-sdk"
  39. import { clearWorkspaceTerminals } from "@/context/terminal"
  40. import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
  41. import { useNotification } from "@/context/notification"
  42. import { usePermission } from "@/context/permission"
  43. import { Binary } from "@kirincode-ai/core/util/binary"
  44. import { retry } from "@kirincode-ai/core/util/retry"
  45. import { playSoundById } from "@/utils/sound"
  46. import { createAim } from "@/utils/aim"
  47. import { setNavigate } from "@/utils/notification-click"
  48. import { Worktree as WorktreeState } from "@/utils/worktree"
  49. import { setSessionHandoff } from "@/pages/session/handoff"
  50. import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
  51. import { useDialog } from "@kirincode-ai/ui/context/dialog"
  52. import { useTheme, type ColorScheme } from "@kirincode-ai/ui/theme/context"
  53. import { useCommand, type CommandOption } from "@/context/command"
  54. import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
  55. import { DebugBar } from "@/components/debug-bar"
  56. import { HelpButton, TabsInfoPopup } from "@/components/help-button"
  57. import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
  58. import { useDirectoryPicker } from "@/components/directory-picker"
  59. import { ServerConnection, useServer } from "@/context/server"
  60. import { useLanguage, type Locale } from "@/context/language"
  61. import { pathKey } from "@/utils/path-key"
  62. import {
  63. displayName,
  64. effectiveWorkspaceOrder,
  65. errorMessage,
  66. latestRootSession,
  67. sortedRootSessions,
  68. } from "./layout/helpers"
  69. import {
  70. collectNewSessionDeepLinks,
  71. collectOpenProjectDeepLinks,
  72. deepLinkEvent,
  73. drainPendingDeepLinks,
  74. } from "./layout/deep-links"
  75. import { createInlineEditorController } from "./layout/inline-editor"
  76. import {
  77. LocalWorkspace,
  78. SortableWorkspace,
  79. WorkspaceDragOverlay,
  80. type WorkspaceSidebarContext,
  81. } from "./layout/sidebar-workspace"
  82. import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
  83. import { SidebarContent } from "./layout/sidebar-shell"
  84. export default function LegacyLayout(props: ParentProps) {
  85. const serverSDK = useServerSDK()
  86. const [store, setStore, , ready] = persisted(
  87. Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
  88. createStore({
  89. lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
  90. activeProject: undefined as string | undefined,
  91. activeWorkspace: undefined as string | undefined,
  92. workspaceOrder: {} as Record<string, string[]>,
  93. workspaceName: {} as Record<string, string>,
  94. workspaceBranchName: {} as Record<string, Record<string, string>>,
  95. workspaceExpanded: {} as Record<string, boolean>,
  96. gettingStartedDismissed: false,
  97. }),
  98. )
  99. const pageReady = createMemo(() => ready())
  100. let scrollContainerRef: HTMLDivElement | undefined
  101. let dialogRun = 0
  102. let dialogDead = false
  103. const params = useParams()
  104. const serverSync = useServerSync()
  105. const layout = useLayout()
  106. const layoutReady = createMemo(() => layout.ready())
  107. const platform = usePlatform()
  108. const pickDirectory = useDirectoryPicker()
  109. const settings = useSettings()
  110. const server = useServer()
  111. const notification = useNotification()
  112. const permission = usePermission()
  113. const navigate = useNavigate()
  114. setNavigate(navigate)
  115. const providers = useProviders()
  116. const dialog = useDialog()
  117. const command = useCommand()
  118. const theme = useTheme()
  119. const language = useLanguage()
  120. createEffect(() => setV2Toast(false))
  121. const initialDirectory = decode64(params.dir)
  122. const route = createMemo(() => {
  123. const slug = params.dir
  124. if (!slug) return { slug, dir: "" }
  125. const dir = decode64(slug)
  126. if (!dir) return { slug, dir: "" }
  127. const store = serverSync().peek(dir, { bootstrap: false })
  128. return {
  129. slug,
  130. store,
  131. dir: store[0].path.directory || dir,
  132. }
  133. })
  134. const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
  135. const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
  136. const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
  137. system: "theme.scheme.system",
  138. light: "theme.scheme.light",
  139. dark: "theme.scheme.dark",
  140. }
  141. const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
  142. const currentDir = createMemo(() => route().dir)
  143. const [state, setState] = createStore({
  144. autoselect: !initialDirectory,
  145. busyWorkspaces: {} as Record<string, boolean>,
  146. hoverProject: undefined as string | undefined,
  147. scrollSessionKey: undefined as string | undefined,
  148. nav: undefined as HTMLElement | undefined,
  149. sortNow: Date.now(),
  150. sizing: false,
  151. peek: undefined as string | undefined,
  152. peeked: false,
  153. })
  154. const updateVersion = () => {
  155. const state = platform.updater?.state()
  156. if (state?.status !== "ready") return
  157. return state.version
  158. }
  159. const installUpdate = () => void platform.updater?.install()
  160. const titlebarUpdate: TitlebarUpdate = {
  161. version: updateVersion,
  162. installing: () => platform.updater?.state().status === "installing",
  163. install: installUpdate,
  164. }
  165. const editor = createInlineEditorController()
  166. const setBusy = (directory: string, value: boolean) => {
  167. const key = pathKey(directory)
  168. if (value) {
  169. setState("busyWorkspaces", key, true)
  170. return
  171. }
  172. setState(
  173. "busyWorkspaces",
  174. produce((draft) => {
  175. delete draft[key]
  176. }),
  177. )
  178. }
  179. const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)]
  180. const navLeave = { current: undefined as number | undefined }
  181. const sortNow = () => state.sortNow
  182. let sizet: number | undefined
  183. let sortNowInterval: ReturnType<typeof setInterval> | undefined
  184. const sortNowTimeout = setTimeout(
  185. () => {
  186. setState("sortNow", Date.now())
  187. sortNowInterval = setInterval(() => setState("sortNow", Date.now()), 60_000)
  188. },
  189. 60_000 - (Date.now() % 60_000),
  190. )
  191. const aim = createAim({
  192. enabled: () => !layout.sidebar.opened(),
  193. active: () => state.hoverProject,
  194. el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
  195. onActivate: (directory) => {
  196. serverSync().child(directory)
  197. setState("hoverProject", directory)
  198. },
  199. })
  200. onCleanup(() => {
  201. dialogDead = true
  202. dialogRun += 1
  203. if (navLeave.current !== undefined) clearTimeout(navLeave.current)
  204. clearTimeout(sortNowTimeout)
  205. if (sortNowInterval) clearInterval(sortNowInterval)
  206. if (sizet !== undefined) clearTimeout(sizet)
  207. if (peekt !== undefined) clearTimeout(peekt)
  208. aim.reset()
  209. })
  210. onMount(() => {
  211. const stop = () => setState("sizing", false)
  212. const blur = () => reset()
  213. const hide = () => {
  214. if (document.visibilityState !== "hidden") return
  215. reset()
  216. }
  217. makeEventListener(window, "pointerup", stop)
  218. makeEventListener(window, "pointercancel", stop)
  219. makeEventListener(window, "blur", stop)
  220. makeEventListener(window, "blur", blur)
  221. makeEventListener(document, "visibilitychange", hide)
  222. })
  223. const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
  224. const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
  225. const setHoverProject = (value: string | undefined) => {
  226. setState("hoverProject", value)
  227. if (value !== undefined) return
  228. aim.reset()
  229. }
  230. const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
  231. const disarm = () => {
  232. if (navLeave.current === undefined) return
  233. clearTimeout(navLeave.current)
  234. navLeave.current = undefined
  235. }
  236. const reset = () => {
  237. disarm()
  238. setHoverProject(undefined)
  239. }
  240. const arm = () => {
  241. if (layout.sidebar.opened()) return
  242. if (state.hoverProject === undefined) return
  243. disarm()
  244. navLeave.current = window.setTimeout(() => {
  245. navLeave.current = undefined
  246. setHoverProject(undefined)
  247. }, 300)
  248. }
  249. let peekt: number | undefined
  250. const hoverProjectData = createMemo(() => {
  251. const id = state.hoverProject
  252. if (!id) return
  253. return layout.projects.list().find((project) => project.worktree === id)
  254. })
  255. const peekProject = createMemo(() => {
  256. const id = state.peek
  257. if (!id) return
  258. return layout.projects.list().find((project) => project.worktree === id)
  259. })
  260. createEffect(() => {
  261. const p = hoverProjectData()
  262. if (p) {
  263. if (peekt !== undefined) {
  264. clearTimeout(peekt)
  265. peekt = undefined
  266. }
  267. setState("peek", p.worktree)
  268. setState("peeked", true)
  269. return
  270. }
  271. setState("peeked", false)
  272. if (state.peek === undefined) return
  273. if (peekt !== undefined) clearTimeout(peekt)
  274. peekt = window.setTimeout(() => {
  275. peekt = undefined
  276. setState("peek", undefined)
  277. }, 180)
  278. })
  279. createEffect(() => {
  280. if (!layout.sidebar.opened()) return
  281. setHoverProject(undefined)
  282. })
  283. createEffect(() => {
  284. if (!state.autoselect) return
  285. const dir = params.dir
  286. if (!dir) return
  287. const directory = decode64(dir)
  288. if (!directory) return
  289. setState("autoselect", false)
  290. })
  291. const editorOpen = editor.editorOpen
  292. const openEditor = editor.openEditor
  293. const closeEditor = editor.closeEditor
  294. const setEditor = editor.setEditor
  295. const InlineEditor = editor.InlineEditor
  296. const clearSidebarHoverState = () => {
  297. if (layout.sidebar.opened()) return
  298. reset()
  299. }
  300. const navigateWithSidebarReset = (href: string) => {
  301. clearSidebarHoverState()
  302. navigate(href)
  303. layout.mobileSidebar.hide()
  304. }
  305. function cycleTheme(direction = 1) {
  306. const ids = availableThemeEntries().map(([id]) => id)
  307. if (ids.length === 0) return
  308. const currentIndex = ids.indexOf(theme.themeId())
  309. const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
  310. const nextThemeId = ids[nextIndex]
  311. theme.setTheme(nextThemeId)
  312. showToast({
  313. title: language.t("toast.theme.title"),
  314. description: theme.name(nextThemeId),
  315. })
  316. }
  317. function cycleColorScheme(direction = 1) {
  318. const current = theme.colorScheme()
  319. const currentIndex = colorSchemeOrder.indexOf(current)
  320. const nextIndex =
  321. currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
  322. const next = colorSchemeOrder[nextIndex]
  323. theme.setColorScheme(next)
  324. showToast({
  325. title: language.t("toast.scheme.title"),
  326. description: colorSchemeLabel(next),
  327. })
  328. }
  329. function setLocale(next: Locale) {
  330. if (next === language.locale()) return
  331. language.setLocale(next)
  332. showToast({
  333. title: language.t("toast.language.title"),
  334. description: language.t("toast.language.description", { language: language.label(next) }),
  335. })
  336. }
  337. function cycleLanguage(direction = 1) {
  338. const locales = language.locales
  339. const currentIndex = locales.indexOf(language.locale())
  340. const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length
  341. const next = locales[nextIndex]
  342. if (!next) return
  343. setLocale(next)
  344. }
  345. const useSDKNotificationToasts = () =>
  346. onMount(() => {
  347. const toastBySession = new Map<string, number>()
  348. const alertedAtBySession = new Map<string, number>()
  349. const cooldownMs = 5000
  350. const dismissSessionAlert = (sessionKey: string) => {
  351. const toastId = toastBySession.get(sessionKey)
  352. if (toastId === undefined) return
  353. toaster.dismiss(toastId)
  354. toastBySession.delete(sessionKey)
  355. alertedAtBySession.delete(sessionKey)
  356. }
  357. const unsub = serverSDK().event.listen((e) => {
  358. if (e.details?.type === "worktree.ready") {
  359. setBusy(e.name, false)
  360. WorktreeState.ready(serverSDK().scope, e.name)
  361. return
  362. }
  363. if (e.details?.type === "worktree.failed") {
  364. setBusy(e.name, false)
  365. WorktreeState.failed(
  366. serverSDK().scope,
  367. e.name,
  368. e.details.properties?.message ?? language.t("common.requestFailed"),
  369. )
  370. return
  371. }
  372. if (
  373. e.details?.type === "question.replied" ||
  374. e.details?.type === "question.rejected" ||
  375. e.details?.type === "permission.replied"
  376. ) {
  377. const props = e.details.properties as { sessionID: string }
  378. const sessionKey = `${e.name}:${props.sessionID}`
  379. dismissSessionAlert(sessionKey)
  380. return
  381. }
  382. if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
  383. const title =
  384. e.details.type === "permission.asked"
  385. ? language.t("notification.permission.title")
  386. : language.t("notification.question.title")
  387. const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
  388. const directory = e.name
  389. const props = e.details.properties
  390. if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
  391. const [store] = serverSync().child(directory, { bootstrap: false })
  392. const session = store.session.find((s) => s.id === props.sessionID)
  393. const sessionKey = `${directory}:${props.sessionID}`
  394. const sessionTitle = session?.title ?? language.t("command.session.new")
  395. const projectName = getFilename(directory)
  396. const description =
  397. e.details.type === "permission.asked"
  398. ? language.t("notification.permission.description", { sessionTitle, projectName })
  399. : language.t("notification.question.description", { sessionTitle, projectName })
  400. const href = `/${base64Encode(directory)}/session/${props.sessionID}`
  401. const now = Date.now()
  402. const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
  403. if (now - lastAlerted < cooldownMs) return
  404. alertedAtBySession.set(sessionKey, now)
  405. if (e.details.type === "permission.asked") {
  406. if (settings.sounds.permissionsEnabled()) {
  407. void playSoundById(settings.sounds.permissions())
  408. }
  409. if (settings.notifications.permissions()) {
  410. void platform.notify(title, description, href)
  411. }
  412. }
  413. if (e.details.type === "question.asked") {
  414. if (settings.notifications.agent()) {
  415. void platform.notify(title, description, href)
  416. }
  417. }
  418. const currentSession = params.id
  419. if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return
  420. if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return
  421. dismissSessionAlert(sessionKey)
  422. const toastId = showToast({
  423. persistent: true,
  424. icon,
  425. title,
  426. description,
  427. actions: [
  428. {
  429. label: language.t("notification.action.goToSession"),
  430. onClick: () => navigate(href),
  431. },
  432. {
  433. label: language.t("common.dismiss"),
  434. onClick: "dismiss",
  435. },
  436. ],
  437. })
  438. toastBySession.set(sessionKey, toastId)
  439. })
  440. onCleanup(unsub)
  441. createEffect(() => {
  442. const currentSession = params.id
  443. if (!currentDir() || !currentSession) return
  444. const sessionKey = `${currentDir()}:${currentSession}`
  445. dismissSessionAlert(sessionKey)
  446. const [store] = serverSync().child(currentDir(), { bootstrap: false })
  447. const childSessions = store.session.filter((s) => s.parentID === currentSession)
  448. for (const child of childSessions) {
  449. dismissSessionAlert(`${currentDir()}:${child.id}`)
  450. }
  451. })
  452. })
  453. useSDKNotificationToasts()
  454. function scrollToSession(sessionId: string, sessionKey: string) {
  455. if (!scrollContainerRef) return
  456. if (state.scrollSessionKey === sessionKey) return
  457. const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
  458. if (!element) return
  459. const containerRect = scrollContainerRef.getBoundingClientRect()
  460. const elementRect = element.getBoundingClientRect()
  461. if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
  462. setState("scrollSessionKey", sessionKey)
  463. return
  464. }
  465. setState("scrollSessionKey", sessionKey)
  466. element.scrollIntoView({ block: "nearest", behavior: "smooth" })
  467. }
  468. const currentProject = createMemo(() => {
  469. const directory = currentDir()
  470. if (!directory) return
  471. const key = pathKey(directory)
  472. const projects = layout.projects.list()
  473. const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key))
  474. if (sandbox) return sandbox
  475. const direct = projects.find((p) => pathKey(p.worktree) === key)
  476. if (direct) return direct
  477. const [child] = serverSync().child(directory, { bootstrap: false })
  478. const id = child.project
  479. if (!id) return
  480. const meta = serverSync().data.project.find((p) => p.id === id)
  481. const root = meta?.worktree
  482. if (!root) return
  483. return projects.find((p) => p.worktree === root)
  484. })
  485. const [autoselecting] = createResource(async () => {
  486. await ready.promise
  487. await layout.ready.promise
  488. if (!untrack(() => state.autoselect)) return
  489. const list = layout.projects.list()
  490. const last = server.projects.last()
  491. if (list.length === 0) {
  492. if (!last) return
  493. await openProject(last, true)
  494. } else {
  495. const next = list.find((project) => project.worktree === last) ?? list[0]
  496. if (!next) return
  497. await openProject(next.worktree, true)
  498. }
  499. })
  500. const workspaceName = (directory: string, projectId?: string, branch?: string) => {
  501. const key = pathKey(directory)
  502. const direct = store.workspaceName[key] ?? store.workspaceName[directory]
  503. if (direct) return direct
  504. if (!projectId) return
  505. if (!branch) return
  506. return store.workspaceBranchName[projectId]?.[branch]
  507. }
  508. const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
  509. const key = pathKey(directory)
  510. setStore("workspaceName", key, next)
  511. if (!projectId) return
  512. if (!branch) return
  513. if (!store.workspaceBranchName[projectId]) {
  514. setStore("workspaceBranchName", projectId, {})
  515. }
  516. setStore("workspaceBranchName", projectId, branch, next)
  517. }
  518. const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
  519. workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
  520. const workspaceSetting = createMemo(() => {
  521. const project = currentProject()
  522. if (!project) return false
  523. if (project.vcs !== "git") return false
  524. return layout.sidebar.workspaces(project.worktree)()
  525. })
  526. const visibleSessionDirs = createMemo(() => {
  527. const project = currentProject()
  528. if (!project) return [] as string[]
  529. if (!workspaceSetting()) return [project.worktree]
  530. const activeDir = currentDir()
  531. return workspaceIds(project).filter((directory) => {
  532. const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
  533. const active = pathKey(directory) === pathKey(activeDir)
  534. return expanded || active
  535. })
  536. })
  537. createEffect(() => {
  538. if (!pageReady()) return
  539. if (!layoutReady()) return
  540. const projects = layout.projects.list()
  541. for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
  542. if (!expanded) continue
  543. const key = pathKey(directory)
  544. const project = projects.find(
  545. (item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
  546. )
  547. if (!project) continue
  548. if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
  549. setStore("workspaceExpanded", directory, false)
  550. }
  551. })
  552. const currentSessions = createMemo(() => {
  553. const now = Date.now()
  554. const dirs = visibleSessionDirs()
  555. if (dirs.length === 0) return [] as Session[]
  556. const result: Session[] = []
  557. for (const dir of dirs) {
  558. const [dirStore] = serverSync().child(dir, { bootstrap: true })
  559. const dirSessions = sortedRootSessions(dirStore, now)
  560. result.push(...dirSessions)
  561. }
  562. return result
  563. })
  564. type PrefetchQueue = {
  565. inflight: Set<string>
  566. pending: string[]
  567. pendingSet: Set<string>
  568. running: number
  569. }
  570. const prefetchChunk = 200
  571. const prefetchConcurrency = 2
  572. const prefetchPendingLimit = 10
  573. const span = 4
  574. const prefetchToken = { value: 0 }
  575. const prefetchQueues = new Map<string, PrefetchQueue>()
  576. const PREFETCH_MAX_SESSIONS_PER_DIR = 10
  577. const prefetchedByDir = new Map<string, Set<string>>()
  578. const lruFor = (directory: string) => {
  579. const existing = prefetchedByDir.get(directory)
  580. if (existing) return existing
  581. const created = new Set<string>()
  582. prefetchedByDir.set(directory, created)
  583. return created
  584. }
  585. const markPrefetched = (directory: string, sessionID: string) => {
  586. const lru = lruFor(directory)
  587. return pickSessionCacheEvictions({
  588. seen: lru,
  589. keep: sessionID,
  590. limit: PREFETCH_MAX_SESSIONS_PER_DIR,
  591. preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined,
  592. })
  593. }
  594. createEffect(() => {
  595. const active = new Set(visibleSessionDirs())
  596. for (const directory of prefetchedByDir.keys()) {
  597. if (active.has(directory)) continue
  598. prefetchedByDir.delete(directory)
  599. }
  600. })
  601. createEffect(() => {
  602. route()
  603. serverSDK().url
  604. prefetchToken.value += 1
  605. prefetchQueues.clear()
  606. })
  607. createEffect(() => {
  608. const visible = new Set(visibleSessionDirs())
  609. for (const [directory, q] of prefetchQueues) {
  610. if (visible.has(directory)) continue
  611. q.pending.length = 0
  612. q.pendingSet.clear()
  613. if (q.running === 0) prefetchQueues.delete(directory)
  614. }
  615. })
  616. const queueFor = (directory: string) => {
  617. const existing = prefetchQueues.get(directory)
  618. if (existing) return existing
  619. const created: PrefetchQueue = {
  620. inflight: new Set(),
  621. pending: [],
  622. pendingSet: new Set(),
  623. running: 0,
  624. }
  625. prefetchQueues.set(directory, created)
  626. return created
  627. }
  628. async function prefetchMessages(directory: string, sessionID: string, token: number) {
  629. await serverSync()
  630. .session.prefetch(sessionID, prefetchChunk)
  631. .catch(() => {})
  632. if (prefetchToken.value !== token) return
  633. for (const stale of markPrefetched(directory, sessionID)) serverSync().session.evict(stale)
  634. }
  635. const pumpPrefetch = (directory: string) => {
  636. const q = queueFor(directory)
  637. if (q.running >= prefetchConcurrency) return
  638. const sessionID = q.pending.shift()
  639. if (!sessionID) return
  640. q.pendingSet.delete(sessionID)
  641. q.inflight.add(sessionID)
  642. q.running += 1
  643. const token = prefetchToken.value
  644. void prefetchMessages(directory, sessionID, token).finally(() => {
  645. q.running -= 1
  646. q.inflight.delete(sessionID)
  647. pumpPrefetch(directory)
  648. })
  649. }
  650. const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
  651. const directory = session.directory
  652. if (!directory) return
  653. const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
  654. if (cached) return
  655. const q = queueFor(directory)
  656. if (q.inflight.has(session.id)) return
  657. if (q.pendingSet.has(session.id)) {
  658. if (priority !== "high") return
  659. const index = q.pending.indexOf(session.id)
  660. if (index > 0) {
  661. q.pending.splice(index, 1)
  662. q.pending.unshift(session.id)
  663. }
  664. return
  665. }
  666. const lru = lruFor(directory)
  667. const known = lru.has(session.id)
  668. if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
  669. if (priority === "high") q.pending.unshift(session.id)
  670. if (priority !== "high") q.pending.push(session.id)
  671. q.pendingSet.add(session.id)
  672. while (q.pending.length > prefetchPendingLimit) {
  673. const dropped = q.pending.pop()
  674. if (!dropped) continue
  675. q.pendingSet.delete(dropped)
  676. }
  677. pumpPrefetch(directory)
  678. }
  679. const warm = (sessions: Session[], index: number) => {
  680. for (let offset = 1; offset <= span; offset++) {
  681. const next = sessions[index + offset]
  682. if (next) prefetchSession(next, offset === 1 ? "high" : "low")
  683. const prev = sessions[index - offset]
  684. if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
  685. }
  686. }
  687. createEffect(() => {
  688. const sessions = currentSessions()
  689. if (sessions.length === 0) return
  690. const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
  691. if (index === -1) return
  692. if (!params.id) {
  693. const first = sessions[index]
  694. if (first) prefetchSession(first, "high")
  695. }
  696. warm(sessions, index)
  697. })
  698. function navigateSessionByOffset(offset: number) {
  699. const sessions = currentSessions()
  700. if (sessions.length === 0) return
  701. const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
  702. let targetIndex: number
  703. if (sessionIndex === -1) {
  704. targetIndex = offset > 0 ? 0 : sessions.length - 1
  705. } else {
  706. targetIndex = (sessionIndex + offset + sessions.length) % sessions.length
  707. }
  708. const session = sessions[targetIndex]
  709. if (!session) return
  710. prefetchSession(session, "high")
  711. warm(sessions, targetIndex)
  712. navigateToSession(session)
  713. }
  714. function navigateProjectByOffset(offset: number) {
  715. const projects = layout.projects.list()
  716. if (projects.length === 0) return
  717. const current = currentProject()?.worktree
  718. const fallback = currentDir() ? projectRoot(currentDir()) : undefined
  719. const active = current ?? fallback
  720. const index = active ? projects.findIndex((project) => project.worktree === active) : -1
  721. const target =
  722. index === -1
  723. ? offset > 0
  724. ? projects[0]
  725. : projects[projects.length - 1]
  726. : projects[(index + offset + projects.length) % projects.length]
  727. if (!target) return
  728. // warm up child store to prevent flicker
  729. serverSync().child(target.worktree)
  730. void openProject(target.worktree)
  731. }
  732. function navigateToProjectIndex(index: number) {
  733. const projects = layout.projects.list()
  734. const target = projects[index]
  735. if (!target) return
  736. serverSync().child(target.worktree)
  737. void openProject(target.worktree)
  738. }
  739. function navigateSessionByUnseen(offset: number) {
  740. const sessions = currentSessions()
  741. if (sessions.length === 0) return
  742. const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0)
  743. if (!hasUnseen) return
  744. const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
  745. const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
  746. for (let i = 1; i <= sessions.length; i++) {
  747. const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
  748. const session = sessions[index]
  749. if (!session) continue
  750. if (notification.session.unseenCount(session.id) === 0) continue
  751. prefetchSession(session, "high")
  752. warm(sessions, index)
  753. navigateToSession(session)
  754. return
  755. }
  756. }
  757. async function archiveSession(session: Session) {
  758. const [store, setStore] = serverSync().child(session.directory)
  759. const sessions = store.session ?? []
  760. const index = sessions.findIndex((s) => s.id === session.id)
  761. const nextSession = sessions[index + 1] ?? sessions[index - 1]
  762. await serverSDK().client.session.update({
  763. directory: session.directory,
  764. sessionID: session.id,
  765. time: { archived: Date.now() },
  766. })
  767. setStore(
  768. produce((draft) => {
  769. const match = Binary.search(draft.session, session.id, (s) => s.id)
  770. if (match.found) draft.session.splice(match.index, 1)
  771. }),
  772. )
  773. if (session.id === params.id) {
  774. if (nextSession) {
  775. navigate(`/${params.dir}/session/${nextSession.id}`)
  776. } else {
  777. navigate(`/${params.dir}/session`)
  778. }
  779. }
  780. }
  781. command.register("layout", () => {
  782. const commands: CommandOption[] = [
  783. {
  784. id: "sidebar.toggle",
  785. title: language.t("command.sidebar.toggle"),
  786. category: language.t("command.category.view"),
  787. keybind: "mod+b",
  788. onSelect: () => layout.sidebar.toggle(),
  789. },
  790. {
  791. id: "project.open",
  792. title: language.t("command.project.open"),
  793. category: language.t("command.category.project"),
  794. keybind: "mod+o",
  795. onSelect: () => chooseProject(),
  796. },
  797. {
  798. id: "project.previous",
  799. title: language.t("command.project.previous"),
  800. category: language.t("command.category.project"),
  801. keybind: "mod+alt+arrowup",
  802. onSelect: () => navigateProjectByOffset(-1),
  803. },
  804. {
  805. id: "project.next",
  806. title: language.t("command.project.next"),
  807. category: language.t("command.category.project"),
  808. keybind: "mod+alt+arrowdown",
  809. onSelect: () => navigateProjectByOffset(1),
  810. },
  811. {
  812. id: "provider.connect",
  813. title: language.t("command.provider.connect"),
  814. category: language.t("command.category.provider"),
  815. onSelect: () => connectProvider(),
  816. },
  817. {
  818. id: "server.switch",
  819. title: language.t("command.server.switch"),
  820. category: language.t("command.category.server"),
  821. onSelect: () => openServer(),
  822. },
  823. {
  824. id: "settings.open",
  825. title: language.t("command.settings.open"),
  826. category: language.t("command.category.settings"),
  827. keybind: "mod+comma",
  828. onSelect: () => openSettings(),
  829. },
  830. {
  831. id: "session.previous",
  832. title: language.t("command.session.previous"),
  833. category: language.t("command.category.session"),
  834. keybind: "alt+arrowup",
  835. onSelect: () => navigateSessionByOffset(-1),
  836. },
  837. {
  838. id: "session.next",
  839. title: language.t("command.session.next"),
  840. category: language.t("command.category.session"),
  841. keybind: "alt+arrowdown",
  842. onSelect: () => navigateSessionByOffset(1),
  843. },
  844. {
  845. id: "session.previous.unseen",
  846. title: language.t("command.session.previous.unseen"),
  847. category: language.t("command.category.session"),
  848. keybind: "shift+alt+arrowup",
  849. onSelect: () => navigateSessionByUnseen(-1),
  850. },
  851. {
  852. id: "session.next.unseen",
  853. title: language.t("command.session.next.unseen"),
  854. category: language.t("command.category.session"),
  855. keybind: "shift+alt+arrowdown",
  856. onSelect: () => navigateSessionByUnseen(1),
  857. },
  858. {
  859. id: "session.archive",
  860. title: language.t("command.session.archive"),
  861. category: language.t("command.category.session"),
  862. keybind: "mod+shift+backspace",
  863. disabled: !params.dir || !params.id,
  864. onSelect: () => {
  865. const session = currentSessions().find((s) => s.id === params.id)
  866. if (session) void archiveSession(session)
  867. },
  868. },
  869. {
  870. id: "workspace.new",
  871. title: language.t("workspace.new"),
  872. category: language.t("command.category.workspace"),
  873. keybind: "mod+shift+w",
  874. disabled: !workspaceSetting(),
  875. onSelect: () => {
  876. const project = currentProject()
  877. if (!project) return
  878. return createWorkspace(project)
  879. },
  880. },
  881. {
  882. id: "workspace.toggle",
  883. title: language.t("command.workspace.toggle"),
  884. description: language.t("command.workspace.toggle.description"),
  885. category: language.t("command.category.workspace"),
  886. slash: "workspace",
  887. disabled: !currentProject() || currentProject()?.vcs !== "git",
  888. onSelect: () => {
  889. const project = currentProject()
  890. if (!project) return
  891. if (project.vcs !== "git") return
  892. const wasEnabled = layout.sidebar.workspaces(project.worktree)()
  893. layout.sidebar.toggleWorkspaces(project.worktree)
  894. showToast({
  895. title: wasEnabled
  896. ? language.t("toast.workspace.disabled.title")
  897. : language.t("toast.workspace.enabled.title"),
  898. description: wasEnabled
  899. ? language.t("toast.workspace.disabled.description")
  900. : language.t("toast.workspace.enabled.description"),
  901. })
  902. },
  903. },
  904. {
  905. id: "theme.cycle",
  906. title: language.t("command.theme.cycle"),
  907. category: language.t("command.category.theme"),
  908. keybind: "mod+shift+t",
  909. onSelect: () => cycleTheme(1),
  910. },
  911. ]
  912. Array.from({ length: 9 }, (_, i) => {
  913. const index = i
  914. const number = index + 1
  915. commands.push({
  916. id: `project.${number}`,
  917. category: language.t("command.category.project"),
  918. title: `Open Project {number}`,
  919. keybind: `mod+${number}`,
  920. disabled: layout.projects.list().length <= index,
  921. hidden: true,
  922. onSelect: () => navigateToProjectIndex(index),
  923. })
  924. })
  925. for (const [id] of availableThemeEntries()) {
  926. commands.push({
  927. id: `theme.set.${id}`,
  928. title: language.t("command.theme.set", { theme: theme.name(id) }),
  929. category: language.t("command.category.theme"),
  930. onSelect: () => theme.commitPreview(),
  931. onHighlight: () => {
  932. theme.previewTheme(id)
  933. return () => theme.cancelPreview()
  934. },
  935. })
  936. }
  937. commands.push({
  938. id: "theme.scheme.cycle",
  939. title: language.t("command.theme.scheme.cycle"),
  940. category: language.t("command.category.theme"),
  941. keybind: "mod+shift+s",
  942. onSelect: () => cycleColorScheme(1),
  943. })
  944. for (const scheme of colorSchemeOrder) {
  945. commands.push({
  946. id: `theme.scheme.${scheme}`,
  947. title: language.t("command.theme.scheme.set", { scheme: colorSchemeLabel(scheme) }),
  948. category: language.t("command.category.theme"),
  949. onSelect: () => theme.commitPreview(),
  950. onHighlight: () => {
  951. theme.previewColorScheme(scheme)
  952. return () => theme.cancelPreview()
  953. },
  954. })
  955. }
  956. commands.push({
  957. id: "language.cycle",
  958. title: language.t("command.language.cycle"),
  959. category: language.t("command.category.language"),
  960. onSelect: () => cycleLanguage(1),
  961. })
  962. for (const locale of language.locales) {
  963. commands.push({
  964. id: `language.set.${locale}`,
  965. title: language.t("command.language.set", { language: language.label(locale) }),
  966. category: language.t("command.category.language"),
  967. onSelect: () => setLocale(locale),
  968. })
  969. }
  970. return commands
  971. })
  972. function connectProvider() {
  973. const run = ++dialogRun
  974. void import("@/components/dialog-connect-provider").then((x) => {
  975. if (dialogDead || dialogRun !== run) return
  976. void dialog.show(() => <x.DialogConnectProvider />)
  977. })
  978. }
  979. function openServer() {
  980. const run = ++dialogRun
  981. void import("@/components/dialog-select-server").then((x) => {
  982. if (dialogDead || dialogRun !== run) return
  983. dialog.show(() => <x.DialogSelectServer />)
  984. })
  985. }
  986. function openSettings() {
  987. const run = ++dialogRun
  988. const module = settings.general.newLayoutDesigns()
  989. ? import("@/components/settings-v2")
  990. : import("@/components/dialog-settings")
  991. void module.then((x) => {
  992. if (dialogDead || dialogRun !== run) return
  993. dialog.show(() => <x.DialogSettings />)
  994. })
  995. }
  996. function projectRoot(directory: string) {
  997. const key = pathKey(directory)
  998. const project = layout.projects
  999. .list()
  1000. .find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
  1001. if (project) return project.worktree
  1002. const known = Object.entries(store.workspaceOrder).find(
  1003. ([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key),
  1004. )
  1005. if (known) return known[0]
  1006. const [child] = serverSync().child(directory, { bootstrap: false })
  1007. const id = child.project
  1008. if (!id) return directory
  1009. const meta = serverSync().data.project.find((item) => item.id === id)
  1010. return meta?.worktree ?? directory
  1011. }
  1012. function activeProjectRoot(directory: string) {
  1013. return currentProject()?.worktree ?? projectRoot(directory)
  1014. }
  1015. function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
  1016. setStore("lastProjectSession", root, { directory, id, at: Date.now() })
  1017. return root
  1018. }
  1019. function clearLastProjectSession(root: string) {
  1020. if (!store.lastProjectSession[root]) return
  1021. setStore(
  1022. "lastProjectSession",
  1023. produce((draft) => {
  1024. delete draft[root]
  1025. }),
  1026. )
  1027. }
  1028. function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
  1029. rememberSessionRoute(directory, id, root)
  1030. notification.session.markViewed(id)
  1031. const expanded = untrack(() => store.workspaceExpanded[directory])
  1032. if (expanded === false) {
  1033. setStore("workspaceExpanded", directory, true)
  1034. }
  1035. requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
  1036. return root
  1037. }
  1038. async function navigateToProject(directory: string | undefined) {
  1039. if (!directory) return
  1040. const root = projectRoot(directory)
  1041. server.projects.touch(root)
  1042. const project = layout.projects.list().find((item) => item.worktree === root)
  1043. let dirs = project
  1044. ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
  1045. : [root]
  1046. const canOpen = (value: string | undefined) => {
  1047. if (!value) return false
  1048. return dirs.some((item) => pathKey(item) === pathKey(value))
  1049. }
  1050. const refreshDirs = async (target?: string) => {
  1051. if (!target || target === root || canOpen(target)) return canOpen(target)
  1052. const listed = await serverSDK()
  1053. .client.worktree.list({ directory: root })
  1054. .then((x) => x.data ?? [])
  1055. .catch(() => [] as string[])
  1056. dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
  1057. return canOpen(target)
  1058. }
  1059. const openSession = async (target: { directory: string; id: string }) => {
  1060. if (!canOpen(target.directory)) return false
  1061. const sync = serverSync().ensureDirSyncContext(target.directory)
  1062. if (sync.session.get(target.id)) {
  1063. setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
  1064. navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
  1065. return true
  1066. }
  1067. const resolved = await sync.session
  1068. .sync(target.id)
  1069. .then(() => sync.session.get(target.id))
  1070. .catch(() => undefined)
  1071. if (!resolved?.directory) return false
  1072. if (!canOpen(resolved.directory)) return false
  1073. setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
  1074. navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
  1075. return true
  1076. }
  1077. const projectSession = store.lastProjectSession[root]
  1078. if (projectSession?.id) {
  1079. await refreshDirs(projectSession.directory)
  1080. const opened = await openSession(projectSession)
  1081. if (opened) return
  1082. clearLastProjectSession(root)
  1083. }
  1084. const latest = latestRootSession(
  1085. dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
  1086. Date.now(),
  1087. )
  1088. if (latest && (await openSession(latest))) {
  1089. return
  1090. }
  1091. const fetched = latestRootSession(
  1092. await Promise.all(
  1093. dirs.map(async (item) => ({
  1094. path: { directory: item },
  1095. session: await serverSDK()
  1096. .client.session.list({ directory: item })
  1097. .then((x) => x.data ?? [])
  1098. .catch(() => []),
  1099. })),
  1100. ),
  1101. Date.now(),
  1102. )
  1103. if (fetched && (await openSession(fetched))) {
  1104. return
  1105. }
  1106. navigateWithSidebarReset(`/${base64Encode(root)}/session`)
  1107. }
  1108. function navigateToSession(session: Session | undefined) {
  1109. if (!session) return
  1110. navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
  1111. }
  1112. function openProject(directory: string, navigate = true) {
  1113. layout.projects.open(directory)
  1114. if (navigate) return navigateToProject(directory)
  1115. }
  1116. const handleDeepLinks = (urls: string[]) => {
  1117. if (!server.isLocal()) return
  1118. for (const directory of collectOpenProjectDeepLinks(urls)) {
  1119. void openProject(directory)
  1120. }
  1121. for (const link of collectNewSessionDeepLinks(urls)) {
  1122. void openProject(link.directory, false)
  1123. const slug = base64Encode(link.directory)
  1124. if (link.prompt) {
  1125. setSessionHandoff(SessionStateKey.from(server.scope(), SessionRouteKey.fromLegacy(slug)), {
  1126. prompt: link.prompt,
  1127. })
  1128. }
  1129. const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
  1130. navigateWithSidebarReset(href)
  1131. }
  1132. }
  1133. onMount(() => {
  1134. const handler = (event: Event) => {
  1135. const detail = (event as CustomEvent<{ urls: string[] }>).detail
  1136. const urls = detail?.urls ?? []
  1137. if (urls.length === 0) return
  1138. handleDeepLinks(urls)
  1139. }
  1140. handleDeepLinks(drainPendingDeepLinks(window))
  1141. makeEventListener(window, deepLinkEvent, handler as EventListener)
  1142. })
  1143. async function renameProject(project: LocalProject, next: string) {
  1144. const current = displayName(project)
  1145. if (next === current) return
  1146. const name = next === getFilename(project.worktree) ? "" : next
  1147. if (project.id && project.id !== "global") {
  1148. await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
  1149. return
  1150. }
  1151. serverSync().project.meta(project.worktree, { name })
  1152. }
  1153. const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
  1154. const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
  1155. if (current === next) return
  1156. setWorkspaceName(directory, next, projectId, branch)
  1157. }
  1158. function closeProject(directory: string) {
  1159. const list = layout.projects.list()
  1160. const key = pathKey(directory)
  1161. const index = list.findIndex((x) => pathKey(x.worktree) === key)
  1162. const active = pathKey(currentProject()?.worktree ?? "") === key
  1163. if (index === -1) return
  1164. if (!active) {
  1165. layout.projects.close(directory)
  1166. return
  1167. }
  1168. if (list.length === 1) {
  1169. layout.projects.close(directory)
  1170. navigate("/")
  1171. return
  1172. }
  1173. const next = list[index + 1] ?? list[index - 1]
  1174. navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
  1175. layout.projects.close(directory)
  1176. queueMicrotask(() => {
  1177. void navigateToProject(next.worktree)
  1178. })
  1179. }
  1180. function toggleProjectWorkspaces(project: LocalProject) {
  1181. const enabled = layout.sidebar.workspaces(project.worktree)()
  1182. if (enabled) {
  1183. layout.sidebar.toggleWorkspaces(project.worktree)
  1184. return
  1185. }
  1186. if (project.vcs !== "git") return
  1187. layout.sidebar.toggleWorkspaces(project.worktree)
  1188. }
  1189. const showEditProjectDialog = (conn: ServerConnection.Any, project: LocalProject) => {
  1190. const run = ++dialogRun
  1191. void import("@/components/dialog-edit-project").then((x) => {
  1192. if (dialogDead || dialogRun !== run) return
  1193. dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
  1194. })
  1195. }
  1196. function chooseProject() {
  1197. const conn = server.current
  1198. if (!conn) return
  1199. function resolve(result: string | string[] | null) {
  1200. if (Array.isArray(result)) {
  1201. for (const directory of result) {
  1202. void openProject(directory, false)
  1203. }
  1204. void navigateToProject(result[0])
  1205. } else if (result) {
  1206. void openProject(result)
  1207. }
  1208. }
  1209. pickDirectory({
  1210. server: conn,
  1211. title: language.t("command.project.open"),
  1212. multiple: true,
  1213. onSelect: resolve,
  1214. })
  1215. }
  1216. const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
  1217. if (directory === root) return
  1218. const current = currentDir()
  1219. const currentKey = pathKey(current)
  1220. const deletedKey = pathKey(directory)
  1221. const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
  1222. if (!leaveDeletedWorkspace && shouldLeave) {
  1223. navigateWithSidebarReset(`/${base64Encode(root)}/session`)
  1224. }
  1225. setBusy(directory, true)
  1226. const result = await serverSDK()
  1227. .client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
  1228. .then((x) => x.data)
  1229. .catch((err) => {
  1230. showToast({
  1231. title: language.t("workspace.delete.failed.title"),
  1232. description: errorMessage(err, language.t("common.requestFailed")),
  1233. })
  1234. return false
  1235. })
  1236. setBusy(directory, false)
  1237. if (!result) return
  1238. if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
  1239. clearLastProjectSession(root)
  1240. }
  1241. serverSync().set(
  1242. "project",
  1243. produce((draft) => {
  1244. const project = draft.find((item) => item.worktree === root)
  1245. if (!project) return
  1246. project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
  1247. }),
  1248. )
  1249. setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
  1250. layout.projects.close(directory)
  1251. layout.projects.open(root)
  1252. if (shouldLeave) return
  1253. const nextCurrent = currentDir()
  1254. const nextKey = pathKey(nextCurrent)
  1255. const project = layout.projects.list().find((item) => item.worktree === root)
  1256. const dirs = project
  1257. ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
  1258. : [root]
  1259. const valid = dirs.some((item) => pathKey(item) === nextKey)
  1260. if (params.dir && projectRoot(nextCurrent) === root && !valid) {
  1261. navigateWithSidebarReset(`/${base64Encode(root)}/session`)
  1262. }
  1263. }
  1264. const resetWorkspace = async (root: string, directory: string) => {
  1265. if (directory === root) return
  1266. setBusy(directory, true)
  1267. const progress = showToast({
  1268. persistent: true,
  1269. title: language.t("workspace.resetting.title"),
  1270. description: language.t("workspace.resetting.description"),
  1271. })
  1272. const dismiss = () => toaster.dismiss(progress)
  1273. const sessions: Session[] = await serverSDK()
  1274. .client.session.list({ directory })
  1275. .then((x) => x.data ?? [])
  1276. .catch(() => [])
  1277. clearWorkspaceTerminals(
  1278. directory,
  1279. sessions.map((s) => s.id),
  1280. platform,
  1281. serverSDK().scope,
  1282. )
  1283. await serverSDK()
  1284. .client.instance.dispose({ directory })
  1285. .catch(() => undefined)
  1286. const result = await serverSDK()
  1287. .client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
  1288. .then((x) => x.data)
  1289. .catch((err) => {
  1290. showToast({
  1291. title: language.t("workspace.reset.failed.title"),
  1292. description: errorMessage(err, language.t("common.requestFailed")),
  1293. })
  1294. return false
  1295. })
  1296. if (!result) {
  1297. setBusy(directory, false)
  1298. dismiss()
  1299. return
  1300. }
  1301. const archivedAt = Date.now()
  1302. await Promise.all(
  1303. sessions
  1304. .filter((session) => session.time.archived === undefined)
  1305. .map((session) =>
  1306. serverSDK()
  1307. .client.session.update({
  1308. sessionID: session.id,
  1309. directory: session.directory,
  1310. time: { archived: archivedAt },
  1311. })
  1312. .catch(() => undefined),
  1313. ),
  1314. )
  1315. setBusy(directory, false)
  1316. dismiss()
  1317. showToast({
  1318. title: language.t("workspace.reset.success.title"),
  1319. description: language.t("workspace.reset.success.description"),
  1320. actions: [
  1321. {
  1322. label: language.t("command.session.new"),
  1323. onClick: () => {
  1324. const href = `/${base64Encode(directory)}/session`
  1325. navigate(href)
  1326. layout.mobileSidebar.hide()
  1327. },
  1328. },
  1329. {
  1330. label: language.t("common.dismiss"),
  1331. onClick: "dismiss",
  1332. },
  1333. ],
  1334. })
  1335. }
  1336. function DialogDeleteWorkspace(props: { root: string; directory: string }) {
  1337. const name = createMemo(() => getFilename(props.directory))
  1338. const [data, setData] = createStore({
  1339. status: "loading" as "loading" | "ready" | "error",
  1340. dirty: false,
  1341. })
  1342. onMount(() => {
  1343. serverSDK()
  1344. .client.vcs.status({ directory: props.directory })
  1345. .then((x) => {
  1346. const files = x.data ?? []
  1347. const dirty = files.length > 0
  1348. setData({ status: "ready", dirty })
  1349. })
  1350. .catch(() => {
  1351. setData({ status: "error", dirty: false })
  1352. })
  1353. })
  1354. const handleDelete = () => {
  1355. const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory)
  1356. if (leaveDeletedWorkspace) {
  1357. navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
  1358. }
  1359. dialog.close()
  1360. void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
  1361. }
  1362. const description = () => {
  1363. if (data.status === "loading") return language.t("workspace.status.checking")
  1364. if (data.status === "error") return language.t("workspace.status.error")
  1365. if (!data.dirty) return language.t("workspace.status.clean")
  1366. return language.t("workspace.status.dirty")
  1367. }
  1368. return (
  1369. <Dialog title={language.t("workspace.delete.title")} fit>
  1370. <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
  1371. <div class="flex flex-col gap-1">
  1372. <span class="text-14-regular text-text-strong">
  1373. {language.t("workspace.delete.confirm", { name: name() })}
  1374. </span>
  1375. <span class="text-12-regular text-text-weak">{description()}</span>
  1376. </div>
  1377. <div class="flex justify-end gap-2">
  1378. <Button variant="ghost" size="large" onClick={() => dialog.close()}>
  1379. {language.t("common.cancel")}
  1380. </Button>
  1381. <Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
  1382. {language.t("workspace.delete.button")}
  1383. </Button>
  1384. </div>
  1385. </div>
  1386. </Dialog>
  1387. )
  1388. }
  1389. function DialogResetWorkspace(props: { root: string; directory: string }) {
  1390. const name = createMemo(() => getFilename(props.directory))
  1391. const [state, setState] = createStore({
  1392. status: "loading" as "loading" | "ready" | "error",
  1393. dirty: false,
  1394. sessions: [] as Session[],
  1395. })
  1396. const refresh = async () => {
  1397. const sessions = await serverSDK()
  1398. .client.session.list({ directory: props.directory })
  1399. .then((x) => x.data ?? [])
  1400. .catch(() => [])
  1401. const active = sessions.filter((session) => session.time.archived === undefined)
  1402. setState({ sessions: active })
  1403. }
  1404. onMount(() => {
  1405. serverSDK()
  1406. .client.vcs.status({ directory: props.directory })
  1407. .then((x) => {
  1408. const files = x.data ?? []
  1409. const dirty = files.length > 0
  1410. setState({ status: "ready", dirty })
  1411. void refresh()
  1412. })
  1413. .catch(() => {
  1414. setState({ status: "error", dirty: false })
  1415. })
  1416. })
  1417. const handleReset = () => {
  1418. dialog.close()
  1419. void resetWorkspace(props.root, props.directory)
  1420. }
  1421. const archivedCount = () => state.sessions.length
  1422. const description = () => {
  1423. if (state.status === "loading") return language.t("workspace.status.checking")
  1424. if (state.status === "error") return language.t("workspace.status.error")
  1425. if (!state.dirty) return language.t("workspace.status.clean")
  1426. return language.t("workspace.status.dirty")
  1427. }
  1428. const archivedLabel = () => {
  1429. const count = archivedCount()
  1430. if (count === 0) return language.t("workspace.reset.archived.none")
  1431. if (count === 1) return language.t("workspace.reset.archived.one")
  1432. return language.t("workspace.reset.archived.many", { count })
  1433. }
  1434. return (
  1435. <Dialog title={language.t("workspace.reset.title")} fit>
  1436. <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
  1437. <div class="flex flex-col gap-1">
  1438. <span class="text-14-regular text-text-strong">
  1439. {language.t("workspace.reset.confirm", { name: name() })}
  1440. </span>
  1441. <span class="text-12-regular text-text-weak">
  1442. {description()} {archivedLabel()} {language.t("workspace.reset.note")}
  1443. </span>
  1444. </div>
  1445. <div class="flex justify-end gap-2">
  1446. <Button variant="ghost" size="large" onClick={() => dialog.close()}>
  1447. {language.t("common.cancel")}
  1448. </Button>
  1449. <Button variant="primary" size="large" disabled={state.status === "loading"} onClick={handleReset}>
  1450. {language.t("workspace.reset.button")}
  1451. </Button>
  1452. </div>
  1453. </div>
  1454. </Dialog>
  1455. )
  1456. }
  1457. const activeRoute = {
  1458. session: "",
  1459. sessionProject: "",
  1460. directory: "",
  1461. }
  1462. createEffect(
  1463. on(
  1464. () => {
  1465. return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
  1466. },
  1467. ([ready, slug, id, root, dir]) => {
  1468. if (!ready || !slug || !dir) {
  1469. activeRoute.session = ""
  1470. activeRoute.sessionProject = ""
  1471. activeRoute.directory = ""
  1472. return
  1473. }
  1474. if (!id) {
  1475. activeRoute.session = ""
  1476. activeRoute.sessionProject = ""
  1477. activeRoute.directory = ""
  1478. return
  1479. }
  1480. const session = `${slug}/${id}`
  1481. if (!root) {
  1482. activeRoute.session = session
  1483. activeRoute.directory = dir
  1484. activeRoute.sessionProject = ""
  1485. return
  1486. }
  1487. if (server.projects.last() !== root) server.projects.touch(root)
  1488. const changed = session !== activeRoute.session || dir !== activeRoute.directory
  1489. if (changed) {
  1490. activeRoute.session = session
  1491. activeRoute.directory = dir
  1492. activeRoute.sessionProject = syncSessionRoute(dir, id, root)
  1493. return
  1494. }
  1495. if (root === activeRoute.sessionProject) return
  1496. activeRoute.directory = dir
  1497. activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
  1498. },
  1499. ),
  1500. )
  1501. createEffect(() => {
  1502. document.documentElement.style.setProperty(
  1503. "--dialog-left-margin",
  1504. `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
  1505. )
  1506. })
  1507. const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
  1508. const panel = createMemo(() => Math.max(side() - 64, 0))
  1509. const loadedSessionDirs = new Set<string>()
  1510. createEffect(
  1511. on(
  1512. visibleSessionDirs,
  1513. (dirs) => {
  1514. if (dirs.length === 0) {
  1515. loadedSessionDirs.clear()
  1516. return
  1517. }
  1518. const next = new Set(dirs)
  1519. for (const directory of next) {
  1520. if (loadedSessionDirs.has(directory)) continue
  1521. void serverSync().project.loadSessions(directory)
  1522. }
  1523. loadedSessionDirs.clear()
  1524. for (const directory of next) {
  1525. loadedSessionDirs.add(directory)
  1526. }
  1527. },
  1528. { defer: true },
  1529. ),
  1530. )
  1531. function handleDragStart(event: unknown) {
  1532. const id = getDraggableId(event)
  1533. if (!id) return
  1534. setHoverProject(undefined)
  1535. setStore("activeProject", id)
  1536. }
  1537. function handleDragOver(event: DragEvent) {
  1538. const { draggable, droppable } = event
  1539. if (draggable && droppable) {
  1540. const projects = layout.projects.list()
  1541. const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
  1542. const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
  1543. if (fromIndex !== toIndex && toIndex !== -1) {
  1544. layout.projects.move(draggable.id.toString(), toIndex)
  1545. }
  1546. }
  1547. }
  1548. function handleDragEnd() {
  1549. setStore("activeProject", undefined)
  1550. }
  1551. function workspaceIds(project: LocalProject | undefined) {
  1552. if (!project) return []
  1553. const local = project.worktree
  1554. const dirs = [local, ...(project.sandboxes ?? [])]
  1555. const active = currentProject()
  1556. const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
  1557. const extra =
  1558. directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
  1559. ? directory
  1560. : undefined
  1561. const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
  1562. const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
  1563. if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
  1564. if (!extra) return ordered
  1565. if (pending) return ordered
  1566. return [...ordered, extra]
  1567. }
  1568. const sidebarProject = createMemo(() => {
  1569. if (layout.sidebar.opened()) return currentProject()
  1570. const hovered = hoverProjectData()
  1571. if (hovered) return hovered
  1572. return currentProject()
  1573. })
  1574. function handleWorkspaceDragStart(event: unknown) {
  1575. const id = getDraggableId(event)
  1576. if (!id) return
  1577. setStore("activeWorkspace", id)
  1578. }
  1579. function handleWorkspaceDragOver(event: DragEvent) {
  1580. const { draggable, droppable } = event
  1581. if (!draggable || !droppable) return
  1582. const project = sidebarProject()
  1583. if (!project) return
  1584. const ids = workspaceIds(project)
  1585. const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
  1586. const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
  1587. if (fromIndex === -1 || toIndex === -1) return
  1588. if (fromIndex === toIndex) return
  1589. const result = ids.slice()
  1590. const [item] = result.splice(fromIndex, 1)
  1591. if (!item) return
  1592. result.splice(toIndex, 0, item)
  1593. setStore(
  1594. "workspaceOrder",
  1595. project.worktree,
  1596. result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)),
  1597. )
  1598. }
  1599. function handleWorkspaceDragEnd() {
  1600. setStore("activeWorkspace", undefined)
  1601. }
  1602. const createWorkspace = async (project: LocalProject) => {
  1603. clearSidebarHoverState()
  1604. const created = await serverSDK()
  1605. .client.worktree.create({ directory: project.worktree })
  1606. .then((x) => x.data)
  1607. .catch((err) => {
  1608. showToast({
  1609. title: language.t("workspace.create.failed.title"),
  1610. description: errorMessage(err, language.t("common.requestFailed")),
  1611. })
  1612. return undefined
  1613. })
  1614. if (!created?.directory) return
  1615. setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
  1616. const local = project.worktree
  1617. const key = pathKey(created.directory)
  1618. const root = pathKey(local)
  1619. setBusy(created.directory, true)
  1620. WorktreeState.pending(serverSDK().scope, created.directory)
  1621. setStore("workspaceExpanded", key, true)
  1622. if (key !== created.directory) {
  1623. setStore("workspaceExpanded", created.directory, true)
  1624. }
  1625. setStore("workspaceOrder", project.worktree, (prev) => {
  1626. const existing = prev ?? []
  1627. const next = existing.filter((item) => {
  1628. const id = pathKey(item)
  1629. return id !== root && id !== key
  1630. })
  1631. return [created.directory, ...next]
  1632. })
  1633. serverSync().child(created.directory)
  1634. navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
  1635. }
  1636. const workspaceSidebarCtx: WorkspaceSidebarContext = {
  1637. currentDir,
  1638. navList: currentSessions,
  1639. sidebarExpanded,
  1640. sidebarHovering,
  1641. clearHoverProjectSoon,
  1642. prefetchSession,
  1643. archiveSession,
  1644. workspaceName,
  1645. renameWorkspace,
  1646. editorOpen,
  1647. openEditor,
  1648. closeEditor,
  1649. setEditor,
  1650. InlineEditor,
  1651. isBusy,
  1652. workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
  1653. setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
  1654. showResetWorkspaceDialog: (root, directory) =>
  1655. dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
  1656. showDeleteWorkspaceDialog: (root, directory) =>
  1657. dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />),
  1658. setScrollContainerRef: (el, mobile) => {
  1659. if (!mobile) scrollContainerRef = el
  1660. },
  1661. }
  1662. const projectSidebarCtx: ProjectSidebarContext = {
  1663. currentDir,
  1664. currentProject,
  1665. sidebarOpened: () => layout.sidebar.opened(),
  1666. sidebarHovering,
  1667. hoverProject: () => state.hoverProject,
  1668. onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
  1669. onProjectMouseLeave: (worktree) => aim.leave(worktree),
  1670. onProjectFocus: (worktree) => aim.activate(worktree),
  1671. onHoverOpenChanged: (worktree, hoverOpen) => {
  1672. if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
  1673. setState("hoverProject", hoverOpen ? worktree : undefined)
  1674. },
  1675. navigateToProject,
  1676. openSidebar: () => layout.sidebar.open(),
  1677. closeProject,
  1678. showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
  1679. toggleProjectWorkspaces,
  1680. workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
  1681. workspaceIds,
  1682. workspaceLabel,
  1683. sessionProps: {
  1684. navList: currentSessions,
  1685. sidebarExpanded,
  1686. clearHoverProjectSoon,
  1687. prefetchSession,
  1688. archiveSession,
  1689. },
  1690. }
  1691. const SidebarPanel = (panelProps: {
  1692. project: Accessor<LocalProject | undefined>
  1693. mobile?: boolean
  1694. merged?: boolean
  1695. }) => {
  1696. const project = panelProps.project
  1697. const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
  1698. const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
  1699. const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
  1700. const projectName = createMemo(() => {
  1701. const item = project()
  1702. if (!item) return ""
  1703. return item.name || getFilename(item.worktree)
  1704. })
  1705. const projectId = createMemo(() => project()?.id ?? "")
  1706. const worktree = createMemo(() => project()?.worktree ?? "")
  1707. const slug = createMemo(() => {
  1708. const dir = worktree()
  1709. if (!dir) return ""
  1710. return base64Encode(dir)
  1711. })
  1712. const workspaces = createMemo(() => {
  1713. const item = project()
  1714. if (!item) return [] as string[]
  1715. return workspaceIds(item)
  1716. })
  1717. const unseenCount = createMemo(() =>
  1718. workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
  1719. )
  1720. const clearNotifications = () =>
  1721. workspaces()
  1722. .filter((directory) => notification.project.unseenCount(directory) > 0)
  1723. .forEach((directory) => notification.project.markViewed(directory))
  1724. const workspacesEnabled = createMemo(() => {
  1725. const item = project()
  1726. if (!item) return false
  1727. if (item.vcs !== "git") return false
  1728. return layout.sidebar.workspaces(item.worktree)()
  1729. })
  1730. const canToggle = createMemo(() => {
  1731. const item = project()
  1732. if (!item) return false
  1733. return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
  1734. })
  1735. const homedir = createMemo(() => serverSync().data.path.home)
  1736. return (
  1737. <div
  1738. classList={{
  1739. "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-3": true,
  1740. "border border-b-0 border-border-weak-base": !merged(),
  1741. "border-l border-t border-border-weaker-base": merged(),
  1742. "bg-background-base": merged() || hover(),
  1743. "bg-background-stronger": !merged() && !hover(),
  1744. "flex-1 min-w-0": panelProps.mobile,
  1745. "max-w-full overflow-hidden": panelProps.mobile,
  1746. }}
  1747. style={{
  1748. width: panelProps.mobile ? undefined : `${panel()}px`,
  1749. }}
  1750. >
  1751. <Show
  1752. when={project()}
  1753. fallback={
  1754. <Show when={empty()}>
  1755. <div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
  1756. <div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
  1757. <div class="flex flex-col gap-3">
  1758. <div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
  1759. <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
  1760. {language.t("sidebar.empty.description")}
  1761. </div>
  1762. </div>
  1763. <Button size="large" icon="folder-add-left" onClick={chooseProject}>
  1764. {language.t("command.project.open")}
  1765. </Button>
  1766. </div>
  1767. </div>
  1768. </Show>
  1769. }
  1770. keyed
  1771. >
  1772. {(project) => (
  1773. <>
  1774. <div class="shrink-0 pl-1 py-1">
  1775. <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
  1776. <div class="flex flex-col min-w-0">
  1777. <InlineEditor
  1778. id={`project:${projectId()}`}
  1779. value={projectName}
  1780. onSave={(next) => {
  1781. void renameProject(project, next)
  1782. }}
  1783. class="text-14-medium text-text-strong truncate"
  1784. displayClass="text-14-medium text-text-strong truncate"
  1785. stopPropagation
  1786. />
  1787. <Tooltip
  1788. placement="bottom"
  1789. gutter={2}
  1790. value={worktree()}
  1791. class="shrink-0"
  1792. contentStyle={{
  1793. "max-width": "640px",
  1794. transform: "translate3d(52px, 0, 0)",
  1795. }}
  1796. >
  1797. <span class="text-12-regular text-text-base truncate select-text">
  1798. {worktree().replace(homedir(), "~")}
  1799. </span>
  1800. </Tooltip>
  1801. </div>
  1802. <DropdownMenu modal={!sidebarHovering()}>
  1803. <DropdownMenu.Trigger
  1804. as={IconButton}
  1805. icon="dot-grid"
  1806. variant="ghost"
  1807. data-action="project-menu"
  1808. data-project={slug()}
  1809. class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
  1810. classList={{
  1811. "opacity-100": panelProps.mobile || merged(),
  1812. "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
  1813. !panelProps.mobile && !merged(),
  1814. }}
  1815. aria-label={language.t("common.moreOptions")}
  1816. />
  1817. <DropdownMenu.Portal>
  1818. <DropdownMenu.Content class="mt-1">
  1819. <DropdownMenu.Item
  1820. onSelect={() => {
  1821. showEditProjectDialog(server.current!, project)
  1822. }}
  1823. >
  1824. <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
  1825. </DropdownMenu.Item>
  1826. <DropdownMenu.Item
  1827. data-action="project-workspaces-toggle"
  1828. data-project={slug()}
  1829. disabled={!canToggle()}
  1830. onSelect={() => {
  1831. toggleProjectWorkspaces(project)
  1832. }}
  1833. >
  1834. <DropdownMenu.ItemLabel>
  1835. {workspacesEnabled()
  1836. ? language.t("sidebar.workspaces.disable")
  1837. : language.t("sidebar.workspaces.enable")}
  1838. </DropdownMenu.ItemLabel>
  1839. </DropdownMenu.Item>
  1840. <DropdownMenu.Item
  1841. data-action="project-clear-notifications"
  1842. data-project={slug()}
  1843. disabled={unseenCount() === 0}
  1844. onSelect={clearNotifications}
  1845. >
  1846. <DropdownMenu.ItemLabel>
  1847. {language.t("sidebar.project.clearNotifications")}
  1848. </DropdownMenu.ItemLabel>
  1849. </DropdownMenu.Item>
  1850. <DropdownMenu.Separator />
  1851. <DropdownMenu.Item
  1852. data-action="project-close-menu"
  1853. data-project={slug()}
  1854. onSelect={() => {
  1855. const dir = worktree()
  1856. if (!dir) return
  1857. closeProject(dir)
  1858. }}
  1859. >
  1860. <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
  1861. </DropdownMenu.Item>
  1862. </DropdownMenu.Content>
  1863. </DropdownMenu.Portal>
  1864. </DropdownMenu>
  1865. </div>
  1866. </div>
  1867. <div class="flex-1 min-h-0 flex flex-col">
  1868. <Show
  1869. when={workspacesEnabled()}
  1870. fallback={
  1871. <>
  1872. <div class="shrink-0 py-4">
  1873. <Button
  1874. size="large"
  1875. class="w-full"
  1876. onClick={() => {
  1877. const dir = worktree()
  1878. if (!dir) return
  1879. navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
  1880. }}
  1881. >
  1882. <IconV2 name="edit" size="small" />
  1883. {language.t("command.session.new")}
  1884. </Button>
  1885. </div>
  1886. <div class="flex-1 min-h-0">
  1887. <LocalWorkspace
  1888. ctx={workspaceSidebarCtx}
  1889. project={project}
  1890. sortNow={sortNow}
  1891. mobile={panelProps.mobile}
  1892. />
  1893. </div>
  1894. </>
  1895. }
  1896. >
  1897. <>
  1898. <div class="shrink-0 py-4">
  1899. <Button
  1900. size="large"
  1901. icon="plus-small"
  1902. class="w-full"
  1903. onClick={() => {
  1904. void createWorkspace(project)
  1905. }}
  1906. >
  1907. {language.t("workspace.new")}
  1908. </Button>
  1909. </div>
  1910. <div class="relative flex-1 min-h-0">
  1911. <DragDropProvider
  1912. onDragStart={handleWorkspaceDragStart}
  1913. onDragEnd={handleWorkspaceDragEnd}
  1914. onDragOver={handleWorkspaceDragOver}
  1915. collisionDetector={closestCenter}
  1916. >
  1917. <DragDropSensors />
  1918. <ConstrainDragXAxis />
  1919. <div
  1920. ref={(el) => {
  1921. if (!panelProps.mobile) scrollContainerRef = el
  1922. }}
  1923. class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
  1924. >
  1925. <SortableProvider ids={workspaces()}>
  1926. <For each={workspaces()}>
  1927. {(directory) => (
  1928. <SortableWorkspace
  1929. ctx={workspaceSidebarCtx}
  1930. directory={directory}
  1931. project={project}
  1932. sortNow={sortNow}
  1933. mobile={panelProps.mobile}
  1934. />
  1935. )}
  1936. </For>
  1937. </SortableProvider>
  1938. </div>
  1939. <DragOverlay>
  1940. <WorkspaceDragOverlay
  1941. sidebarProject={sidebarProject}
  1942. activeWorkspace={() => store.activeWorkspace}
  1943. workspaceLabel={workspaceLabel}
  1944. />
  1945. </DragOverlay>
  1946. </DragDropProvider>
  1947. </div>
  1948. </>
  1949. </Show>
  1950. </div>
  1951. </>
  1952. )}
  1953. </Show>
  1954. <div
  1955. class="shrink-0 px-3 py-3"
  1956. classList={{
  1957. hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
  1958. }}
  1959. >
  1960. <div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
  1961. <div class="p-3 flex flex-col gap-6">
  1962. <div class="flex flex-col gap-2">
  1963. <div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
  1964. <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
  1965. {language.t("sidebar.gettingStarted.line1")}
  1966. </div>
  1967. <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
  1968. {language.t("sidebar.gettingStarted.line2")}
  1969. </div>
  1970. </div>
  1971. <div data-component="getting-started-actions">
  1972. <Button size="large" icon="plus-small" onClick={connectProvider}>
  1973. {language.t("command.provider.connect")}
  1974. </Button>
  1975. <Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
  1976. {language.t("toast.update.action.notYet")}
  1977. </Button>
  1978. </div>
  1979. </div>
  1980. </div>
  1981. </div>
  1982. </div>
  1983. )
  1984. }
  1985. const projects = () => layout.projects.list()
  1986. const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
  1987. const sidebarContent = (mobile?: boolean) => (
  1988. <SidebarContent
  1989. mobile={mobile}
  1990. opened={() => layout.sidebar.opened()}
  1991. aimMove={aim.move}
  1992. projects={projects}
  1993. renderProject={(project) => (
  1994. <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
  1995. )}
  1996. handleDragStart={handleDragStart}
  1997. handleDragEnd={handleDragEnd}
  1998. handleDragOver={handleDragOver}
  1999. openProjectLabel={language.t("command.project.open")}
  2000. openProjectKeybind={() => command.keybind("project.open")}
  2001. onOpenProject={chooseProject}
  2002. renderProjectOverlay={projectOverlay}
  2003. settingsLabel={() => language.t("sidebar.settings")}
  2004. settingsKeybind={() => command.keybind("settings.open")}
  2005. onOpenSettings={openSettings}
  2006. helpLabel={() => language.t("sidebar.help")}
  2007. onOpenHelp={() => platform.openLink("https://kailin.com.cn/feedback")}
  2008. renderPanel={() =>
  2009. mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
  2010. }
  2011. />
  2012. )
  2013. return (
  2014. <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
  2015. {autoselecting() ?? ""}
  2016. <Titlebar update={titlebarUpdate} />
  2017. <Show when={updateVersion() !== undefined}>
  2018. <UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
  2019. </Show>
  2020. <div class="flex-1 min-h-0 min-w-0 flex">
  2021. <div class="flex-1 min-h-0 relative">
  2022. <div class="size-full relative overflow-x-hidden">
  2023. <nav
  2024. aria-label={language.t("sidebar.nav.projectsAndSessions")}
  2025. data-component="sidebar-nav-desktop"
  2026. classList={{
  2027. "hidden xl:block": true,
  2028. "absolute inset-y-0 left-0": true,
  2029. "z-10": true,
  2030. }}
  2031. style={{ width: `${side()}px` }}
  2032. ref={(el) => {
  2033. setState("nav", el)
  2034. }}
  2035. onMouseEnter={() => {
  2036. disarm()
  2037. }}
  2038. onMouseLeave={() => {
  2039. aim.reset()
  2040. if (!sidebarHovering()) return
  2041. arm()
  2042. }}
  2043. >
  2044. <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
  2045. </nav>
  2046. <Show when={layout.sidebar.opened()}>
  2047. <div
  2048. class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
  2049. style={{ left: `${side()}px` }}
  2050. onPointerDown={() => setState("sizing", true)}
  2051. >
  2052. <ResizeHandle
  2053. direction="horizontal"
  2054. size={layout.sidebar.width()}
  2055. min={244}
  2056. max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
  2057. onResize={(w) => {
  2058. setState("sizing", true)
  2059. if (sizet !== undefined) clearTimeout(sizet)
  2060. sizet = window.setTimeout(() => setState("sizing", false), 120)
  2061. layout.sidebar.resize(w)
  2062. }}
  2063. />
  2064. </div>
  2065. </Show>
  2066. <div
  2067. class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
  2068. style={{ left: "calc(4rem + 12px)" }}
  2069. />
  2070. <div class="xl:hidden">
  2071. <div
  2072. classList={{
  2073. "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
  2074. "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
  2075. "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
  2076. }}
  2077. onClick={(e) => {
  2078. if (e.target === e.currentTarget) layout.mobileSidebar.hide()
  2079. }}
  2080. />
  2081. <nav
  2082. aria-label={language.t("sidebar.nav.projectsAndSessions")}
  2083. data-component="sidebar-nav-mobile"
  2084. classList={{
  2085. "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
  2086. "translate-x-0": layout.mobileSidebar.opened(),
  2087. "-translate-x-full": !layout.mobileSidebar.opened(),
  2088. }}
  2089. onClick={(e) => e.stopPropagation()}
  2090. >
  2091. {sidebarContent(true)}
  2092. </nav>
  2093. </div>
  2094. <div
  2095. classList={{
  2096. "absolute inset-0": true,
  2097. "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
  2098. "z-20": true,
  2099. "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
  2100. !state.sizing,
  2101. }}
  2102. style={{
  2103. "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
  2104. }}
  2105. >
  2106. <main
  2107. classList={{
  2108. "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
  2109. }}
  2110. >
  2111. <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
  2112. {props.children}
  2113. </Show>
  2114. </main>
  2115. </div>
  2116. <div
  2117. classList={{
  2118. "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
  2119. "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
  2120. "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
  2121. "transition-[opacity,transform] motion-reduce:transition-none": true,
  2122. "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
  2123. "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
  2124. }}
  2125. onMouseMove={disarm}
  2126. onMouseEnter={() => {
  2127. disarm()
  2128. aim.reset()
  2129. }}
  2130. onPointerDown={disarm}
  2131. onMouseLeave={() => {
  2132. arm()
  2133. }}
  2134. >
  2135. <Show when={peekProject()}>
  2136. <SidebarPanel project={peekProject} merged={false} />
  2137. </Show>
  2138. </div>
  2139. <div
  2140. classList={{
  2141. "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
  2142. "opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
  2143. "opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
  2144. "transition-[opacity,transform] motion-reduce:transition-none": true,
  2145. "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
  2146. "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
  2147. }}
  2148. style={{ left: `calc(4rem + ${panel()}px)` }}
  2149. >
  2150. <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
  2151. </div>
  2152. </div>
  2153. </div>
  2154. {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
  2155. </div>
  2156. <TabsInfoPopup />
  2157. <HelpButton />
  2158. <ToastRegion v2={false} />
  2159. </div>
  2160. )
  2161. }
  2162. function UpdateAvailableToast(props: {
  2163. version: string
  2164. install: () => void
  2165. language: ReturnType<typeof useLanguage>
  2166. }) {
  2167. let toastId: number | undefined
  2168. onMount(() => {
  2169. toastId = showToast({
  2170. persistent: true,
  2171. icon: "download",
  2172. title: props.language.t("toast.update.title"),
  2173. description: props.language.t("toast.update.description", { version: props.version }),
  2174. actions: [
  2175. {
  2176. label: props.language.t("toast.update.action.installRestart"),
  2177. onClick: props.install,
  2178. },
  2179. {
  2180. label: props.language.t("toast.update.action.notYet"),
  2181. onClick: "dismiss",
  2182. },
  2183. ],
  2184. })
  2185. })
  2186. onCleanup(() => {
  2187. if (toastId === undefined) return
  2188. toaster.dismiss(toastId)
  2189. })
  2190. return null
  2191. }