| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405 |
- import {
- batch,
- createEffect,
- createMemo,
- createSignal,
- For,
- Match,
- on,
- onCleanup,
- onMount,
- ParentProps,
- Show,
- Switch,
- untrack,
- type Accessor,
- type JSX,
- } from "solid-js"
- import { A, useNavigate, useParams } from "@solidjs/router"
- import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
- import { useGlobalSync } from "@/context/global-sync"
- import { Persist, persisted } from "@/utils/persist"
- import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
- import { Avatar } from "@opencode-ai/ui/avatar"
- import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
- import { Button } from "@opencode-ai/ui/button"
- import { Icon } from "@opencode-ai/ui/icon"
- import { IconButton } from "@opencode-ai/ui/icon-button"
- import { InlineInput } from "@opencode-ai/ui/inline-input"
- import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
- import { HoverCard } from "@opencode-ai/ui/hover-card"
- import { MessageNav } from "@opencode-ai/ui/message-nav"
- import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
- import { Collapsible } from "@opencode-ai/ui/collapsible"
- import { DiffChanges } from "@opencode-ai/ui/diff-changes"
- import { Spinner } from "@opencode-ai/ui/spinner"
- import { Dialog } from "@opencode-ai/ui/dialog"
- import { getFilename } from "@opencode-ai/util/path"
- import { Session, type Message, type TextPart } 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,
- createSortable,
- } 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 { 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 { 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 } from "@/utils/solid-dnd"
- import { navStart } from "@/utils/perf"
- import { DialogSelectDirectory } from "@/components/dialog-select-directory"
- import { DialogEditProject } from "@/components/dialog-edit-project"
- import { Titlebar } from "@/components/titlebar"
- import { useServer } from "@/context/server"
- import { useLanguage, type Locale } from "@/context/language"
- export default function Layout(props: ParentProps) {
- const [store, setStore, , ready] = persisted(
- Persist.global("layout.page", ["layout.page.v1"]),
- createStore({
- lastSession: {} as { [directory: string]: string },
- 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>,
- }),
- )
- const pageReady = createMemo(() => ready())
- let scrollContainerRef: HTMLDivElement | undefined
- const xlQuery = window.matchMedia("(min-width: 1280px)")
- const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
- const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
- xlQuery.addEventListener("change", handleViewportChange)
- onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
- const params = useParams()
- const [autoselect, setAutoselect] = createSignal(!params.dir)
- 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()
- const providers = useProviders()
- const dialog = useDialog()
- const command = useCommand()
- const theme = useTheme()
- const language = useLanguage()
- const initialDir = 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 [editor, setEditor] = createStore({
- active: "" as string,
- value: "",
- })
- const [busyWorkspaces, setBusyWorkspaces] = createSignal<Set<string>>(new Set())
- const setBusy = (directory: string, value: boolean) => {
- const key = workspaceKey(directory)
- setBusyWorkspaces((prev) => {
- const next = new Set(prev)
- if (value) next.add(key)
- else next.delete(key)
- return next
- })
- }
- const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory))
- const editorRef = { current: undefined as HTMLInputElement | undefined }
- const [hoverSession, setHoverSession] = createSignal<string | undefined>()
- const autoselecting = createMemo(() => {
- if (params.dir) return false
- if (initialDir) return false
- if (!autoselect()) return false
- if (!pageReady()) return true
- if (!layoutReady()) return true
- const list = layout.projects.list()
- if (list.length === 0) return false
- return true
- })
- const editorOpen = (id: string) => editor.active === id
- const editorValue = () => editor.value
- const openEditor = (id: string, value: string) => {
- if (!id) return
- setEditor({ active: id, value })
- }
- const closeEditor = () => setEditor({ active: "", value: "" })
- const saveEditor = (callback: (next: string) => void) => {
- const next = editor.value.trim()
- if (!next) {
- closeEditor()
- return
- }
- closeEditor()
- callback(next)
- }
- const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
- if (event.key === "Enter") {
- event.preventDefault()
- saveEditor(callback)
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeEditor()
- }
- }
- const InlineEditor = (props: {
- id: string
- value: Accessor<string>
- onSave: (next: string) => void
- class?: string
- displayClass?: string
- editing?: boolean
- stopPropagation?: boolean
- openOnDblClick?: boolean
- }) => {
- const isEditing = () => props.editing ?? editorOpen(props.id)
- const stopEvents = () => props.stopPropagation ?? false
- const allowDblClick = () => props.openOnDblClick ?? true
- const stopPropagation = (event: Event) => {
- if (!stopEvents()) return
- event.stopPropagation()
- }
- const handleDblClick = (event: MouseEvent) => {
- if (!allowDblClick()) return
- stopPropagation(event)
- openEditor(props.id, props.value())
- }
- return (
- <Show
- when={isEditing()}
- fallback={
- <span
- class={props.displayClass ?? props.class}
- onDblClick={handleDblClick}
- onPointerDown={stopPropagation}
- onMouseDown={stopPropagation}
- onClick={stopPropagation}
- onTouchStart={stopPropagation}
- >
- {props.value()}
- </span>
- }
- >
- <InlineInput
- ref={(el) => {
- editorRef.current = el
- requestAnimationFrame(() => el.focus())
- }}
- value={editorValue()}
- class={props.class}
- onInput={(event) => setEditor("value", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- editorKeyDown(event, props.onSave)
- }}
- onBlur={() => closeEditor()}
- onPointerDown={stopPropagation}
- onClick={stopPropagation}
- onDblClick={stopPropagation}
- onMouseDown={stopPropagation}
- onMouseUp={stopPropagation}
- onTouchStart={stopPropagation}
- />
- </Show>
- )
- }
- 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)
- }
- onMount(() => {
- if (!platform.checkUpdate || !platform.update || !platform.restart) return
- let toastId: number | undefined
- async function pollUpdate() {
- const { updateAvailable, version } = await platform.checkUpdate!()
- if (updateAvailable && toastId === undefined) {
- 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",
- },
- ],
- })
- }
- }
- pollUpdate()
- const interval = setInterval(pollUpdate, 10 * 60 * 1000)
- onCleanup(() => clearInterval(interval))
- })
- onMount(() => {
- const toastBySession = new Map<string, number>()
- const alertedAtBySession = new Map<string, number>()
- const cooldownMs = 5000
- const unsub = globalSDK.event.listen((e) => {
- 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)
- 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") {
- 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 currentDir = params.dir ? base64Decode(params.dir) : undefined
- const currentSession = params.id
- if (directory === currentDir && props.sessionID === currentSession) return
- if (directory === currentDir && session?.parentID === currentSession) return
- const existingToastId = toastBySession.get(sessionKey)
- if (existingToastId !== undefined) toaster.dismiss(existingToastId)
- 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 currentDir = params.dir ? base64Decode(params.dir) : undefined
- const currentSession = params.id
- if (!currentDir || !currentSession) return
- const sessionKey = `${currentDir}:${currentSession}`
- const toastId = toastBySession.get(sessionKey)
- if (toastId !== undefined) {
- toaster.dismiss(toastId)
- toastBySession.delete(sessionKey)
- alertedAtBySession.delete(sessionKey)
- }
- const [store] = globalSync.child(currentDir)
- const childSessions = store.session.filter((s) => s.parentID === currentSession)
- for (const child of childSessions) {
- const childKey = `${currentDir}:${child.id}`
- const childToastId = toastBySession.get(childKey)
- if (childToastId !== undefined) {
- toaster.dismiss(childToastId)
- toastBySession.delete(childKey)
- alertedAtBySession.delete(childKey)
- }
- }
- })
- })
- function sortSessions(a: Session, b: Session) {
- const now = Date.now()
- const oneMinuteAgo = now - 60 * 1000
- const aUpdated = a.time.updated ?? a.time.created
- const bUpdated = b.time.updated ?? b.time.created
- const aRecent = aUpdated > oneMinuteAgo
- const bRecent = bUpdated > oneMinuteAgo
- if (aRecent && bRecent) return a.id.localeCompare(b.id)
- if (aRecent && !bRecent) return -1
- if (!aRecent && bRecent) return 1
- return bUpdated - aUpdated
- }
- const [scrollSessionKey, setScrollSessionKey] = createSignal<string | undefined>(undefined)
- function scrollToSession(sessionId: string, sessionKey: string) {
- if (!scrollContainerRef) return
- if (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) {
- setScrollSessionKey(sessionKey)
- return
- }
- setScrollSessionKey(sessionKey)
- element.scrollIntoView({ block: "nearest", behavior: "smooth" })
- }
- const currentProject = createMemo(() => {
- const directory = params.dir ? base64Decode(params.dir) : undefined
- 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)
- 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(), project: currentProject() }),
- (value) => {
- if (!value.ready) return
- const project = value.project
- if (!project) return
- const last = server.projects.last()
- if (last === project.worktree) return
- server.projects.touch(project.worktree)
- },
- { defer: true },
- ),
- )
- createEffect(
- on(
- () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
- (value) => {
- if (!value.ready) return
- if (!value.layoutReady) return
- if (!autoselect()) return
- if (initialDir) return
- if (value.dir) return
- if (value.list.length === 0) return
- const last = server.projects.last()
- const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
- if (!next) return
- setAutoselect(false)
- openProject(next.worktree, false)
- navigateToProject(next.worktree)
- },
- { defer: true },
- ),
- )
- const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
- 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", (prev) => ({ ...(prev ?? {}), [key]: next }))
- if (!projectId) return
- if (!branch) return
- setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next }))
- }
- const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
- workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
- const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
- const workspaceSetting = createMemo(() => {
- const project = currentProject()
- if (!project) return false
- return layout.sidebar.workspaces(project.worktree)()
- })
- createEffect(() => {
- if (!pageReady()) return
- if (!layoutReady()) return
- const project = currentProject()
- if (!project) return
- const dirs = [project.worktree, ...(project.sandboxes ?? [])]
- const existing = store.workspaceOrder[project.worktree]
- if (!existing) {
- setStore("workspaceOrder", project.worktree, dirs)
- return
- }
- const keep = existing.filter((d) => dirs.includes(d))
- const missing = dirs.filter((d) => !existing.includes(d))
- const merged = [...keep, ...missing]
- if (merged.length !== existing.length) {
- setStore("workspaceOrder", project.worktree, merged)
- return
- }
- if (merged.some((d, i) => d !== existing[i])) {
- setStore("workspaceOrder", project.worktree, merged)
- }
- })
- 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 (layout.sidebar.workspaces(project.worktree)()) continue
- setStore("workspaceExpanded", directory, false)
- }
- })
- const currentSessions = createMemo(() => {
- const project = currentProject()
- if (!project) return [] as Session[]
- if (workspaceSetting()) {
- const dirs = workspaceIds(project)
- const activeDir = params.dir ? base64Decode(params.dir) : ""
- const result: Session[] = []
- for (const dir of dirs) {
- const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
- const active = dir === activeDir
- if (!expanded && !active) continue
- const [dirStore] = globalSync.child(dir, { bootstrap: true })
- const dirSessions = dirStore.session
- .filter((session) => session.directory === dirStore.path.directory)
- .filter((session) => !session.parentID && !session.time?.archived)
- .toSorted(sortSessions)
- result.push(...dirSessions)
- }
- return result
- }
- const [projectStore] = globalSync.child(project.worktree)
- return projectStore.session
- .filter((session) => session.directory === projectStore.path.directory)
- .filter((session) => !session.parentID && !session.time?.archived)
- .toSorted(sortSessions)
- })
- type PrefetchQueue = {
- inflight: Set<string>
- pending: string[]
- pendingSet: Set<string>
- running: number
- }
- const prefetchChunk = 600
- const prefetchConcurrency = 1
- const prefetchPendingLimit = 6
- const prefetchToken = { value: 0 }
- const prefetchQueues = new Map<string, PrefetchQueue>()
- 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
- }
- async function prefetchMessages(directory: string, sessionID: string, token: number) {
- const [, setStore] = globalSync.child(directory)
- return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
- .then((messages) => {
- if (prefetchToken.value !== token) return
- const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
- const next = items
- .map((x) => x.info)
- .filter((m) => !!m?.id)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- batch(() => {
- setStore("message", sessionID, reconcile(next, { key: "id" }))
- for (const message of items) {
- setStore(
- "part",
- message.info.id,
- reconcile(
- message.parts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id)),
- { 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)
- if (store.message[session.id] !== undefined) return
- const q = queueFor(directory)
- if (q.inflight.has(session.id)) return
- if (q.pendingSet.has(session.id)) 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)
- }
- 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)
- }
- if (import.meta.env.DEV) {
- navStart({
- dir: base64Encode(session.directory),
- from: params.id,
- to: session.id,
- trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
- })
- }
- navigateToSession(session)
- queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
- }
- 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(() => {
- 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.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: "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 navigateToProject(directory: string | undefined) {
- if (!directory) return
- server.projects.touch(directory)
- const lastSession = store.lastSession[directory]
- navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
- layout.mobileSidebar.hide()
- }
- function navigateToSession(session: Session | undefined) {
- if (!session) return
- navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
- layout.mobileSidebar.hide()
- }
- function openProject(directory: string, navigate = true) {
- layout.projects.open(directory)
- if (navigate) navigateToProject(directory)
- }
- const displayName = (project: LocalProject) => project.name || getFilename(project.worktree)
- async function renameProject(project: LocalProject, next: string) {
- if (!project.id) return
- const current = displayName(project)
- if (next === current) return
- const name = next === getFilename(project.worktree) ? "" : next
- await globalSDK.client.project.update({ projectID: project.id, name })
- }
- async function renameSession(session: Session, next: string) {
- if (next === session.title) return
- await globalSDK.client.session.update({
- directory: session.directory,
- sessionID: session.id,
- title: next,
- })
- }
- 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 index = layout.projects.list().findIndex((x) => x.worktree === directory)
- const next = layout.projects.list()[index + 1]
- layout.projects.close(directory)
- if (next) navigateToProject(next.worktree)
- else navigate("/")
- }
- 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 errorMessage = (err: unknown) => {
- if (err && typeof err === "object" && "data" in err) {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message
- }
- if (err instanceof Error) return err.message
- return language.t("common.requestFailed")
- }
- const deleteWorkspace = async (directory: string) => {
- const current = currentProject()
- if (!current) return
- if (directory === current.worktree) return
- setBusy(directory, true)
- const result = await globalSDK.client.worktree
- .remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("workspace.delete.failed.title"),
- description: errorMessage(err),
- })
- return false
- })
- setBusy(directory, false)
- if (!result) return
- layout.projects.close(directory)
- layout.projects.open(current.worktree)
- if (params.dir && base64Decode(params.dir) === directory) {
- navigateToProject(current.worktree)
- }
- }
- const resetWorkspace = async (directory: string) => {
- const current = currentProject()
- if (!current) return
- if (directory === current.worktree) 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 = await globalSDK.client.session
- .list({ directory })
- .then((x) => x.data ?? [])
- .catch(() => [])
- const result = await globalSDK.client.worktree
- .reset({ directory: current.worktree, worktreeResetInput: { directory } })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("workspace.reset.failed.title"),
- description: errorMessage(err),
- })
- 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),
- ),
- )
- await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
- setBusy(directory, false)
- dismiss()
- const href = `/${base64Encode(directory)}/session`
- navigate(href)
- layout.mobileSidebar.hide()
- showToast({
- title: language.t("workspace.reset.success.title"),
- description: language.t("workspace.reset.success.description"),
- })
- }
- function DialogDeleteWorkspace(props: { directory: string }) {
- const name = createMemo(() => getFilename(props.directory))
- const [data, setData] = createStore({
- status: "loading" as "loading" | "ready" | "error",
- dirty: false,
- })
- onMount(() => {
- const current = currentProject()
- if (!current) {
- setData({ status: "error", dirty: false })
- return
- }
- 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 = async () => {
- await deleteWorkspace(props.directory)
- dialog.close()
- }
- 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: { 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(() => {
- const current = currentProject()
- if (!current) {
- setState({ status: "error", dirty: false })
- return
- }
- 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.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>
- )
- }
- createEffect(
- on(
- () => ({ ready: pageReady(), dir: params.dir, id: params.id }),
- (value) => {
- if (!value.ready) return
- const dir = value.dir
- const id = value.id
- if (!dir || !id) return
- const directory = base64Decode(dir)
- setStore("lastSession", directory, id)
- notification.session.markViewed(id)
- const expanded = untrack(() => store.workspaceExpanded[directory])
- if (expanded === false) {
- setStore("workspaceExpanded", directory, true)
- }
- requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
- },
- { defer: true },
- ),
- )
- createEffect(() => {
- const project = currentProject()
- if (!project) return
- if (workspaceSetting()) {
- const activeDir = params.dir ? base64Decode(params.dir) : ""
- const dirs = [project.worktree, ...(project.sandboxes ?? [])]
- for (const directory of dirs) {
- const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
- const active = directory === activeDir
- if (!expanded && !active) continue
- globalSync.project.loadSessions(directory)
- }
- return
- }
- globalSync.project.loadSessions(project.worktree)
- })
- function getDraggableId(event: unknown): string | undefined {
- if (typeof event !== "object" || event === null) return undefined
- if (!("draggable" in event)) return undefined
- const draggable = (event as { draggable?: { id?: unknown } }).draggable
- if (!draggable) return undefined
- return typeof draggable.id === "string" ? draggable.id : undefined
- }
- function handleDragStart(event: unknown) {
- const id = getDraggableId(event)
- if (!id) return
- 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 dirs = [project.worktree, ...(project.sandboxes ?? [])]
- const active = currentProject()
- const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
- const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs
- const existing = store.workspaceOrder[project.worktree]
- if (!existing) return next
- const keep = existing.filter((d) => next.includes(d))
- const missing = next.filter((d) => !existing.includes(d))
- return [...keep, ...missing]
- }
- 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 = currentProject()
- 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)
- }
- function handleWorkspaceDragEnd() {
- setStore("activeWorkspace", undefined)
- }
- const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
- const notification = useNotification()
- const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
- const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
- const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
- const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)"
- const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
- return (
- <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
- <div class="size-full rounded overflow-clip">
- <Avatar
- fallback={name()}
- src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
- {...getAvatarColors(props.project.icon?.color)}
- class="size-full rounded"
- style={
- notifications().length > 0 && props.notify
- ? { "-webkit-mask-image": mask, "mask-image": mask }
- : undefined
- }
- />
- </div>
- <Show when={notifications().length > 0 && props.notify}>
- <div
- classList={{
- "absolute top-px right-px size-1.5 rounded-full z-10": true,
- "bg-icon-critical-base": hasError(),
- "bg-text-interactive-base": !hasError(),
- }}
- />
- </Show>
- </div>
- )
- }
- const SessionItem = (props: {
- session: Session
- slug: string
- mobile?: boolean
- dense?: boolean
- popover?: boolean
- }): JSX.Element => {
- const notification = useNotification()
- const notifications = createMemo(() => notification.session.unseen(props.session.id))
- const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
- const [sessionStore] = globalSync.child(props.session.directory)
- const hasPermissions = createMemo(() => {
- const permissions = sessionStore.permission?.[props.session.id] ?? []
- if (permissions.length > 0) return true
- const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
- for (const child of childSessions) {
- const childPermissions = sessionStore.permission?.[child.id] ?? []
- if (childPermissions.length > 0) return true
- }
- return false
- })
- const isWorking = createMemo(() => {
- if (hasPermissions()) return false
- const status = sessionStore.session_status[props.session.id]
- return status?.type === "busy" || status?.type === "retry"
- })
- const tint = createMemo(() => {
- const messages = sessionStore.message[props.session.id]
- if (!messages) return undefined
- const user = messages
- .slice()
- .reverse()
- .find((m) => m.role === "user")
- if (!user?.agent) return undefined
- const agent = sessionStore.agent.find((a) => a.name === user.agent)
- return agent?.color
- })
- const hoverMessages = createMemo(() =>
- sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
- )
- const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
- const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
- const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
- const isActive = createMemo(() => props.session.id === params.id)
- const messageLabel = (message: Message) => {
- const parts = sessionStore.part[message.id] ?? []
- const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
- return text?.text
- }
- const item = (
- <A
- href={`${props.slug}/session/${props.session.id}`}
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
- onMouseEnter={() => prefetchSession(props.session, "high")}
- onFocus={() => prefetchSession(props.session, "high")}
- >
- <div class="flex items-center gap-1 w-full">
- <div
- class="shrink-0 size-6 flex items-center justify-center"
- style={{ color: tint() ?? "var(--icon-interactive-base)" }}
- >
- <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
- <Match when={isWorking()}>
- <Spinner class="size-[15px]" />
- </Match>
- <Match when={hasPermissions()}>
- <div class="size-1.5 rounded-full bg-surface-warning-strong" />
- </Match>
- <Match when={hasError()}>
- <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
- </Match>
- <Match when={notifications().length > 0}>
- <div class="size-1.5 rounded-full bg-text-interactive-base" />
- </Match>
- </Switch>
- </div>
- <InlineEditor
- id={`session:${props.session.id}`}
- value={() => props.session.title}
- onSave={(next) => renameSession(props.session, next)}
- class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
- displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
- stopPropagation
- />
- <Show when={props.session.summary}>
- {(summary) => (
- <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
- <DiffChanges changes={summary()} />
- </div>
- )}
- </Show>
- </div>
- </A>
- )
- return (
- <div
- data-session-id={props.session.id}
- class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
- hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
- >
- <Show
- when={hoverEnabled()}
- fallback={
- <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
- {item}
- </Tooltip>
- }
- >
- <HoverCard
- openDelay={1000}
- closeDelay={0}
- placement="right"
- gutter={28}
- trigger={item}
- open={hoverSession() === props.session.id}
- onOpenChange={(open) => setHoverSession(open ? props.session.id : undefined)}
- >
- <Show
- when={hoverReady()}
- fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
- >
- <MessageNav
- messages={hoverMessages() ?? []}
- current={undefined}
- getLabel={messageLabel}
- onMessageSelect={(message) => {
- if (!isActive()) {
- sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
- navigate(`${props.slug}/session/${props.session.id}`)
- return
- }
- window.history.replaceState(null, "", `#message-${message.id}`)
- window.dispatchEvent(new HashChangeEvent("hashchange"))
- }}
- size="normal"
- class="w-60"
- />
- </Show>
- </HoverCard>
- </Show>
- <div
- class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
- >
- <TooltipKeybind
- placement={props.mobile ? "bottom" : "right"}
- title={language.t("command.session.archive")}
- keybind={command.keybind("session.archive")}
- gutter={8}
- >
- <IconButton
- icon="archive"
- variant="ghost"
- onClick={() => archiveSession(props.session)}
- aria-label={language.t("command.session.archive")}
- />
- </TooltipKeybind>
- </div>
- </div>
- )
- }
- const SessionSkeleton = (props: { count?: number }): JSX.Element => {
- const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
- return (
- <div class="flex flex-col gap-1">
- <For each={items}>
- {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
- </For>
- </div>
- )
- }
- const ProjectDragOverlay = (): JSX.Element => {
- const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
- return (
- <Show when={project()}>
- {(p) => (
- <div class="bg-background-base rounded-xl p-1">
- <ProjectIcon project={p()} />
- </div>
- )}
- </Show>
- )
- }
- const WorkspaceDragOverlay = (): JSX.Element => {
- const label = createMemo(() => {
- const project = currentProject()
- if (!project) return
- const directory = store.activeWorkspace
- if (!directory) return
- const [workspaceStore] = globalSync.child(directory)
- const kind =
- directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
- const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
- return `${kind} : ${name}`
- })
- return (
- <Show when={label()}>
- {(value) => (
- <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
- )}
- </Show>
- )
- }
- const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
- const sortable = createSortable(props.directory)
- const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
- const [menuOpen, setMenuOpen] = createSignal(false)
- const [pendingRename, setPendingRename] = createSignal(false)
- const slug = createMemo(() => base64Encode(props.directory))
- const sessions = createMemo(() =>
- workspaceStore.session
- .filter((session) => session.directory === workspaceStore.path.directory)
- .filter((session) => !session.parentID && !session.time?.archived)
- .toSorted(sortSessions),
- )
- const local = createMemo(() => props.directory === props.project.worktree)
- const active = createMemo(() => {
- const current = params.dir ? base64Decode(params.dir) : ""
- return current === props.directory
- })
- const workspaceValue = createMemo(() => {
- const branch = workspaceStore.vcs?.branch
- const name = branch ?? getFilename(props.directory)
- return workspaceName(props.directory, props.project.id, branch) ?? name
- })
- const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local())
- const boot = createMemo(() => open() || active())
- const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
- const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
- const busy = createMemo(() => isBusy(props.directory))
- const loadMore = async () => {
- if (!local()) return
- setWorkspaceStore("limit", (limit) => limit + 5)
- await globalSync.project.loadSessions(props.directory)
- }
- const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`))
- const openWrapper = (value: boolean) => {
- setStore("workspaceExpanded", props.directory, value)
- if (value) return
- if (editorOpen(`workspace:${props.directory}`)) closeEditor()
- }
- createEffect(() => {
- if (!boot()) return
- globalSync.child(props.directory, { bootstrap: true })
- })
- const header = () => (
- <div class="flex items-center gap-1 min-w-0 flex-1">
- <div class="flex items-center justify-center shrink-0 size-6">
- <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
- <Spinner class="size-[15px]" />
- </Show>
- </div>
- <span class="text-14-medium text-text-base shrink-0">
- {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
- </span>
- <Show
- when={!local()}
- fallback={
- <span class="text-14-medium text-text-base min-w-0 truncate">
- {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
- </span>
- }
- >
- <InlineEditor
- id={`workspace:${props.directory}`}
- value={workspaceValue}
- onSave={(next) => {
- const trimmed = next.trim()
- if (!trimmed) return
- renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
- setEditor("value", workspaceValue())
- }}
- class="text-14-medium text-text-base min-w-0 truncate"
- displayClass="text-14-medium text-text-base min-w-0 truncate"
- editing={workspaceEditActive()}
- stopPropagation={false}
- openOnDblClick={false}
- />
- </Show>
- <Icon
- name={open() ? "chevron-down" : "chevron-right"}
- size="small"
- class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
- />
- </div>
- )
- return (
- <div
- // @ts-ignore
- use:sortable
- classList={{
- "opacity-30": sortable.isActiveDraggable,
- "opacity-50 pointer-events-none": busy(),
- }}
- >
- <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
- <div class="px-2 py-1">
- <div class="group/workspace relative">
- <div class="flex items-center gap-1">
- <Show
- when={workspaceEditActive()}
- fallback={
- <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
- {header()}
- </Collapsible.Trigger>
- }
- >
- <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
- </Show>
- <div
- class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
- classList={{
- "opacity-100 pointer-events-auto": menuOpen(),
- "opacity-0 pointer-events-none": !menuOpen(),
- "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
- "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
- }}
- >
- <DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
- <Tooltip value={language.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md"
- aria-label={language.t("common.moreOptions")}
- />
- </Tooltip>
- <DropdownMenu.Portal>
- <DropdownMenu.Content
- onCloseAutoFocus={(event) => {
- if (!pendingRename()) return
- event.preventDefault()
- setPendingRename(false)
- openEditor(`workspace:${props.directory}`, workspaceValue())
- }}
- >
- <DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
- <DropdownMenu.ItemLabel>{language.t("command.session.new")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local()}
- onSelect={() => {
- setPendingRename(true)
- setMenuOpen(false)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local() || busy()}
- onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
- >
- <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- disabled={local() || busy()}
- onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
- >
- <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- <TooltipKeybind
- placement="right"
- title={language.t("command.session.new")}
- keybind={command.keybind("session.new")}
- >
- <IconButton
- icon="plus-small"
- variant="ghost"
- class="size-6 rounded-md"
- onClick={() => navigate(`/${slug()}/session`)}
- aria-label={language.t("command.session.new")}
- />
- </TooltipKeybind>
- </div>
- </div>
- </div>
- </div>
- <Collapsible.Content>
- <nav class="flex flex-col gap-1 px-2">
- <Button
- as={A}
- href={`${slug()}/session`}
- variant="ghost"
- size="large"
- icon="edit"
- class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
- >
- {language.t("command.session.new")}
- </Button>
- <Show when={loading()}>
- <SessionSkeleton />
- </Show>
- <For each={sessions()}>
- {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
- </For>
- <Show when={hasMore()}>
- <div class="relative w-full py-1">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
- size="large"
- onClick={(e: MouseEvent) => {
- loadMore()
- ;(e.currentTarget as HTMLButtonElement).blur()
- }}
- >
- {language.t("common.loadMore")}
- </Button>
- </div>
- </Show>
- </nav>
- </Collapsible.Content>
- </Collapsible>
- </div>
- )
- }
- const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- const sortable = createSortable(props.project.worktree)
- const selected = createMemo(() => {
- const current = params.dir ? base64Decode(params.dir) : ""
- return props.project.worktree === current || props.project.sandboxes?.includes(current)
- })
- const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
- const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
- const [open, setOpen] = createSignal(false)
- const label = (directory: string) => {
- const [data] = globalSync.child(directory)
- const kind =
- directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
- const name = workspaceLabel(directory, data.vcs?.branch, props.project.id)
- return `${kind} : ${name}`
- }
- const sessions = (directory: string) => {
- const [data] = globalSync.child(directory)
- return data.session
- .filter((session) => session.directory === data.path.directory)
- .filter((session) => !session.parentID && !session.time?.archived)
- .toSorted(sortSessions)
- .slice(0, 2)
- }
- const projectSessions = () => {
- const [data] = globalSync.child(props.project.worktree)
- return data.session
- .filter((session) => session.directory === data.path.directory)
- .filter((session) => !session.parentID && !session.time?.archived)
- .toSorted(sortSessions)
- .slice(0, 2)
- }
- const projectName = () => props.project.name || getFilename(props.project.worktree)
- const trigger = (
- <button
- type="button"
- aria-label={projectName()}
- classList={{
- "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
- "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
- "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
- !selected() && !open(),
- "bg-surface-base-hover border border-border-weak-base": !selected() && open(),
- }}
- onClick={() => navigateToProject(props.project.worktree)}
- >
- <ProjectIcon project={props.project} notify />
- </button>
- )
- return (
- // @ts-ignore
- <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
- <HoverCard
- open={open()}
- openDelay={0}
- closeDelay={0}
- placement="right-start"
- gutter={6}
- trigger={trigger}
- onOpenChange={(value) => {
- setOpen(value)
- if (value) setHoverSession(undefined)
- }}
- >
- <div class="-m-3 p-2 flex flex-col w-72">
- <div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
- <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
- <div class="px-2 pb-2 flex flex-col gap-2">
- <Show
- when={workspaceEnabled()}
- fallback={
- <For each={projectSessions()}>
- {(session) => (
- <SessionItem
- session={session}
- slug={base64Encode(props.project.worktree)}
- dense
- mobile={props.mobile}
- popover={false}
- />
- )}
- </For>
- }
- >
- <For each={workspaces()}>
- {(directory) => (
- <div class="flex flex-col gap-1">
- <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
- <div class="shrink-0 size-6 flex items-center justify-center">
- <Icon name="branch" size="small" class="text-icon-base" />
- </div>
- <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
- </div>
- <For each={sessions(directory)}>
- {(session) => (
- <SessionItem
- session={session}
- slug={base64Encode(directory)}
- dense
- mobile={props.mobile}
- popover={false}
- />
- )}
- </For>
- </div>
- )}
- </For>
- </Show>
- </div>
- <div class="px-2 py-2 border-t border-border-weak-base">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
- onClick={() => {
- if (selected()) {
- setOpen(false)
- return
- }
- layout.sidebar.open()
- navigateToProject(props.project.worktree)
- }}
- >
- {language.t("sidebar.project.viewAllSessions")}
- </Button>
- </div>
- </div>
- </HoverCard>
- </div>
- )
- }
- const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
- const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree)
- const slug = createMemo(() => base64Encode(props.project.worktree))
- const sessions = createMemo(() =>
- workspaceStore.session
- .filter((session) => session.directory === workspaceStore.path.directory)
- .filter((session) => !session.parentID && !session.time?.archived)
- .toSorted(sortSessions),
- )
- const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0)
- const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length)
- const loadMore = async () => {
- setWorkspaceStore("limit", (limit) => limit + 5)
- await globalSync.project.loadSessions(props.project.worktree)
- }
- return (
- <div
- ref={(el) => {
- if (!props.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
- style={{ "overflow-anchor": "none" }}
- >
- <nav class="flex flex-col gap-1 px-2">
- <Show when={loading()}>
- <SessionSkeleton />
- </Show>
- <For each={sessions()}>
- {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
- </For>
- <Show when={hasMore()}>
- <div class="relative w-full py-1">
- <Button
- variant="ghost"
- class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
- size="large"
- onClick={(e: MouseEvent) => {
- loadMore()
- ;(e.currentTarget as HTMLButtonElement).blur()
- }}
- >
- {language.t("common.loadMore")}
- </Button>
- </div>
- </Show>
- </nav>
- </div>
- )
- }
- const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
- const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
- const sync = useGlobalSync()
- const project = createMemo(() => currentProject())
- const projectName = createMemo(() => {
- const current = project()
- if (!current) return ""
- return current.name || getFilename(current.worktree)
- })
- const projectId = createMemo(() => project()?.id ?? "")
- const workspaces = createMemo(() => workspaceIds(project()))
- const createWorkspace = async () => {
- const current = project()
- if (!current) return
- const created = await globalSDK.client.worktree
- .create({ directory: current.worktree })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("workspace.create.failed.title"),
- description: errorMessage(err),
- })
- return undefined
- })
- if (!created?.directory) return
- globalSync.child(created.directory)
- navigate(`/${base64Encode(created.directory)}/session`)
- }
- command.register(() => [
- {
- id: "workspace.new",
- title: language.t("workspace.new"),
- category: language.t("command.category.workspace"),
- keybind: "mod+shift+w",
- disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(),
- onSelect: createWorkspace,
- },
- ])
- const homedir = createMemo(() => sync.data.path.home)
- return (
- <div class="flex h-full w-full overflow-hidden">
- <div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
- <div class="flex-1 min-h-0 w-full">
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
- <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
- <For each={layout.projects.list()}>
- {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
- </For>
- </SortableProvider>
- <Tooltip
- placement={sidebarProps.mobile ? "bottom" : "right"}
- value={
- <div class="flex items-center gap-2">
- <span>{language.t("command.project.open")}</span>
- <Show when={!sidebarProps.mobile}>
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
- </Show>
- </div>
- }
- >
- <IconButton
- icon="plus"
- variant="ghost"
- size="large"
- onClick={chooseProject}
- aria-label={language.t("command.project.open")}
- />
- </Tooltip>
- </div>
- <DragOverlay>
- <ProjectDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
- <TooltipKeybind
- placement={sidebarProps.mobile ? "bottom" : "right"}
- title={language.t("sidebar.settings")}
- keybind={command.keybind("settings.open")}
- >
- <IconButton
- icon="settings-gear"
- variant="ghost"
- size="large"
- onClick={openSettings}
- aria-label={language.t("sidebar.settings")}
- />
- </TooltipKeybind>
- <Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value={language.t("sidebar.help")}>
- <IconButton
- icon="help"
- variant="ghost"
- size="large"
- onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
- aria-label={language.t("sidebar.help")}
- />
- </Tooltip>
- </div>
- </div>
- <Show when={expanded()}>
- <div
- classList={{
- "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
- "flex-1 min-w-0": sidebarProps.mobile,
- }}
- style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
- >
- <Show when={project()} keyed>
- {(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) => project() && renameProject(project()!, next)}
- class="text-16-medium text-text-strong truncate"
- displayClass="text-16-medium text-text-strong truncate"
- stopPropagation
- />
- <Tooltip
- placement={sidebarProps.mobile ? "bottom" : "top"}
- gutter={2}
- value={project()?.worktree}
- class="shrink-0"
- contentStyle={{
- "max-width": "640px",
- transform: "translate3d(52px, 0, 0)",
- }}
- >
- <span class="text-12-regular text-text-base truncate select-text">
- {project()?.worktree.replace(homedir(), "~")}
- </span>
- </Tooltip>
- </div>
- <DropdownMenu>
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
- aria-label={language.t("common.moreOptions")}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
- <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
- <DropdownMenu.ItemLabel>
- {layout.sidebar.workspaces(p.worktree)()
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
- <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
- </div>
- </div>
- <Show
- when={layout.sidebar.workspaces(p.worktree)()}
- fallback={
- <>
- <div class="py-4 px-3">
- <TooltipKeybind
- title={language.t("command.session.new")}
- keybind={command.keybind("session.new")}
- placement="top"
- >
- <Button
- size="large"
- icon="plus-small"
- class="w-full"
- onClick={() => {
- navigate(`/${base64Encode(p.worktree)}/session`)
- layout.mobileSidebar.hide()
- }}
- >
- {language.t("command.session.new")}
- </Button>
- </TooltipKeybind>
- </div>
- <div class="flex-1 min-h-0">
- <LocalWorkspace project={p} mobile={sidebarProps.mobile} />
- </div>
- </>
- }
- >
- <>
- <div class="py-4 px-3">
- <TooltipKeybind
- title={language.t("workspace.new")}
- keybind={command.keybind("workspace.new")}
- placement="top"
- >
- <Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
- {language.t("workspace.new")}
- </Button>
- </TooltipKeybind>
- </div>
- <div class="relative flex-1 min-h-0">
- <DragDropProvider
- onDragStart={handleWorkspaceDragStart}
- onDragEnd={handleWorkspaceDragEnd}
- onDragOver={handleWorkspaceDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={(el) => {
- if (!sidebarProps.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar"
- style={{ "overflow-anchor": "none" }}
- >
- <SortableProvider ids={workspaces()}>
- <For each={workspaces()}>
- {(directory) => (
- <SortableWorkspace directory={directory} project={p} mobile={sidebarProps.mobile} />
- )}
- </For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <WorkspaceDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- </>
- </Show>
- </>
- )}
- </Show>
- <Show when={providers.all().length > 0 && providers.paid().length === 0}>
- <div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
- <div class="rounded-md bg-background-base shadow-xs-border-base">
- <div class="p-3 flex flex-col gap-2">
- <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
- <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
- <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
- </div>
- <Button
- class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
- size="large"
- icon="plus"
- onClick={connectProvider}
- >
- {language.t("command.provider.connect")}
- </Button>
- </div>
- </div>
- </Show>
- </div>
- </Show>
- </div>
- )
- }
- return (
- <div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
- <Titlebar />
- <div class="flex-1 min-h-0 flex">
- <div
- classList={{
- "hidden xl:block": true,
- "relative shrink-0": true,
- }}
- style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
- >
- <div class="@container w-full h-full contain-strict">
- <SidebarContent />
- </div>
- <Show when={layout.sidebar.opened()}>
- <ResizeHandle
- direction="horizontal"
- size={layout.sidebar.width()}
- min={244}
- max={window.innerWidth * 0.3 + 64}
- collapseThreshold={244}
- onResize={layout.sidebar.resize}
- onCollapse={layout.sidebar.close}
- />
- </Show>
- </div>
- <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()
- }}
- />
- <div
- classList={{
- "@container fixed top-10 bottom-0 left-0 z-50 w-72 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 />
- </div>
- </div>
- <main
- classList={{
- "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
- "xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
- }}
- >
- <Show when={!autoselecting()} fallback={<div class="size-full" />}>
- {props.children}
- </Show>
- </main>
- </div>
- <Toast.Region />
- </div>
- )
- }
|