| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342 |
- import {
- batch,
- createEffect,
- createMemo,
- createSignal,
- For,
- on,
- onCleanup,
- onMount,
- ParentProps,
- Show,
- untrack,
- } from "solid-js"
- import { useNavigate, useParams } from "@solidjs/router"
- import { useLayout, LocalProject } from "@/context/layout"
- import { useGlobalSync } from "@/context/global-sync"
- import { Persist, persisted } from "@/utils/persist"
- import { base64Encode } from "@opencode-ai/util/encode"
- import { decode64 } from "@/utils/base64"
- import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
- import { Button } from "@opencode-ai/ui/button"
- import { IconButton } from "@opencode-ai/ui/icon-button"
- import { Tooltip } from "@opencode-ai/ui/tooltip"
- import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
- import { Dialog } from "@opencode-ai/ui/dialog"
- import { getFilename } from "@opencode-ai/util/path"
- import { Session, type Message } from "@opencode-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 { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
- import { useGlobalSDK } from "@/context/global-sdk"
- import { clearWorkspaceTerminals } from "@/context/terminal"
- import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
- import { useNotification } from "@/context/notification"
- import { usePermission } from "@/context/permission"
- import { Binary } from "@opencode-ai/util/binary"
- import { retry } from "@opencode-ai/util/retry"
- import { playSound, soundSrc } 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 { useDialog } from "@opencode-ai/ui/context/dialog"
- import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
- import { DialogSelectProvider } from "@/components/dialog-select-provider"
- import { DialogSelectServer } from "@/components/dialog-select-server"
- import { DialogSettings } from "@/components/dialog-settings"
- import { useCommand, type CommandOption } from "@/context/command"
- import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
- import { DialogSelectDirectory } from "@/components/dialog-select-directory"
- import { DialogEditProject } from "@/components/dialog-edit-project"
- import { DebugBar } from "@/components/debug-bar"
- import { Titlebar } from "@/components/titlebar"
- import { useServer } from "@/context/server"
- import { useLanguage, type Locale } from "@/context/language"
- import {
- displayName,
- effectiveWorkspaceOrder,
- errorMessage,
- latestRootSession,
- sortedRootSessions,
- workspaceKey,
- } 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 Layout(props: ParentProps) {
- const [store, setStore, , ready] = persisted(
- Persist.global("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
- const params = useParams()
- const globalSDK = useGlobalSDK()
- const globalSync = useGlobalSync()
- const layout = useLayout()
- const layoutReady = createMemo(() => layout.ready())
- const platform = usePlatform()
- 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()
- const initialDirectory = decode64(params.dir)
- const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
- 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(() => decode64(params.dir) ?? "")
- const [state, setState] = createStore({
- autoselect: !initialDirectory,
- busyWorkspaces: {} as Record<string, boolean>,
- hoverSession: undefined as string | undefined,
- hoverProject: undefined as string | undefined,
- scrollSessionKey: undefined as string | undefined,
- nav: undefined as HTMLElement | undefined,
- })
- const editor = createInlineEditorController()
- const setBusy = (directory: string, value: boolean) => {
- const key = workspaceKey(directory)
- if (value) {
- setState("busyWorkspaces", key, true)
- return
- }
- setState(
- "busyWorkspaces",
- produce((draft) => {
- delete draft[key]
- }),
- )
- }
- const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
- const navLeave = { current: undefined as number | undefined }
- const [sortNow, setSortNow] = createSignal(Date.now())
- const [sizing, setSizing] = createSignal(false)
- let sizet: number | undefined
- let sortNowInterval: ReturnType<typeof setInterval> | undefined
- const sortNowTimeout = setTimeout(
- () => {
- setSortNow(Date.now())
- sortNowInterval = setInterval(() => setSortNow(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) => {
- globalSync.child(directory)
- setState("hoverProject", directory)
- setState("hoverSession", undefined)
- },
- })
- onCleanup(() => {
- 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 = () => setSizing(false)
- window.addEventListener("pointerup", stop)
- window.addEventListener("pointercancel", stop)
- window.addEventListener("blur", stop)
- onCleanup(() => {
- window.removeEventListener("pointerup", stop)
- window.removeEventListener("pointercancel", stop)
- window.removeEventListener("blur", stop)
- })
- })
- 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 setHoverSession = (id: string | undefined) => setState("hoverSession", id)
- const disarm = () => {
- if (navLeave.current === undefined) return
- clearTimeout(navLeave.current)
- navLeave.current = undefined
- }
- const arm = () => {
- if (layout.sidebar.opened()) return
- if (state.hoverProject === undefined) return
- disarm()
- navLeave.current = window.setTimeout(() => {
- navLeave.current = undefined
- setHoverProject(undefined)
- setState("hoverSession", undefined)
- }, 300)
- }
- const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
- const [peeked, setPeeked] = createSignal(false)
- let peekt: number | undefined
- const hoverProjectData = createMemo(() => {
- const id = state.hoverProject
- 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
- }
- setPeek(p)
- setPeeked(true)
- return
- }
- setPeeked(false)
- if (peek() === undefined) return
- if (peekt !== undefined) clearTimeout(peekt)
- peekt = window.setTimeout(() => {
- peekt = undefined
- setPeek(undefined)
- }, 180)
- })
- createEffect(() => {
- if (!layout.sidebar.opened()) return
- setHoverProject(undefined)
- })
- const autoselecting = createMemo(() => {
- if (params.dir) return false
- if (!state.autoselect) return false
- if (!pageReady()) return true
- if (!layoutReady()) return true
- const list = layout.projects.list()
- if (list.length > 0) return true
- return !!server.projects.last()
- })
- 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
- setState("hoverSession", undefined)
- setHoverProject(undefined)
- }
- 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)
- const nextTheme = theme.themes()[nextThemeId]
- showToast({
- title: language.t("toast.theme.title"),
- description: nextTheme?.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 useUpdatePolling = () =>
- onMount(() => {
- if (!platform.checkUpdate || !platform.update || !platform.restart) return
- let toastId: number | undefined
- let interval: ReturnType<typeof setInterval> | undefined
- const pollUpdate = () =>
- platform.checkUpdate!().then(({ updateAvailable, version }) => {
- if (!updateAvailable) return
- if (toastId !== undefined) return
- toastId = showToast({
- persistent: true,
- icon: "download",
- title: language.t("toast.update.title"),
- description: language.t("toast.update.description", { version: version ?? "" }),
- actions: [
- {
- label: language.t("toast.update.action.installRestart"),
- onClick: async () => {
- await platform.update!()
- await platform.restart!()
- },
- },
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss",
- },
- ],
- })
- })
- createEffect(() => {
- if (!settings.ready()) return
- if (!settings.updates.startup()) {
- if (interval === undefined) return
- clearInterval(interval)
- interval = undefined
- return
- }
- if (interval !== undefined) return
- void pollUpdate()
- interval = setInterval(pollUpdate, 10 * 60 * 1000)
- })
- onCleanup(() => {
- if (interval === undefined) return
- clearInterval(interval)
- })
- })
- 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 = globalSDK.event.listen((e) => {
- if (e.details?.type === "worktree.ready") {
- setBusy(e.name, false)
- WorktreeState.ready(e.name)
- return
- }
- if (e.details?.type === "worktree.failed") {
- setBusy(e.name, false)
- WorktreeState.failed(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] = globalSync.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()) {
- playSound(soundSrc(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 (directory === currentDir() && props.sessionID === currentSession) return
- if (directory === 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] = globalSync.child(currentDir(), { bootstrap: false })
- const childSessions = store.session.filter((s) => s.parentID === currentSession)
- for (const child of childSessions) {
- dismissSessionAlert(`${currentDir()}:${child.id}`)
- }
- })
- })
- useUpdatePolling()
- 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 projects = layout.projects.list()
- const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
- if (sandbox) return sandbox
- const direct = projects.find((p) => p.worktree === directory)
- if (direct) return direct
- const [child] = globalSync.child(directory, { bootstrap: false })
- const id = child.project
- if (!id) return
- const meta = globalSync.data.project.find((p) => p.id === id)
- const root = meta?.worktree
- if (!root) return
- return projects.find((p) => p.worktree === root)
- })
- createEffect(
- on(
- () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
- (value) => {
- if (!value.ready) return
- if (!value.layoutReady) return
- if (!state.autoselect) return
- if (value.dir) return
- const last = server.projects.last()
- if (value.list.length === 0) {
- if (!last) return
- setState("autoselect", false)
- openProject(last, false)
- navigateToProject(last)
- return
- }
- const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
- if (!next) return
- setState("autoselect", false)
- openProject(next.worktree, false)
- navigateToProject(next.worktree)
- },
- ),
- )
- const workspaceName = (directory: string, projectId?: string, branch?: string) => {
- const key = workspaceKey(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 = workspaceKey(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 = directory === 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 project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
- 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] = globalSync.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 = 1
- const prefetchPendingLimit = 6
- 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: directory === params.dir && params.id ? [params.id] : undefined,
- })
- }
- createEffect(() => {
- params.dir
- globalSDK.url
- prefetchToken.value += 1
- for (const q of prefetchQueues.values()) {
- q.pending.length = 0
- q.pendingSet.clear()
- }
- })
- 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
- }
- const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
- if (current.length === 0) {
- return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }
- const map = new Map<string, T>()
- for (const item of current) {
- map.set(item.id, item)
- }
- for (const item of incoming) {
- map.set(item.id, item)
- }
- return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }
- async function prefetchMessages(directory: string, sessionID: string, token: number) {
- const [store, setStore] = globalSync.child(directory, { bootstrap: false })
- return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
- .then((messages) => {
- if (prefetchToken.value !== token) return
- if (!lruFor(directory).has(sessionID)) return
- const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
- const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
- const sorted = mergeByID([], next)
- const current = store.message[sessionID] ?? []
- const merged = mergeByID(
- current.filter((item): item is Message => !!item?.id),
- sorted,
- )
- batch(() => {
- setStore("message", sessionID, reconcile(merged, { key: "id" }))
- for (const message of items) {
- const currentParts = store.part[message.info.id] ?? []
- const mergedParts = mergeByID(
- currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
- message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
- )
- setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
- }
- })
- })
- .catch(() => undefined)
- }
- 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 [store] = globalSync.child(directory, { bootstrap: false })
- const cached = untrack(() => store.message[session.id] !== undefined)
- if (cached) return
- const q = queueFor(directory)
- if (q.inflight.has(session.id)) return
- if (q.pendingSet.has(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
- const stale = markPrefetched(directory, session.id)
- if (stale.length > 0) {
- const [, setStore] = globalSync.child(directory, { bootstrap: false })
- for (const id of stale) {
- globalSync.todo.set(id, undefined)
- }
- setStore(
- produce((draft) => {
- dropSessionCaches(draft, stale)
- }),
- )
- }
- 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)
- }
- createEffect(() => {
- const sessions = currentSessions()
- const id = params.id
- if (!id) {
- const first = sessions[0]
- if (first) prefetchSession(first)
- const second = sessions[1]
- if (second) prefetchSession(second)
- return
- }
- const index = sessions.findIndex((s) => s.id === id)
- if (index === -1) return
- const next = sessions[index + 1]
- if (next) prefetchSession(next)
- const prev = sessions[index - 1]
- if (prev) prefetchSession(prev)
- })
- 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
- const next = sessions[(targetIndex + 1) % sessions.length]
- const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
- if (offset > 0) {
- if (next) prefetchSession(next, "high")
- if (prev) prefetchSession(prev)
- }
- if (offset < 0) {
- if (prev) prefetchSession(prev, "high")
- if (next) prefetchSession(next)
- }
- navigateToSession(session)
- }
- 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")
- const next = sessions[(index + 1) % sessions.length]
- const prev = sessions[(index - 1 + sessions.length) % sessions.length]
- if (offset > 0) {
- if (next) prefetchSession(next, "high")
- if (prev) prefetchSession(prev)
- }
- if (offset < 0) {
- if (prev) prefetchSession(prev, "high")
- if (next) prefetchSession(next)
- }
- navigateToSession(session)
- return
- }
- }
- async function archiveSession(session: Session) {
- const [store, setStore] = globalSync.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 globalSDK.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: "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) 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),
- },
- ]
- for (const [id, definition] of availableThemeEntries()) {
- commands.push({
- id: `theme.set.${id}`,
- title: language.t("command.theme.set", { theme: definition.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() {
- dialog.show(() => <DialogSelectProvider />)
- }
- function openServer() {
- dialog.show(() => <DialogSelectServer />)
- }
- function openSettings() {
- dialog.show(() => <DialogSettings />)
- }
- function projectRoot(directory: string) {
- const project = layout.projects
- .list()
- .find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
- if (project) return project.worktree
- const known = Object.entries(store.workspaceOrder).find(
- ([root, dirs]) => root === directory || dirs.includes(directory),
- )
- if (known) return known[0]
- const [child] = globalSync.child(directory, { bootstrap: false })
- const id = child.project
- if (!id) return directory
- const meta = globalSync.data.project.find((item) => item.id === id)
- return meta?.worktree ?? directory
- }
- function activeProjectRoot(directory: string) {
- return currentProject()?.worktree ?? projectRoot(directory)
- }
- function touchProjectRoute() {
- const root = currentProject()?.worktree
- if (!root) return
- if (server.projects.last() !== root) server.projects.touch(root)
- return root
- }
- 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) => workspaceKey(item) === workspaceKey(value))
- }
- const refreshDirs = async (target?: string) => {
- if (!target || target === root || canOpen(target)) return canOpen(target)
- const listed = await globalSDK.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 [data] = globalSync.child(target.directory, { bootstrap: false })
- if (data.session.some((item) => item.id === 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 globalSDK.client.session
- .get({ sessionID: target.id })
- .then((x) => x.data)
- .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) => globalSync.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 globalSDK.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) navigateToProject(directory)
- }
- const handleDeepLinks = (urls: string[]) => {
- if (!server.isLocal()) return
- for (const directory of collectOpenProjectDeepLinks(urls)) {
- openProject(directory)
- }
- for (const link of collectNewSessionDeepLinks(urls)) {
- openProject(link.directory, false)
- const slug = base64Encode(link.directory)
- if (link.prompt) {
- setSessionHandoff(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))
- window.addEventListener(deepLinkEvent, handler as EventListener)
- onCleanup(() => window.removeEventListener(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 globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
- return
- }
- globalSync.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 index = list.findIndex((x) => x.worktree === directory)
- const active = currentProject()?.worktree === directory
- if (index === -1) return
- const next = list[index + 1]
- if (!active) {
- layout.projects.close(directory)
- return
- }
- if (!next) {
- layout.projects.close(directory)
- navigate("/")
- return
- }
- 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 = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
- async function chooseProject() {
- function resolve(result: string | string[] | null) {
- if (Array.isArray(result)) {
- for (const directory of result) {
- openProject(directory, false)
- }
- navigateToProject(result[0])
- } else if (result) {
- openProject(result)
- }
- }
- if (platform.openDirectoryPickerDialog && server.isLocal()) {
- const result = await platform.openDirectoryPickerDialog?.({
- title: language.t("command.project.open"),
- multiple: true,
- })
- resolve(result)
- } else {
- dialog.show(
- () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
- () => resolve(null),
- )
- }
- }
- const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
- if (directory === root) return
- const current = currentDir()
- const currentKey = workspaceKey(current)
- const deletedKey = workspaceKey(directory)
- const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
- if (!leaveDeletedWorkspace && shouldLeave) {
- navigateWithSidebarReset(`/${base64Encode(root)}/session`)
- }
- setBusy(directory, true)
- const result = await globalSDK.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 (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
- clearLastProjectSession(root)
- }
- globalSync.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 = workspaceKey(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) => workspaceKey(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 globalSDK.client.session
- .list({ directory })
- .then((x) => x.data ?? [])
- .catch(() => [])
- clearWorkspaceTerminals(
- directory,
- sessions.map((s) => s.id),
- platform,
- )
- await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
- const result = await globalSDK.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) =>
- globalSDK.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(() => {
- globalSDK.client.file
- .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 && workspaceKey(currentDir()) === workspaceKey(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 globalSDK.client.session
- .list({ directory: props.directory })
- .then((x) => x.data ?? [])
- .catch(() => [])
- const active = sessions.filter((session) => session.time.archived === undefined)
- setState({ sessions: active })
- }
- onMount(() => {
- globalSDK.client.file
- .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: "",
- }
- createEffect(
- on(
- () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
- ([ready, dir, id]) => {
- if (!ready || !dir) {
- activeRoute.session = ""
- activeRoute.sessionProject = ""
- return
- }
- const directory = decode64(dir)
- if (!directory) return
- const root = touchProjectRoute() ?? activeProjectRoot(directory)
- if (!id) {
- activeRoute.session = ""
- activeRoute.sessionProject = ""
- return
- }
- const session = `${dir}/${id}`
- if (session !== activeRoute.session) {
- activeRoute.session = session
- activeRoute.sessionProject = syncSessionRoute(directory, id, root)
- return
- }
- if (root === activeRoute.sessionProject) return
- activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
- },
- ),
- )
- createEffect(() => {
- const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
- document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
- })
- 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
- globalSync.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 = active?.worktree === project.worktree ? currentDir() : undefined
- const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
- const pending = extra ? WorktreeState.get(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) => workspaceKey(directory) !== workspaceKey(project.worktree)),
- )
- }
- function handleWorkspaceDragEnd() {
- setStore("activeWorkspace", undefined)
- }
- const createWorkspace = async (project: LocalProject) => {
- clearSidebarHoverState()
- const created = await globalSDK.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, project.id, created.branch)
- const local = project.worktree
- const key = workspaceKey(created.directory)
- const root = workspaceKey(local)
- setBusy(created.directory, true)
- WorktreeState.pending(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 = workspaceKey(item)
- return id !== root && id !== key
- })
- return [created.directory, ...next]
- })
- globalSync.child(created.directory)
- navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
- }
- const workspaceSidebarCtx: WorkspaceSidebarContext = {
- currentDir,
- sidebarExpanded,
- sidebarHovering,
- nav: () => state.nav,
- hoverSession: () => state.hoverSession,
- setHoverSession,
- 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,
- sidebarOpened: () => layout.sidebar.opened(),
- sidebarHovering,
- hoverProject: () => state.hoverProject,
- nav: () => state.nav,
- onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
- onProjectMouseLeave: (worktree) => aim.leave(worktree),
- onProjectFocus: (worktree) => aim.activate(worktree),
- navigateToProject,
- openSidebar: () => layout.sidebar.open(),
- closeProject,
- showEditProjectDialog,
- toggleProjectWorkspaces,
- workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
- workspaceIds,
- workspaceLabel,
- sessionProps: {
- sidebarExpanded,
- sidebarHovering,
- nav: () => state.nav,
- hoverSession: () => state.hoverSession,
- setHoverSession,
- clearHoverProjectSoon,
- prefetchSession,
- archiveSession,
- },
- setHoverSession,
- }
- const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
- const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
- const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
- const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
- const projectName = createMemo(() => {
- const project = panelProps.project
- if (!project) return ""
- return project.name || getFilename(project.worktree)
- })
- const projectId = createMemo(() => panelProps.project?.id ?? "")
- const workspaces = createMemo(() => workspaceIds(panelProps.project))
- 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 project = panelProps.project
- if (!project) return false
- if (project.vcs !== "git") return false
- return layout.sidebar.workspaces(project.worktree)()
- })
- const homedir = createMemo(() => globalSync.data.path.home)
- return (
- <div
- classList={{
- "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": 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 : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
- }}
- >
- <Show when={panelProps.project}>
- {(p) => (
- <>
- <div class="shrink-0 px-2 py-1">
- <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
- <div class="flex flex-col min-w-0">
- <InlineEditor
- id={`project:${projectId()}`}
- value={projectName}
- onSave={(next) => renameProject(p(), next)}
- class="text-14-medium text-text-strong truncate"
- displayClass="text-14-medium text-text-strong truncate"
- stopPropagation
- />
- <Tooltip
- placement="bottom"
- gutter={2}
- value={p().worktree}
- class="shrink-0"
- contentStyle={{
- "max-width": "640px",
- transform: "translate3d(52px, 0, 0)",
- }}
- >
- <span class="text-12-regular text-text-base truncate select-text">
- {p().worktree.replace(homedir(), "~")}
- </span>
- </Tooltip>
- </div>
- <DropdownMenu modal={!sidebarHovering()}>
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- data-action="project-menu"
- data-project={base64Encode(p().worktree)}
- class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
- classList={{
- "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
- }}
- aria-label={language.t("common.moreOptions")}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
- <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-workspaces-toggle"
- data-project={base64Encode(p().worktree)}
- disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
- onSelect={() => toggleProjectWorkspaces(p())}
- >
- <DropdownMenu.ItemLabel>
- {layout.sidebar.workspaces(p().worktree)()
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-clear-notifications"
- data-project={base64Encode(p().worktree)}
- 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={base64Encode(p().worktree)}
- onSelect={() => closeProject(p().worktree)}
- >
- <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 px-3">
- <Button
- size="large"
- icon="plus-small"
- class="w-full"
- onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
- >
- {language.t("command.session.new")}
- </Button>
- </div>
- <div class="flex-1 min-h-0">
- <LocalWorkspace
- ctx={workspaceSidebarCtx}
- project={p()}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- popover={popover()}
- />
- </div>
- </>
- }
- >
- <>
- <div class="shrink-0 py-4 px-3">
- <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
- {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={p()}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- popover={popover()}
- />
- )}
- </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().length > 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)}>
- Not yet
- </Button>
- </div>
- </div>
- </div>
- </div>
- </div>
- )
- }
- 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">
- <Titlebar />
- <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: `${Math.max(layout.sidebar.width(), 244)}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
- opened={() => layout.sidebar.opened()}
- aimMove={aim.move}
- projects={() => layout.projects.list()}
- renderProject={(project) => (
- <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
- )}
- handleDragStart={handleDragStart}
- handleDragEnd={handleDragEnd}
- handleDragOver={handleDragOver}
- openProjectLabel={language.t("command.project.open")}
- openProjectKeybind={() => command.keybind("project.open")}
- onOpenProject={chooseProject}
- renderProjectOverlay={() => (
- <ProjectDragOverlay
- projects={() => layout.projects.list()}
- activeProject={() => store.activeProject}
- />
- )}
- settingsLabel={() => language.t("sidebar.settings")}
- settingsKeybind={() => command.keybind("settings.open")}
- onOpenSettings={openSettings}
- helpLabel={() => language.t("sidebar.help")}
- onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
- renderPanel={() => (
- <Show when={currentProject()} keyed>
- {(project) => <SidebarPanel project={project} merged />}
- </Show>
- )}
- />
- </div>
- <Show when={layout.sidebar.opened()}>
- <div onPointerDown={() => setSizing(true)}>
- <ResizeHandle
- direction="horizontal"
- size={layout.sidebar.width()}
- min={244}
- max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
- collapseThreshold={244}
- onResize={(w) => {
- setSizing(true)
- if (sizet !== undefined) clearTimeout(sizet)
- sizet = window.setTimeout(() => setSizing(false), 120)
- layout.sidebar.resize(w)
- }}
- onCollapse={layout.sidebar.close}
- />
- </div>
- </Show>
- </nav>
- <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
- mobile
- opened={() => layout.sidebar.opened()}
- aimMove={aim.move}
- projects={() => layout.projects.list()}
- renderProject={(project) => (
- <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
- )}
- handleDragStart={handleDragStart}
- handleDragEnd={handleDragEnd}
- handleDragOver={handleDragOver}
- openProjectLabel={language.t("command.project.open")}
- openProjectKeybind={() => command.keybind("project.open")}
- onOpenProject={chooseProject}
- renderProjectOverlay={() => (
- <ProjectDragOverlay
- projects={() => layout.projects.list()}
- activeProject={() => store.activeProject}
- />
- )}
- settingsLabel={() => language.t("sidebar.settings")}
- settingsKeybind={() => command.keybind("settings.open")}
- onOpenSettings={openSettings}
- helpLabel={() => language.t("sidebar.help")}
- onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
- renderPanel={() => <SidebarPanel project={currentProject()} mobile />}
- />
- </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":
- !sizing(),
- }}
- style={{
- "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}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()} 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": peeked() && !layout.sidebar.opened(),
- "opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
- "transition-[opacity,transform] motion-reduce:transition-none": true,
- "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
- "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
- }}
- onMouseMove={disarm}
- onMouseEnter={() => {
- disarm()
- aim.reset()
- }}
- onPointerDown={disarm}
- onMouseLeave={() => {
- arm()
- }}
- >
- <Show when={peek()} keyed>
- {(project) => <SidebarPanel project={project} 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": peeked() && !layout.sidebar.opened(),
- "opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
- "transition-[opacity,transform] motion-reduce:transition-none": true,
- "duration-180 ease-out": peeked() && !layout.sidebar.opened(),
- "duration-120 ease-in": !peeked() || layout.sidebar.opened(),
- }}
- style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
- >
- <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
- </div>
- </div>
- </div>
- {import.meta.env.DEV && <DebugBar />}
- </div>
- <Toast.Region />
- </div>
- )
- }
|