| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437 |
- import {
- createEffect,
- createMemo,
- createResource,
- For,
- on,
- onCleanup,
- onMount,
- ParentProps,
- Show,
- untrack,
- type Accessor,
- } from "solid-js"
- import { makeEventListener } from "@solid-primitives/event-listener"
- import { useNavigate, useParams } from "@solidjs/router"
- import { useLayout, LocalProject } from "@/context/layout"
- import { useServerSync } from "@/context/server-sync"
- import { Persist, persisted } from "@/utils/persist"
- import { base64Encode } from "@kirincode-ai/core/util/encode"
- import { decode64 } from "@/utils/base64"
- import { ResizeHandle } from "@kirincode-ai/ui/resize-handle"
- import { Button } from "@kirincode-ai/ui/button"
- import { Icon as IconV2 } from "@kirincode-ai/ui/v2/icon"
- import { IconButton } from "@kirincode-ai/ui/icon-button"
- import { Tooltip } from "@kirincode-ai/ui/tooltip"
- import { DropdownMenu } from "@kirincode-ai/ui/dropdown-menu"
- import { Dialog } from "@kirincode-ai/ui/dialog"
- import { getFilename } from "@kirincode-ai/core/util/path"
- import { Session } from "@kirincode-ai/sdk/v2/client"
- import { usePlatform } from "@/context/platform"
- import { useSettings } from "@/context/settings"
- import { createStore, produce, reconcile } from "solid-js/store"
- import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
- import type { DragEvent } from "@thisbeyond/solid-dnd"
- import { useProviders } from "@/hooks/use-providers"
- import { toaster } from "@kirincode-ai/ui/toast"
- import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
- import { useServerSDK } from "@/context/server-sdk"
- import { clearWorkspaceTerminals } from "@/context/terminal"
- import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
- import { useNotification } from "@/context/notification"
- import { usePermission } from "@/context/permission"
- import { Binary } from "@kirincode-ai/core/util/binary"
- import { retry } from "@kirincode-ai/core/util/retry"
- import { playSoundById } from "@/utils/sound"
- import { createAim } from "@/utils/aim"
- import { setNavigate } from "@/utils/notification-click"
- import { Worktree as WorktreeState } from "@/utils/worktree"
- import { setSessionHandoff } from "@/pages/session/handoff"
- import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
- import { useDialog } from "@kirincode-ai/ui/context/dialog"
- import { useTheme, type ColorScheme } from "@kirincode-ai/ui/theme/context"
- import { useCommand, type CommandOption } from "@/context/command"
- import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
- import { DebugBar } from "@/components/debug-bar"
- import { HelpButton, TabsInfoPopup } from "@/components/help-button"
- import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
- import { useDirectoryPicker } from "@/components/directory-picker"
- import { ServerConnection, useServer } from "@/context/server"
- import { useLanguage, type Locale } from "@/context/language"
- import { pathKey } from "@/utils/path-key"
- import {
- displayName,
- effectiveWorkspaceOrder,
- errorMessage,
- latestRootSession,
- sortedRootSessions,
- } from "./layout/helpers"
- import {
- collectNewSessionDeepLinks,
- collectOpenProjectDeepLinks,
- deepLinkEvent,
- drainPendingDeepLinks,
- } from "./layout/deep-links"
- import { createInlineEditorController } from "./layout/inline-editor"
- import {
- LocalWorkspace,
- SortableWorkspace,
- WorkspaceDragOverlay,
- type WorkspaceSidebarContext,
- } from "./layout/sidebar-workspace"
- import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
- import { SidebarContent } from "./layout/sidebar-shell"
- export default function LegacyLayout(props: ParentProps) {
- const serverSDK = useServerSDK()
- const [store, setStore, , ready] = persisted(
- Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
- createStore({
- lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
- activeProject: undefined as string | undefined,
- activeWorkspace: undefined as string | undefined,
- workspaceOrder: {} as Record<string, string[]>,
- workspaceName: {} as Record<string, string>,
- workspaceBranchName: {} as Record<string, Record<string, string>>,
- workspaceExpanded: {} as Record<string, boolean>,
- gettingStartedDismissed: false,
- }),
- )
- const pageReady = createMemo(() => ready())
- let scrollContainerRef: HTMLDivElement | undefined
- let dialogRun = 0
- let dialogDead = false
- const params = useParams()
- const serverSync = useServerSync()
- const layout = useLayout()
- const layoutReady = createMemo(() => layout.ready())
- const platform = usePlatform()
- const pickDirectory = useDirectoryPicker()
- const settings = useSettings()
- const server = useServer()
- const notification = useNotification()
- const permission = usePermission()
- const navigate = useNavigate()
- setNavigate(navigate)
- const providers = useProviders()
- const dialog = useDialog()
- const command = useCommand()
- const theme = useTheme()
- const language = useLanguage()
- createEffect(() => setV2Toast(false))
- const initialDirectory = decode64(params.dir)
- const route = createMemo(() => {
- const slug = params.dir
- if (!slug) return { slug, dir: "" }
- const dir = decode64(slug)
- if (!dir) return { slug, dir: "" }
- const store = serverSync().peek(dir, { bootstrap: false })
- return {
- slug,
- store,
- dir: store[0].path.directory || dir,
- }
- })
- const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
- const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
- const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
- system: "theme.scheme.system",
- light: "theme.scheme.light",
- dark: "theme.scheme.dark",
- }
- const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
- const currentDir = createMemo(() => route().dir)
- const [state, setState] = createStore({
- autoselect: !initialDirectory,
- busyWorkspaces: {} as Record<string, boolean>,
- hoverProject: undefined as string | undefined,
- scrollSessionKey: undefined as string | undefined,
- nav: undefined as HTMLElement | undefined,
- sortNow: Date.now(),
- sizing: false,
- peek: undefined as string | undefined,
- peeked: false,
- })
- const updateVersion = () => {
- const state = platform.updater?.state()
- if (state?.status !== "ready") return
- return state.version
- }
- const installUpdate = () => void platform.updater?.install()
- const titlebarUpdate: TitlebarUpdate = {
- version: updateVersion,
- installing: () => platform.updater?.state().status === "installing",
- install: installUpdate,
- }
- const editor = createInlineEditorController()
- const setBusy = (directory: string, value: boolean) => {
- const key = pathKey(directory)
- if (value) {
- setState("busyWorkspaces", key, true)
- return
- }
- setState(
- "busyWorkspaces",
- produce((draft) => {
- delete draft[key]
- }),
- )
- }
- const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)]
- const navLeave = { current: undefined as number | undefined }
- const sortNow = () => state.sortNow
- let sizet: number | undefined
- let sortNowInterval: ReturnType<typeof setInterval> | undefined
- const sortNowTimeout = setTimeout(
- () => {
- setState("sortNow", Date.now())
- sortNowInterval = setInterval(() => setState("sortNow", Date.now()), 60_000)
- },
- 60_000 - (Date.now() % 60_000),
- )
- const aim = createAim({
- enabled: () => !layout.sidebar.opened(),
- active: () => state.hoverProject,
- el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
- onActivate: (directory) => {
- serverSync().child(directory)
- setState("hoverProject", directory)
- },
- })
- onCleanup(() => {
- dialogDead = true
- dialogRun += 1
- if (navLeave.current !== undefined) clearTimeout(navLeave.current)
- clearTimeout(sortNowTimeout)
- if (sortNowInterval) clearInterval(sortNowInterval)
- if (sizet !== undefined) clearTimeout(sizet)
- if (peekt !== undefined) clearTimeout(peekt)
- aim.reset()
- })
- onMount(() => {
- const stop = () => setState("sizing", false)
- const blur = () => reset()
- const hide = () => {
- if (document.visibilityState !== "hidden") return
- reset()
- }
- makeEventListener(window, "pointerup", stop)
- makeEventListener(window, "pointercancel", stop)
- makeEventListener(window, "blur", stop)
- makeEventListener(window, "blur", blur)
- makeEventListener(document, "visibilitychange", hide)
- })
- const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
- const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
- const setHoverProject = (value: string | undefined) => {
- setState("hoverProject", value)
- if (value !== undefined) return
- aim.reset()
- }
- const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
- const disarm = () => {
- if (navLeave.current === undefined) return
- clearTimeout(navLeave.current)
- navLeave.current = undefined
- }
- const reset = () => {
- disarm()
- setHoverProject(undefined)
- }
- const arm = () => {
- if (layout.sidebar.opened()) return
- if (state.hoverProject === undefined) return
- disarm()
- navLeave.current = window.setTimeout(() => {
- navLeave.current = undefined
- setHoverProject(undefined)
- }, 300)
- }
- let peekt: number | undefined
- const hoverProjectData = createMemo(() => {
- const id = state.hoverProject
- if (!id) return
- return layout.projects.list().find((project) => project.worktree === id)
- })
- const peekProject = createMemo(() => {
- const id = state.peek
- if (!id) return
- return layout.projects.list().find((project) => project.worktree === id)
- })
- createEffect(() => {
- const p = hoverProjectData()
- if (p) {
- if (peekt !== undefined) {
- clearTimeout(peekt)
- peekt = undefined
- }
- setState("peek", p.worktree)
- setState("peeked", true)
- return
- }
- setState("peeked", false)
- if (state.peek === undefined) return
- if (peekt !== undefined) clearTimeout(peekt)
- peekt = window.setTimeout(() => {
- peekt = undefined
- setState("peek", undefined)
- }, 180)
- })
- createEffect(() => {
- if (!layout.sidebar.opened()) return
- setHoverProject(undefined)
- })
- createEffect(() => {
- if (!state.autoselect) return
- const dir = params.dir
- if (!dir) return
- const directory = decode64(dir)
- if (!directory) return
- setState("autoselect", false)
- })
- const editorOpen = editor.editorOpen
- const openEditor = editor.openEditor
- const closeEditor = editor.closeEditor
- const setEditor = editor.setEditor
- const InlineEditor = editor.InlineEditor
- const clearSidebarHoverState = () => {
- if (layout.sidebar.opened()) return
- reset()
- }
- const navigateWithSidebarReset = (href: string) => {
- clearSidebarHoverState()
- navigate(href)
- layout.mobileSidebar.hide()
- }
- function cycleTheme(direction = 1) {
- const ids = availableThemeEntries().map(([id]) => id)
- if (ids.length === 0) return
- const currentIndex = ids.indexOf(theme.themeId())
- const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
- const nextThemeId = ids[nextIndex]
- theme.setTheme(nextThemeId)
- showToast({
- title: language.t("toast.theme.title"),
- description: theme.name(nextThemeId),
- })
- }
- function cycleColorScheme(direction = 1) {
- const current = theme.colorScheme()
- const currentIndex = colorSchemeOrder.indexOf(current)
- const nextIndex =
- currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
- const next = colorSchemeOrder[nextIndex]
- theme.setColorScheme(next)
- showToast({
- title: language.t("toast.scheme.title"),
- description: colorSchemeLabel(next),
- })
- }
- function setLocale(next: Locale) {
- if (next === language.locale()) return
- language.setLocale(next)
- showToast({
- title: language.t("toast.language.title"),
- description: language.t("toast.language.description", { language: language.label(next) }),
- })
- }
- function cycleLanguage(direction = 1) {
- const locales = language.locales
- const currentIndex = locales.indexOf(language.locale())
- const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length
- const next = locales[nextIndex]
- if (!next) return
- setLocale(next)
- }
- const useSDKNotificationToasts = () =>
- onMount(() => {
- const toastBySession = new Map<string, number>()
- const alertedAtBySession = new Map<string, number>()
- const cooldownMs = 5000
- const dismissSessionAlert = (sessionKey: string) => {
- const toastId = toastBySession.get(sessionKey)
- if (toastId === undefined) return
- toaster.dismiss(toastId)
- toastBySession.delete(sessionKey)
- alertedAtBySession.delete(sessionKey)
- }
- const unsub = serverSDK().event.listen((e) => {
- if (e.details?.type === "worktree.ready") {
- setBusy(e.name, false)
- WorktreeState.ready(serverSDK().scope, e.name)
- return
- }
- if (e.details?.type === "worktree.failed") {
- setBusy(e.name, false)
- WorktreeState.failed(
- serverSDK().scope,
- e.name,
- e.details.properties?.message ?? language.t("common.requestFailed"),
- )
- return
- }
- if (
- e.details?.type === "question.replied" ||
- e.details?.type === "question.rejected" ||
- e.details?.type === "permission.replied"
- ) {
- const props = e.details.properties as { sessionID: string }
- const sessionKey = `${e.name}:${props.sessionID}`
- dismissSessionAlert(sessionKey)
- return
- }
- if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
- const title =
- e.details.type === "permission.asked"
- ? language.t("notification.permission.title")
- : language.t("notification.question.title")
- const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
- const directory = e.name
- const props = e.details.properties
- if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
- const [store] = serverSync().child(directory, { bootstrap: false })
- const session = store.session.find((s) => s.id === props.sessionID)
- const sessionKey = `${directory}:${props.sessionID}`
- const sessionTitle = session?.title ?? language.t("command.session.new")
- const projectName = getFilename(directory)
- const description =
- e.details.type === "permission.asked"
- ? language.t("notification.permission.description", { sessionTitle, projectName })
- : language.t("notification.question.description", { sessionTitle, projectName })
- const href = `/${base64Encode(directory)}/session/${props.sessionID}`
- const now = Date.now()
- const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
- if (now - lastAlerted < cooldownMs) return
- alertedAtBySession.set(sessionKey, now)
- if (e.details.type === "permission.asked") {
- if (settings.sounds.permissionsEnabled()) {
- void playSoundById(settings.sounds.permissions())
- }
- if (settings.notifications.permissions()) {
- void platform.notify(title, description, href)
- }
- }
- if (e.details.type === "question.asked") {
- if (settings.notifications.agent()) {
- void platform.notify(title, description, href)
- }
- }
- const currentSession = params.id
- if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return
- if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return
- dismissSessionAlert(sessionKey)
- const toastId = showToast({
- persistent: true,
- icon,
- title,
- description,
- actions: [
- {
- label: language.t("notification.action.goToSession"),
- onClick: () => navigate(href),
- },
- {
- label: language.t("common.dismiss"),
- onClick: "dismiss",
- },
- ],
- })
- toastBySession.set(sessionKey, toastId)
- })
- onCleanup(unsub)
- createEffect(() => {
- const currentSession = params.id
- if (!currentDir() || !currentSession) return
- const sessionKey = `${currentDir()}:${currentSession}`
- dismissSessionAlert(sessionKey)
- const [store] = serverSync().child(currentDir(), { bootstrap: false })
- const childSessions = store.session.filter((s) => s.parentID === currentSession)
- for (const child of childSessions) {
- dismissSessionAlert(`${currentDir()}:${child.id}`)
- }
- })
- })
- useSDKNotificationToasts()
- function scrollToSession(sessionId: string, sessionKey: string) {
- if (!scrollContainerRef) return
- if (state.scrollSessionKey === sessionKey) return
- const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
- if (!element) return
- const containerRect = scrollContainerRef.getBoundingClientRect()
- const elementRect = element.getBoundingClientRect()
- if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
- setState("scrollSessionKey", sessionKey)
- return
- }
- setState("scrollSessionKey", sessionKey)
- element.scrollIntoView({ block: "nearest", behavior: "smooth" })
- }
- const currentProject = createMemo(() => {
- const directory = currentDir()
- if (!directory) return
- const key = pathKey(directory)
- const projects = layout.projects.list()
- const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key))
- if (sandbox) return sandbox
- const direct = projects.find((p) => pathKey(p.worktree) === key)
- if (direct) return direct
- const [child] = serverSync().child(directory, { bootstrap: false })
- const id = child.project
- if (!id) return
- const meta = serverSync().data.project.find((p) => p.id === id)
- const root = meta?.worktree
- if (!root) return
- return projects.find((p) => p.worktree === root)
- })
- const [autoselecting] = createResource(async () => {
- await ready.promise
- await layout.ready.promise
- if (!untrack(() => state.autoselect)) return
- const list = layout.projects.list()
- const last = server.projects.last()
- if (list.length === 0) {
- if (!last) return
- await openProject(last, true)
- } else {
- const next = list.find((project) => project.worktree === last) ?? list[0]
- if (!next) return
- await openProject(next.worktree, true)
- }
- })
- const workspaceName = (directory: string, projectId?: string, branch?: string) => {
- const key = pathKey(directory)
- const direct = store.workspaceName[key] ?? store.workspaceName[directory]
- if (direct) return direct
- if (!projectId) return
- if (!branch) return
- return store.workspaceBranchName[projectId]?.[branch]
- }
- const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
- const key = pathKey(directory)
- setStore("workspaceName", key, next)
- if (!projectId) return
- if (!branch) return
- if (!store.workspaceBranchName[projectId]) {
- setStore("workspaceBranchName", projectId, {})
- }
- setStore("workspaceBranchName", projectId, branch, next)
- }
- const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
- workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
- const workspaceSetting = createMemo(() => {
- const project = currentProject()
- if (!project) return false
- if (project.vcs !== "git") return false
- return layout.sidebar.workspaces(project.worktree)()
- })
- const visibleSessionDirs = createMemo(() => {
- const project = currentProject()
- if (!project) return [] as string[]
- if (!workspaceSetting()) return [project.worktree]
- const activeDir = currentDir()
- return workspaceIds(project).filter((directory) => {
- const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
- const active = pathKey(directory) === pathKey(activeDir)
- return expanded || active
- })
- })
- createEffect(() => {
- if (!pageReady()) return
- if (!layoutReady()) return
- const projects = layout.projects.list()
- for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
- if (!expanded) continue
- const key = pathKey(directory)
- const project = projects.find(
- (item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
- )
- if (!project) continue
- if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
- setStore("workspaceExpanded", directory, false)
- }
- })
- const currentSessions = createMemo(() => {
- const now = Date.now()
- const dirs = visibleSessionDirs()
- if (dirs.length === 0) return [] as Session[]
- const result: Session[] = []
- for (const dir of dirs) {
- const [dirStore] = serverSync().child(dir, { bootstrap: true })
- const dirSessions = sortedRootSessions(dirStore, now)
- result.push(...dirSessions)
- }
- return result
- })
- type PrefetchQueue = {
- inflight: Set<string>
- pending: string[]
- pendingSet: Set<string>
- running: number
- }
- const prefetchChunk = 200
- const prefetchConcurrency = 2
- const prefetchPendingLimit = 10
- const span = 4
- const prefetchToken = { value: 0 }
- const prefetchQueues = new Map<string, PrefetchQueue>()
- const PREFETCH_MAX_SESSIONS_PER_DIR = 10
- const prefetchedByDir = new Map<string, Set<string>>()
- const lruFor = (directory: string) => {
- const existing = prefetchedByDir.get(directory)
- if (existing) return existing
- const created = new Set<string>()
- prefetchedByDir.set(directory, created)
- return created
- }
- const markPrefetched = (directory: string, sessionID: string) => {
- const lru = lruFor(directory)
- return pickSessionCacheEvictions({
- seen: lru,
- keep: sessionID,
- limit: PREFETCH_MAX_SESSIONS_PER_DIR,
- preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined,
- })
- }
- createEffect(() => {
- const active = new Set(visibleSessionDirs())
- for (const directory of prefetchedByDir.keys()) {
- if (active.has(directory)) continue
- prefetchedByDir.delete(directory)
- }
- })
- createEffect(() => {
- route()
- serverSDK().url
- prefetchToken.value += 1
- prefetchQueues.clear()
- })
- createEffect(() => {
- const visible = new Set(visibleSessionDirs())
- for (const [directory, q] of prefetchQueues) {
- if (visible.has(directory)) continue
- q.pending.length = 0
- q.pendingSet.clear()
- if (q.running === 0) prefetchQueues.delete(directory)
- }
- })
- const queueFor = (directory: string) => {
- const existing = prefetchQueues.get(directory)
- if (existing) return existing
- const created: PrefetchQueue = {
- inflight: new Set(),
- pending: [],
- pendingSet: new Set(),
- running: 0,
- }
- prefetchQueues.set(directory, created)
- return created
- }
- async function prefetchMessages(directory: string, sessionID: string, token: number) {
- await serverSync()
- .session.prefetch(sessionID, prefetchChunk)
- .catch(() => {})
- if (prefetchToken.value !== token) return
- for (const stale of markPrefetched(directory, sessionID)) serverSync().session.evict(stale)
- }
- const pumpPrefetch = (directory: string) => {
- const q = queueFor(directory)
- if (q.running >= prefetchConcurrency) return
- const sessionID = q.pending.shift()
- if (!sessionID) return
- q.pendingSet.delete(sessionID)
- q.inflight.add(sessionID)
- q.running += 1
- const token = prefetchToken.value
- void prefetchMessages(directory, sessionID, token).finally(() => {
- q.running -= 1
- q.inflight.delete(sessionID)
- pumpPrefetch(directory)
- })
- }
- const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
- const directory = session.directory
- if (!directory) return
- const cached = untrack(() => !serverSync().session.shouldPrefetch(session.id, prefetchChunk))
- if (cached) return
- const q = queueFor(directory)
- if (q.inflight.has(session.id)) return
- if (q.pendingSet.has(session.id)) {
- if (priority !== "high") return
- const index = q.pending.indexOf(session.id)
- if (index > 0) {
- q.pending.splice(index, 1)
- q.pending.unshift(session.id)
- }
- return
- }
- const lru = lruFor(directory)
- const known = lru.has(session.id)
- if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
- if (priority === "high") q.pending.unshift(session.id)
- if (priority !== "high") q.pending.push(session.id)
- q.pendingSet.add(session.id)
- while (q.pending.length > prefetchPendingLimit) {
- const dropped = q.pending.pop()
- if (!dropped) continue
- q.pendingSet.delete(dropped)
- }
- pumpPrefetch(directory)
- }
- const warm = (sessions: Session[], index: number) => {
- for (let offset = 1; offset <= span; offset++) {
- const next = sessions[index + offset]
- if (next) prefetchSession(next, offset === 1 ? "high" : "low")
- const prev = sessions[index - offset]
- if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
- }
- }
- createEffect(() => {
- const sessions = currentSessions()
- if (sessions.length === 0) return
- const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
- if (index === -1) return
- if (!params.id) {
- const first = sessions[index]
- if (first) prefetchSession(first, "high")
- }
- warm(sessions, index)
- })
- function navigateSessionByOffset(offset: number) {
- const sessions = currentSessions()
- if (sessions.length === 0) return
- const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
- let targetIndex: number
- if (sessionIndex === -1) {
- targetIndex = offset > 0 ? 0 : sessions.length - 1
- } else {
- targetIndex = (sessionIndex + offset + sessions.length) % sessions.length
- }
- const session = sessions[targetIndex]
- if (!session) return
- prefetchSession(session, "high")
- warm(sessions, targetIndex)
- navigateToSession(session)
- }
- function navigateProjectByOffset(offset: number) {
- const projects = layout.projects.list()
- if (projects.length === 0) return
- const current = currentProject()?.worktree
- const fallback = currentDir() ? projectRoot(currentDir()) : undefined
- const active = current ?? fallback
- const index = active ? projects.findIndex((project) => project.worktree === active) : -1
- const target =
- index === -1
- ? offset > 0
- ? projects[0]
- : projects[projects.length - 1]
- : projects[(index + offset + projects.length) % projects.length]
- if (!target) return
- // warm up child store to prevent flicker
- serverSync().child(target.worktree)
- void openProject(target.worktree)
- }
- function navigateToProjectIndex(index: number) {
- const projects = layout.projects.list()
- const target = projects[index]
- if (!target) return
- serverSync().child(target.worktree)
- void openProject(target.worktree)
- }
- function navigateSessionByUnseen(offset: number) {
- const sessions = currentSessions()
- if (sessions.length === 0) return
- const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0)
- if (!hasUnseen) return
- const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
- const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
- for (let i = 1; i <= sessions.length; i++) {
- const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
- const session = sessions[index]
- if (!session) continue
- if (notification.session.unseenCount(session.id) === 0) continue
- prefetchSession(session, "high")
- warm(sessions, index)
- navigateToSession(session)
- return
- }
- }
- async function archiveSession(session: Session) {
- const [store, setStore] = serverSync().child(session.directory)
- const sessions = store.session ?? []
- const index = sessions.findIndex((s) => s.id === session.id)
- const nextSession = sessions[index + 1] ?? sessions[index - 1]
- await serverSDK().client.session.update({
- directory: session.directory,
- sessionID: session.id,
- time: { archived: Date.now() },
- })
- setStore(
- produce((draft) => {
- const match = Binary.search(draft.session, session.id, (s) => s.id)
- if (match.found) draft.session.splice(match.index, 1)
- }),
- )
- if (session.id === params.id) {
- if (nextSession) {
- navigate(`/${params.dir}/session/${nextSession.id}`)
- } else {
- navigate(`/${params.dir}/session`)
- }
- }
- }
- command.register("layout", () => {
- const commands: CommandOption[] = [
- {
- id: "sidebar.toggle",
- title: language.t("command.sidebar.toggle"),
- category: language.t("command.category.view"),
- keybind: "mod+b",
- onSelect: () => layout.sidebar.toggle(),
- },
- {
- id: "project.open",
- title: language.t("command.project.open"),
- category: language.t("command.category.project"),
- keybind: "mod+o",
- onSelect: () => chooseProject(),
- },
- {
- id: "project.previous",
- title: language.t("command.project.previous"),
- category: language.t("command.category.project"),
- keybind: "mod+alt+arrowup",
- onSelect: () => navigateProjectByOffset(-1),
- },
- {
- id: "project.next",
- title: language.t("command.project.next"),
- category: language.t("command.category.project"),
- keybind: "mod+alt+arrowdown",
- onSelect: () => navigateProjectByOffset(1),
- },
- {
- id: "provider.connect",
- title: language.t("command.provider.connect"),
- category: language.t("command.category.provider"),
- onSelect: () => connectProvider(),
- },
- {
- id: "server.switch",
- title: language.t("command.server.switch"),
- category: language.t("command.category.server"),
- onSelect: () => openServer(),
- },
- {
- id: "settings.open",
- title: language.t("command.settings.open"),
- category: language.t("command.category.settings"),
- keybind: "mod+comma",
- onSelect: () => openSettings(),
- },
- {
- id: "session.previous",
- title: language.t("command.session.previous"),
- category: language.t("command.category.session"),
- keybind: "alt+arrowup",
- onSelect: () => navigateSessionByOffset(-1),
- },
- {
- id: "session.next",
- title: language.t("command.session.next"),
- category: language.t("command.category.session"),
- keybind: "alt+arrowdown",
- onSelect: () => navigateSessionByOffset(1),
- },
- {
- id: "session.previous.unseen",
- title: language.t("command.session.previous.unseen"),
- category: language.t("command.category.session"),
- keybind: "shift+alt+arrowup",
- onSelect: () => navigateSessionByUnseen(-1),
- },
- {
- id: "session.next.unseen",
- title: language.t("command.session.next.unseen"),
- category: language.t("command.category.session"),
- keybind: "shift+alt+arrowdown",
- onSelect: () => navigateSessionByUnseen(1),
- },
- {
- id: "session.archive",
- title: language.t("command.session.archive"),
- category: language.t("command.category.session"),
- keybind: "mod+shift+backspace",
- disabled: !params.dir || !params.id,
- onSelect: () => {
- const session = currentSessions().find((s) => s.id === params.id)
- if (session) void archiveSession(session)
- },
- },
- {
- id: "workspace.new",
- title: language.t("workspace.new"),
- category: language.t("command.category.workspace"),
- keybind: "mod+shift+w",
- disabled: !workspaceSetting(),
- onSelect: () => {
- const project = currentProject()
- if (!project) return
- return createWorkspace(project)
- },
- },
- {
- id: "workspace.toggle",
- title: language.t("command.workspace.toggle"),
- description: language.t("command.workspace.toggle.description"),
- category: language.t("command.category.workspace"),
- slash: "workspace",
- disabled: !currentProject() || currentProject()?.vcs !== "git",
- onSelect: () => {
- const project = currentProject()
- if (!project) return
- if (project.vcs !== "git") return
- const wasEnabled = layout.sidebar.workspaces(project.worktree)()
- layout.sidebar.toggleWorkspaces(project.worktree)
- showToast({
- title: wasEnabled
- ? language.t("toast.workspace.disabled.title")
- : language.t("toast.workspace.enabled.title"),
- description: wasEnabled
- ? language.t("toast.workspace.disabled.description")
- : language.t("toast.workspace.enabled.description"),
- })
- },
- },
- {
- id: "theme.cycle",
- title: language.t("command.theme.cycle"),
- category: language.t("command.category.theme"),
- keybind: "mod+shift+t",
- onSelect: () => cycleTheme(1),
- },
- ]
- Array.from({ length: 9 }, (_, i) => {
- const index = i
- const number = index + 1
- commands.push({
- id: `project.${number}`,
- category: language.t("command.category.project"),
- title: `Open Project {number}`,
- keybind: `mod+${number}`,
- disabled: layout.projects.list().length <= index,
- hidden: true,
- onSelect: () => navigateToProjectIndex(index),
- })
- })
- for (const [id] of availableThemeEntries()) {
- commands.push({
- id: `theme.set.${id}`,
- title: language.t("command.theme.set", { theme: theme.name(id) }),
- category: language.t("command.category.theme"),
- onSelect: () => theme.commitPreview(),
- onHighlight: () => {
- theme.previewTheme(id)
- return () => theme.cancelPreview()
- },
- })
- }
- commands.push({
- id: "theme.scheme.cycle",
- title: language.t("command.theme.scheme.cycle"),
- category: language.t("command.category.theme"),
- keybind: "mod+shift+s",
- onSelect: () => cycleColorScheme(1),
- })
- for (const scheme of colorSchemeOrder) {
- commands.push({
- id: `theme.scheme.${scheme}`,
- title: language.t("command.theme.scheme.set", { scheme: colorSchemeLabel(scheme) }),
- category: language.t("command.category.theme"),
- onSelect: () => theme.commitPreview(),
- onHighlight: () => {
- theme.previewColorScheme(scheme)
- return () => theme.cancelPreview()
- },
- })
- }
- commands.push({
- id: "language.cycle",
- title: language.t("command.language.cycle"),
- category: language.t("command.category.language"),
- onSelect: () => cycleLanguage(1),
- })
- for (const locale of language.locales) {
- commands.push({
- id: `language.set.${locale}`,
- title: language.t("command.language.set", { language: language.label(locale) }),
- category: language.t("command.category.language"),
- onSelect: () => setLocale(locale),
- })
- }
- return commands
- })
- function connectProvider() {
- const run = ++dialogRun
- void import("@/components/dialog-connect-provider").then((x) => {
- if (dialogDead || dialogRun !== run) return
- void dialog.show(() => <x.DialogConnectProvider />)
- })
- }
- function openServer() {
- const run = ++dialogRun
- void import("@/components/dialog-select-server").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />)
- })
- }
- function openSettings() {
- const run = ++dialogRun
- const module = settings.general.newLayoutDesigns()
- ? import("@/components/settings-v2")
- : import("@/components/dialog-settings")
- void module.then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSettings />)
- })
- }
- function projectRoot(directory: string) {
- const key = pathKey(directory)
- const project = layout.projects
- .list()
- .find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key))
- if (project) return project.worktree
- const known = Object.entries(store.workspaceOrder).find(
- ([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key),
- )
- if (known) return known[0]
- const [child] = serverSync().child(directory, { bootstrap: false })
- const id = child.project
- if (!id) return directory
- const meta = serverSync().data.project.find((item) => item.id === id)
- return meta?.worktree ?? directory
- }
- function activeProjectRoot(directory: string) {
- return currentProject()?.worktree ?? projectRoot(directory)
- }
- function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
- setStore("lastProjectSession", root, { directory, id, at: Date.now() })
- return root
- }
- function clearLastProjectSession(root: string) {
- if (!store.lastProjectSession[root]) return
- setStore(
- "lastProjectSession",
- produce((draft) => {
- delete draft[root]
- }),
- )
- }
- function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
- rememberSessionRoute(directory, id, root)
- notification.session.markViewed(id)
- const expanded = untrack(() => store.workspaceExpanded[directory])
- if (expanded === false) {
- setStore("workspaceExpanded", directory, true)
- }
- requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
- return root
- }
- async function navigateToProject(directory: string | undefined) {
- if (!directory) return
- const root = projectRoot(directory)
- server.projects.touch(root)
- const project = layout.projects.list().find((item) => item.worktree === root)
- let dirs = project
- ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
- : [root]
- const canOpen = (value: string | undefined) => {
- if (!value) return false
- return dirs.some((item) => pathKey(item) === pathKey(value))
- }
- const refreshDirs = async (target?: string) => {
- if (!target || target === root || canOpen(target)) return canOpen(target)
- const listed = await serverSDK()
- .client.worktree.list({ directory: root })
- .then((x) => x.data ?? [])
- .catch(() => [] as string[])
- dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
- return canOpen(target)
- }
- const openSession = async (target: { directory: string; id: string }) => {
- if (!canOpen(target.directory)) return false
- const sync = serverSync().ensureDirSyncContext(target.directory)
- if (sync.session.get(target.id)) {
- setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
- navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
- return true
- }
- const resolved = await sync.session
- .sync(target.id)
- .then(() => sync.session.get(target.id))
- .catch(() => undefined)
- if (!resolved?.directory) return false
- if (!canOpen(resolved.directory)) return false
- setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
- navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
- return true
- }
- const projectSession = store.lastProjectSession[root]
- if (projectSession?.id) {
- await refreshDirs(projectSession.directory)
- const opened = await openSession(projectSession)
- if (opened) return
- clearLastProjectSession(root)
- }
- const latest = latestRootSession(
- dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
- Date.now(),
- )
- if (latest && (await openSession(latest))) {
- return
- }
- const fetched = latestRootSession(
- await Promise.all(
- dirs.map(async (item) => ({
- path: { directory: item },
- session: await serverSDK()
- .client.session.list({ directory: item })
- .then((x) => x.data ?? [])
- .catch(() => []),
- })),
- ),
- Date.now(),
- )
- if (fetched && (await openSession(fetched))) {
- return
- }
- navigateWithSidebarReset(`/${base64Encode(root)}/session`)
- }
- function navigateToSession(session: Session | undefined) {
- if (!session) return
- navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
- }
- function openProject(directory: string, navigate = true) {
- layout.projects.open(directory)
- if (navigate) return navigateToProject(directory)
- }
- const handleDeepLinks = (urls: string[]) => {
- if (!server.isLocal()) return
- for (const directory of collectOpenProjectDeepLinks(urls)) {
- void openProject(directory)
- }
- for (const link of collectNewSessionDeepLinks(urls)) {
- void openProject(link.directory, false)
- const slug = base64Encode(link.directory)
- if (link.prompt) {
- setSessionHandoff(SessionStateKey.from(server.scope(), SessionRouteKey.fromLegacy(slug)), {
- prompt: link.prompt,
- })
- }
- const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
- navigateWithSidebarReset(href)
- }
- }
- onMount(() => {
- const handler = (event: Event) => {
- const detail = (event as CustomEvent<{ urls: string[] }>).detail
- const urls = detail?.urls ?? []
- if (urls.length === 0) return
- handleDeepLinks(urls)
- }
- handleDeepLinks(drainPendingDeepLinks(window))
- makeEventListener(window, deepLinkEvent, handler as EventListener)
- })
- async function renameProject(project: LocalProject, next: string) {
- const current = displayName(project)
- if (next === current) return
- const name = next === getFilename(project.worktree) ? "" : next
- if (project.id && project.id !== "global") {
- await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
- return
- }
- serverSync().project.meta(project.worktree, { name })
- }
- const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
- const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
- if (current === next) return
- setWorkspaceName(directory, next, projectId, branch)
- }
- function closeProject(directory: string) {
- const list = layout.projects.list()
- const key = pathKey(directory)
- const index = list.findIndex((x) => pathKey(x.worktree) === key)
- const active = pathKey(currentProject()?.worktree ?? "") === key
- if (index === -1) return
- if (!active) {
- layout.projects.close(directory)
- return
- }
- if (list.length === 1) {
- layout.projects.close(directory)
- navigate("/")
- return
- }
- const next = list[index + 1] ?? list[index - 1]
- navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
- layout.projects.close(directory)
- queueMicrotask(() => {
- void navigateToProject(next.worktree)
- })
- }
- function toggleProjectWorkspaces(project: LocalProject) {
- const enabled = layout.sidebar.workspaces(project.worktree)()
- if (enabled) {
- layout.sidebar.toggleWorkspaces(project.worktree)
- return
- }
- if (project.vcs !== "git") return
- layout.sidebar.toggleWorkspaces(project.worktree)
- }
- const showEditProjectDialog = (conn: ServerConnection.Any, project: LocalProject) => {
- const run = ++dialogRun
- void import("@/components/dialog-edit-project").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
- })
- }
- function chooseProject() {
- const conn = server.current
- if (!conn) return
- function resolve(result: string | string[] | null) {
- if (Array.isArray(result)) {
- for (const directory of result) {
- void openProject(directory, false)
- }
- void navigateToProject(result[0])
- } else if (result) {
- void openProject(result)
- }
- }
- pickDirectory({
- server: conn,
- title: language.t("command.project.open"),
- multiple: true,
- onSelect: resolve,
- })
- }
- const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
- if (directory === root) return
- const current = currentDir()
- const currentKey = pathKey(current)
- const deletedKey = pathKey(directory)
- const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
- if (!leaveDeletedWorkspace && shouldLeave) {
- navigateWithSidebarReset(`/${base64Encode(root)}/session`)
- }
- setBusy(directory, true)
- const result = await serverSDK()
- .client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("workspace.delete.failed.title"),
- description: errorMessage(err, language.t("common.requestFailed")),
- })
- return false
- })
- setBusy(directory, false)
- if (!result) return
- if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
- clearLastProjectSession(root)
- }
- serverSync().set(
- "project",
- produce((draft) => {
- const project = draft.find((item) => item.worktree === root)
- if (!project) return
- project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
- }),
- )
- setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
- layout.projects.close(directory)
- layout.projects.open(root)
- if (shouldLeave) return
- const nextCurrent = currentDir()
- const nextKey = pathKey(nextCurrent)
- const project = layout.projects.list().find((item) => item.worktree === root)
- const dirs = project
- ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
- : [root]
- const valid = dirs.some((item) => pathKey(item) === nextKey)
- if (params.dir && projectRoot(nextCurrent) === root && !valid) {
- navigateWithSidebarReset(`/${base64Encode(root)}/session`)
- }
- }
- const resetWorkspace = async (root: string, directory: string) => {
- if (directory === root) return
- setBusy(directory, true)
- const progress = showToast({
- persistent: true,
- title: language.t("workspace.resetting.title"),
- description: language.t("workspace.resetting.description"),
- })
- const dismiss = () => toaster.dismiss(progress)
- const sessions: Session[] = await serverSDK()
- .client.session.list({ directory })
- .then((x) => x.data ?? [])
- .catch(() => [])
- clearWorkspaceTerminals(
- directory,
- sessions.map((s) => s.id),
- platform,
- serverSDK().scope,
- )
- await serverSDK()
- .client.instance.dispose({ directory })
- .catch(() => undefined)
- const result = await serverSDK()
- .client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("workspace.reset.failed.title"),
- description: errorMessage(err, language.t("common.requestFailed")),
- })
- return false
- })
- if (!result) {
- setBusy(directory, false)
- dismiss()
- return
- }
- const archivedAt = Date.now()
- await Promise.all(
- sessions
- .filter((session) => session.time.archived === undefined)
- .map((session) =>
- serverSDK()
- .client.session.update({
- sessionID: session.id,
- directory: session.directory,
- time: { archived: archivedAt },
- })
- .catch(() => undefined),
- ),
- )
- setBusy(directory, false)
- dismiss()
- showToast({
- title: language.t("workspace.reset.success.title"),
- description: language.t("workspace.reset.success.description"),
- actions: [
- {
- label: language.t("command.session.new"),
- onClick: () => {
- const href = `/${base64Encode(directory)}/session`
- navigate(href)
- layout.mobileSidebar.hide()
- },
- },
- {
- label: language.t("common.dismiss"),
- onClick: "dismiss",
- },
- ],
- })
- }
- function DialogDeleteWorkspace(props: { root: string; directory: string }) {
- const name = createMemo(() => getFilename(props.directory))
- const [data, setData] = createStore({
- status: "loading" as "loading" | "ready" | "error",
- dirty: false,
- })
- onMount(() => {
- serverSDK()
- .client.vcs.status({ directory: props.directory })
- .then((x) => {
- const files = x.data ?? []
- const dirty = files.length > 0
- setData({ status: "ready", dirty })
- })
- .catch(() => {
- setData({ status: "error", dirty: false })
- })
- })
- const handleDelete = () => {
- const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory)
- if (leaveDeletedWorkspace) {
- navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
- }
- dialog.close()
- void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
- }
- const description = () => {
- if (data.status === "loading") return language.t("workspace.status.checking")
- if (data.status === "error") return language.t("workspace.status.error")
- if (!data.dirty) return language.t("workspace.status.clean")
- return language.t("workspace.status.dirty")
- }
- return (
- <Dialog title={language.t("workspace.delete.title")} fit>
- <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
- <div class="flex flex-col gap-1">
- <span class="text-14-regular text-text-strong">
- {language.t("workspace.delete.confirm", { name: name() })}
- </span>
- <span class="text-12-regular text-text-weak">{description()}</span>
- </div>
- <div class="flex justify-end gap-2">
- <Button variant="ghost" size="large" onClick={() => dialog.close()}>
- {language.t("common.cancel")}
- </Button>
- <Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
- {language.t("workspace.delete.button")}
- </Button>
- </div>
- </div>
- </Dialog>
- )
- }
- function DialogResetWorkspace(props: { root: string; directory: string }) {
- const name = createMemo(() => getFilename(props.directory))
- const [state, setState] = createStore({
- status: "loading" as "loading" | "ready" | "error",
- dirty: false,
- sessions: [] as Session[],
- })
- const refresh = async () => {
- const sessions = await serverSDK()
- .client.session.list({ directory: props.directory })
- .then((x) => x.data ?? [])
- .catch(() => [])
- const active = sessions.filter((session) => session.time.archived === undefined)
- setState({ sessions: active })
- }
- onMount(() => {
- serverSDK()
- .client.vcs.status({ directory: props.directory })
- .then((x) => {
- const files = x.data ?? []
- const dirty = files.length > 0
- setState({ status: "ready", dirty })
- void refresh()
- })
- .catch(() => {
- setState({ status: "error", dirty: false })
- })
- })
- const handleReset = () => {
- dialog.close()
- void resetWorkspace(props.root, props.directory)
- }
- const archivedCount = () => state.sessions.length
- const description = () => {
- if (state.status === "loading") return language.t("workspace.status.checking")
- if (state.status === "error") return language.t("workspace.status.error")
- if (!state.dirty) return language.t("workspace.status.clean")
- return language.t("workspace.status.dirty")
- }
- const archivedLabel = () => {
- const count = archivedCount()
- if (count === 0) return language.t("workspace.reset.archived.none")
- if (count === 1) return language.t("workspace.reset.archived.one")
- return language.t("workspace.reset.archived.many", { count })
- }
- return (
- <Dialog title={language.t("workspace.reset.title")} fit>
- <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
- <div class="flex flex-col gap-1">
- <span class="text-14-regular text-text-strong">
- {language.t("workspace.reset.confirm", { name: name() })}
- </span>
- <span class="text-12-regular text-text-weak">
- {description()} {archivedLabel()} {language.t("workspace.reset.note")}
- </span>
- </div>
- <div class="flex justify-end gap-2">
- <Button variant="ghost" size="large" onClick={() => dialog.close()}>
- {language.t("common.cancel")}
- </Button>
- <Button variant="primary" size="large" disabled={state.status === "loading"} onClick={handleReset}>
- {language.t("workspace.reset.button")}
- </Button>
- </div>
- </div>
- </Dialog>
- )
- }
- const activeRoute = {
- session: "",
- sessionProject: "",
- directory: "",
- }
- createEffect(
- on(
- () => {
- return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
- },
- ([ready, slug, id, root, dir]) => {
- if (!ready || !slug || !dir) {
- activeRoute.session = ""
- activeRoute.sessionProject = ""
- activeRoute.directory = ""
- return
- }
- if (!id) {
- activeRoute.session = ""
- activeRoute.sessionProject = ""
- activeRoute.directory = ""
- return
- }
- const session = `${slug}/${id}`
- if (!root) {
- activeRoute.session = session
- activeRoute.directory = dir
- activeRoute.sessionProject = ""
- return
- }
- if (server.projects.last() !== root) server.projects.touch(root)
- const changed = session !== activeRoute.session || dir !== activeRoute.directory
- if (changed) {
- activeRoute.session = session
- activeRoute.directory = dir
- activeRoute.sessionProject = syncSessionRoute(dir, id, root)
- return
- }
- if (root === activeRoute.sessionProject) return
- activeRoute.directory = dir
- activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
- },
- ),
- )
- createEffect(() => {
- document.documentElement.style.setProperty(
- "--dialog-left-margin",
- `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
- )
- })
- const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
- const panel = createMemo(() => Math.max(side() - 64, 0))
- const loadedSessionDirs = new Set<string>()
- createEffect(
- on(
- visibleSessionDirs,
- (dirs) => {
- if (dirs.length === 0) {
- loadedSessionDirs.clear()
- return
- }
- const next = new Set(dirs)
- for (const directory of next) {
- if (loadedSessionDirs.has(directory)) continue
- void serverSync().project.loadSessions(directory)
- }
- loadedSessionDirs.clear()
- for (const directory of next) {
- loadedSessionDirs.add(directory)
- }
- },
- { defer: true },
- ),
- )
- function handleDragStart(event: unknown) {
- const id = getDraggableId(event)
- if (!id) return
- setHoverProject(undefined)
- setStore("activeProject", id)
- }
- function handleDragOver(event: DragEvent) {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const projects = layout.projects.list()
- const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
- const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
- if (fromIndex !== toIndex && toIndex !== -1) {
- layout.projects.move(draggable.id.toString(), toIndex)
- }
- }
- }
- function handleDragEnd() {
- setStore("activeProject", undefined)
- }
- function workspaceIds(project: LocalProject | undefined) {
- if (!project) return []
- const local = project.worktree
- const dirs = [local, ...(project.sandboxes ?? [])]
- const active = currentProject()
- const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined
- const extra =
- directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
- ? directory
- : undefined
- const pending = extra ? WorktreeState.get(serverSDK().scope, extra)?.status === "pending" : false
- const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
- if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
- if (!extra) return ordered
- if (pending) return ordered
- return [...ordered, extra]
- }
- const sidebarProject = createMemo(() => {
- if (layout.sidebar.opened()) return currentProject()
- const hovered = hoverProjectData()
- if (hovered) return hovered
- return currentProject()
- })
- function handleWorkspaceDragStart(event: unknown) {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeWorkspace", id)
- }
- function handleWorkspaceDragOver(event: DragEvent) {
- const { draggable, droppable } = event
- if (!draggable || !droppable) return
- const project = sidebarProject()
- if (!project) return
- const ids = workspaceIds(project)
- const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
- const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
- if (fromIndex === -1 || toIndex === -1) return
- if (fromIndex === toIndex) return
- const result = ids.slice()
- const [item] = result.splice(fromIndex, 1)
- if (!item) return
- result.splice(toIndex, 0, item)
- setStore(
- "workspaceOrder",
- project.worktree,
- result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)),
- )
- }
- function handleWorkspaceDragEnd() {
- setStore("activeWorkspace", undefined)
- }
- const createWorkspace = async (project: LocalProject) => {
- clearSidebarHoverState()
- const created = await serverSDK()
- .client.worktree.create({ directory: project.worktree })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("workspace.create.failed.title"),
- description: errorMessage(err, language.t("common.requestFailed")),
- })
- return undefined
- })
- if (!created?.directory) return
- setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
- const local = project.worktree
- const key = pathKey(created.directory)
- const root = pathKey(local)
- setBusy(created.directory, true)
- WorktreeState.pending(serverSDK().scope, created.directory)
- setStore("workspaceExpanded", key, true)
- if (key !== created.directory) {
- setStore("workspaceExpanded", created.directory, true)
- }
- setStore("workspaceOrder", project.worktree, (prev) => {
- const existing = prev ?? []
- const next = existing.filter((item) => {
- const id = pathKey(item)
- return id !== root && id !== key
- })
- return [created.directory, ...next]
- })
- serverSync().child(created.directory)
- navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
- }
- const workspaceSidebarCtx: WorkspaceSidebarContext = {
- currentDir,
- navList: currentSessions,
- sidebarExpanded,
- sidebarHovering,
- clearHoverProjectSoon,
- prefetchSession,
- archiveSession,
- workspaceName,
- renameWorkspace,
- editorOpen,
- openEditor,
- closeEditor,
- setEditor,
- InlineEditor,
- isBusy,
- workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
- setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
- showResetWorkspaceDialog: (root, directory) =>
- dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
- showDeleteWorkspaceDialog: (root, directory) =>
- dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />),
- setScrollContainerRef: (el, mobile) => {
- if (!mobile) scrollContainerRef = el
- },
- }
- const projectSidebarCtx: ProjectSidebarContext = {
- currentDir,
- currentProject,
- sidebarOpened: () => layout.sidebar.opened(),
- sidebarHovering,
- hoverProject: () => state.hoverProject,
- onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
- onProjectMouseLeave: (worktree) => aim.leave(worktree),
- onProjectFocus: (worktree) => aim.activate(worktree),
- onHoverOpenChanged: (worktree, hoverOpen) => {
- if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
- setState("hoverProject", hoverOpen ? worktree : undefined)
- },
- navigateToProject,
- openSidebar: () => layout.sidebar.open(),
- closeProject,
- showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
- toggleProjectWorkspaces,
- workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
- workspaceIds,
- workspaceLabel,
- sessionProps: {
- navList: currentSessions,
- sidebarExpanded,
- clearHoverProjectSoon,
- prefetchSession,
- archiveSession,
- },
- }
- const SidebarPanel = (panelProps: {
- project: Accessor<LocalProject | undefined>
- mobile?: boolean
- merged?: boolean
- }) => {
- const project = panelProps.project
- const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
- const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
- const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
- const projectName = createMemo(() => {
- const item = project()
- if (!item) return ""
- return item.name || getFilename(item.worktree)
- })
- const projectId = createMemo(() => project()?.id ?? "")
- const worktree = createMemo(() => project()?.worktree ?? "")
- const slug = createMemo(() => {
- const dir = worktree()
- if (!dir) return ""
- return base64Encode(dir)
- })
- const workspaces = createMemo(() => {
- const item = project()
- if (!item) return [] as string[]
- return workspaceIds(item)
- })
- const unseenCount = createMemo(() =>
- workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
- )
- const clearNotifications = () =>
- workspaces()
- .filter((directory) => notification.project.unseenCount(directory) > 0)
- .forEach((directory) => notification.project.markViewed(directory))
- const workspacesEnabled = createMemo(() => {
- const item = project()
- if (!item) return false
- if (item.vcs !== "git") return false
- return layout.sidebar.workspaces(item.worktree)()
- })
- const canToggle = createMemo(() => {
- const item = project()
- if (!item) return false
- return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
- })
- const homedir = createMemo(() => serverSync().data.path.home)
- return (
- <div
- classList={{
- "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-3": true,
- "border border-b-0 border-border-weak-base": !merged(),
- "border-l border-t border-border-weaker-base": merged(),
- "bg-background-base": merged() || hover(),
- "bg-background-stronger": !merged() && !hover(),
- "flex-1 min-w-0": panelProps.mobile,
- "max-w-full overflow-hidden": panelProps.mobile,
- }}
- style={{
- width: panelProps.mobile ? undefined : `${panel()}px`,
- }}
- >
- <Show
- when={project()}
- fallback={
- <Show when={empty()}>
- <div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
- <div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
- <div class="flex flex-col gap-3">
- <div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
- <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
- {language.t("sidebar.empty.description")}
- </div>
- </div>
- <Button size="large" icon="folder-add-left" onClick={chooseProject}>
- {language.t("command.project.open")}
- </Button>
- </div>
- </div>
- </Show>
- }
- keyed
- >
- {(project) => (
- <>
- <div class="shrink-0 pl-1 py-1">
- <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
- <div class="flex flex-col min-w-0">
- <InlineEditor
- id={`project:${projectId()}`}
- value={projectName}
- onSave={(next) => {
- void renameProject(project, next)
- }}
- class="text-14-medium text-text-strong truncate"
- displayClass="text-14-medium text-text-strong truncate"
- stopPropagation
- />
- <Tooltip
- placement="bottom"
- gutter={2}
- value={worktree()}
- class="shrink-0"
- contentStyle={{
- "max-width": "640px",
- transform: "translate3d(52px, 0, 0)",
- }}
- >
- <span class="text-12-regular text-text-base truncate select-text">
- {worktree().replace(homedir(), "~")}
- </span>
- </Tooltip>
- </div>
- <DropdownMenu modal={!sidebarHovering()}>
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- data-action="project-menu"
- data-project={slug()}
- class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
- classList={{
- "opacity-100": panelProps.mobile || merged(),
- "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
- !panelProps.mobile && !merged(),
- }}
- aria-label={language.t("common.moreOptions")}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item
- onSelect={() => {
- showEditProjectDialog(server.current!, project)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-workspaces-toggle"
- data-project={slug()}
- disabled={!canToggle()}
- onSelect={() => {
- toggleProjectWorkspaces(project)
- }}
- >
- <DropdownMenu.ItemLabel>
- {workspacesEnabled()
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-clear-notifications"
- data-project={slug()}
- disabled={unseenCount() === 0}
- onSelect={clearNotifications}
- >
- <DropdownMenu.ItemLabel>
- {language.t("sidebar.project.clearNotifications")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- data-action="project-close-menu"
- data-project={slug()}
- onSelect={() => {
- const dir = worktree()
- if (!dir) return
- closeProject(dir)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </div>
- </div>
- <div class="flex-1 min-h-0 flex flex-col">
- <Show
- when={workspacesEnabled()}
- fallback={
- <>
- <div class="shrink-0 py-4">
- <Button
- size="large"
- class="w-full"
- onClick={() => {
- const dir = worktree()
- if (!dir) return
- navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
- }}
- >
- <IconV2 name="edit" size="small" />
- {language.t("command.session.new")}
- </Button>
- </div>
- <div class="flex-1 min-h-0">
- <LocalWorkspace
- ctx={workspaceSidebarCtx}
- project={project}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- />
- </div>
- </>
- }
- >
- <>
- <div class="shrink-0 py-4">
- <Button
- size="large"
- icon="plus-small"
- class="w-full"
- onClick={() => {
- void createWorkspace(project)
- }}
- >
- {language.t("workspace.new")}
- </Button>
- </div>
- <div class="relative flex-1 min-h-0">
- <DragDropProvider
- onDragStart={handleWorkspaceDragStart}
- onDragEnd={handleWorkspaceDragEnd}
- onDragOver={handleWorkspaceDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={(el) => {
- if (!panelProps.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
- >
- <SortableProvider ids={workspaces()}>
- <For each={workspaces()}>
- {(directory) => (
- <SortableWorkspace
- ctx={workspaceSidebarCtx}
- directory={directory}
- project={project}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- />
- )}
- </For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <WorkspaceDragOverlay
- sidebarProject={sidebarProject}
- activeWorkspace={() => store.activeWorkspace}
- workspaceLabel={workspaceLabel}
- />
- </DragOverlay>
- </DragDropProvider>
- </div>
- </>
- </Show>
- </div>
- </>
- )}
- </Show>
- <div
- class="shrink-0 px-3 py-3"
- classList={{
- hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
- }}
- >
- <div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
- <div class="p-3 flex flex-col gap-6">
- <div class="flex flex-col gap-2">
- <div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
- <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
- {language.t("sidebar.gettingStarted.line1")}
- </div>
- <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
- {language.t("sidebar.gettingStarted.line2")}
- </div>
- </div>
- <div data-component="getting-started-actions">
- <Button size="large" icon="plus-small" onClick={connectProvider}>
- {language.t("command.provider.connect")}
- </Button>
- <Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
- {language.t("toast.update.action.notYet")}
- </Button>
- </div>
- </div>
- </div>
- </div>
- </div>
- )
- }
- const projects = () => layout.projects.list()
- const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
- const sidebarContent = (mobile?: boolean) => (
- <SidebarContent
- mobile={mobile}
- opened={() => layout.sidebar.opened()}
- aimMove={aim.move}
- projects={projects}
- renderProject={(project) => (
- <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
- )}
- handleDragStart={handleDragStart}
- handleDragEnd={handleDragEnd}
- handleDragOver={handleDragOver}
- openProjectLabel={language.t("command.project.open")}
- openProjectKeybind={() => command.keybind("project.open")}
- onOpenProject={chooseProject}
- renderProjectOverlay={projectOverlay}
- settingsLabel={() => language.t("sidebar.settings")}
- settingsKeybind={() => command.keybind("settings.open")}
- onOpenSettings={openSettings}
- helpLabel={() => language.t("sidebar.help")}
- onOpenHelp={() => platform.openLink("https://kailin.com.cn/feedback")}
- renderPanel={() =>
- mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
- }
- />
- )
- return (
- <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">
- {autoselecting() ?? ""}
- <Titlebar update={titlebarUpdate} />
- <Show when={updateVersion() !== undefined}>
- <UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
- </Show>
- <div class="flex-1 min-h-0 min-w-0 flex">
- <div class="flex-1 min-h-0 relative">
- <div class="size-full relative overflow-x-hidden">
- <nav
- aria-label={language.t("sidebar.nav.projectsAndSessions")}
- data-component="sidebar-nav-desktop"
- classList={{
- "hidden xl:block": true,
- "absolute inset-y-0 left-0": true,
- "z-10": true,
- }}
- style={{ width: `${side()}px` }}
- ref={(el) => {
- setState("nav", el)
- }}
- onMouseEnter={() => {
- disarm()
- }}
- onMouseLeave={() => {
- aim.reset()
- if (!sidebarHovering()) return
- arm()
- }}
- >
- <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
- </nav>
- <Show when={layout.sidebar.opened()}>
- <div
- class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
- style={{ left: `${side()}px` }}
- onPointerDown={() => setState("sizing", true)}
- >
- <ResizeHandle
- direction="horizontal"
- size={layout.sidebar.width()}
- min={244}
- max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
- onResize={(w) => {
- setState("sizing", true)
- if (sizet !== undefined) clearTimeout(sizet)
- sizet = window.setTimeout(() => setState("sizing", false), 120)
- layout.sidebar.resize(w)
- }}
- />
- </div>
- </Show>
- <div
- class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
- style={{ left: "calc(4rem + 12px)" }}
- />
- <div class="xl:hidden">
- <div
- classList={{
- "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
- "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
- "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
- }}
- onClick={(e) => {
- if (e.target === e.currentTarget) layout.mobileSidebar.hide()
- }}
- />
- <nav
- aria-label={language.t("sidebar.nav.projectsAndSessions")}
- data-component="sidebar-nav-mobile"
- classList={{
- "@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,
- "translate-x-0": layout.mobileSidebar.opened(),
- "-translate-x-full": !layout.mobileSidebar.opened(),
- }}
- onClick={(e) => e.stopPropagation()}
- >
- {sidebarContent(true)}
- </nav>
- </div>
- <div
- classList={{
- "absolute inset-0": true,
- "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
- "z-20": true,
- "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
- !state.sizing,
- }}
- style={{
- "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
- }}
- >
- <main
- classList={{
- "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,
- }}
- >
- <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
- {props.children}
- </Show>
- </main>
- </div>
- <div
- classList={{
- "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
- "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
- "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
- "transition-[opacity,transform] motion-reduce:transition-none": true,
- "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
- "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
- }}
- onMouseMove={disarm}
- onMouseEnter={() => {
- disarm()
- aim.reset()
- }}
- onPointerDown={disarm}
- onMouseLeave={() => {
- arm()
- }}
- >
- <Show when={peekProject()}>
- <SidebarPanel project={peekProject} merged={false} />
- </Show>
- </div>
- <div
- classList={{
- "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
- "opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
- "opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
- "transition-[opacity,transform] motion-reduce:transition-none": true,
- "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
- "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
- }}
- style={{ left: `calc(4rem + ${panel()}px)` }}
- >
- <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
- </div>
- </div>
- </div>
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
- </div>
- <TabsInfoPopup />
- <HelpButton />
- <ToastRegion v2={false} />
- </div>
- )
- }
- function UpdateAvailableToast(props: {
- version: string
- install: () => void
- language: ReturnType<typeof useLanguage>
- }) {
- let toastId: number | undefined
- onMount(() => {
- toastId = showToast({
- persistent: true,
- icon: "download",
- title: props.language.t("toast.update.title"),
- description: props.language.t("toast.update.description", { version: props.version }),
- actions: [
- {
- label: props.language.t("toast.update.action.installRestart"),
- onClick: props.install,
- },
- {
- label: props.language.t("toast.update.action.notYet"),
- onClick: "dismiss",
- },
- ],
- })
- })
- onCleanup(() => {
- if (toastId === undefined) return
- toaster.dismiss(toastId)
- })
- return null
- }
|