Buckets:
| import type { Session } from "@opencode-ai/sdk/v2/client" | |
| import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" | |
| import { makeEventListener } from "@solid-primitives/event-listener" | |
| import { createStore } from "solid-js/store" | |
| import { useQuery } from "@tanstack/solid-query" | |
| import { Button } from "@opencode-ai/ui/button" | |
| import { Logo } from "@opencode-ai/ui/logo" | |
| import { Spinner } from "@opencode-ai/ui/spinner" | |
| import { ScrollView } from "@opencode-ai/ui/scroll-view" | |
| import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" | |
| import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" | |
| import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" | |
| import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" | |
| import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" | |
| import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" | |
| import { useNavigate } from "@solidjs/router" | |
| import { base64Encode } from "@opencode-ai/core/util/encode" | |
| import { Icon } from "@opencode-ai/ui/icon" | |
| import { usePlatform } from "@/context/platform" | |
| import { DateTime } from "luxon" | |
| import { useDialog } from "@opencode-ai/ui/context/dialog" | |
| import { useDirectoryPicker } from "@/components/directory-picker" | |
| import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" | |
| import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" | |
| import { ServerConnection, useServer } from "@/context/server" | |
| import { sessionHasOpenTab, useTabs } from "@/context/tabs" | |
| import { useServerSync } from "@/context/server-sync" | |
| import { useLanguage } from "@/context/language" | |
| import { useNotification } from "@/context/notification" | |
| import { | |
| closeHomeProject, | |
| displayName, | |
| getProjectAvatarSource, | |
| homeProjectDirectories, | |
| homeProjectNavigation, | |
| type HomeProjectSelection, | |
| projectForSession, | |
| sortedRootSessions, | |
| toggleHomeProjectSelection, | |
| } from "@/pages/layout/helpers" | |
| import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" | |
| import { sessionTitle } from "@/utils/session-title" | |
| import { pathKey } from "@/utils/path-key" | |
| import { useGlobal } from "@/context/global" | |
| import { useCommand } from "@/context/command" | |
| import { useSettings } from "@/context/settings" | |
| import { ServerRowMenu } from "@/components/server/server-row-menu" | |
| import { ServerHealthIndicator } from "@/components/server/server-row" | |
| import { type ServerHealth } from "@/utils/server-health" | |
| const HOME_SESSION_LIMIT = 64 | |
| const HOME_ROW_LAYOUT = | |
| "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" | |
| const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` | |
| const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover` | |
| const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" | |
| const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]` | |
| const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" | |
| type HomeSessionRecord = { | |
| session: Session | |
| project: LocalProject | |
| projectName: string | |
| } | |
| type HomeSessionGroup = { | |
| id: "today" | "yesterday" | "older" | |
| title: string | |
| sessions: HomeSessionRecord[] | |
| } | |
| const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" | |
| const HOME_SEARCH_RESULT_ROW = | |
| "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" | |
| const HOME_SEARCH_RESULT_TITLE = | |
| "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]" | |
| const HOME_SEARCH_RESULT_META = | |
| "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]" | |
| let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined | |
| function buildHomeSessionRecords(input: { | |
| sync: Pick<ReturnType<typeof useServerSync>, "child"> | |
| projectDirectories: () => string[] | |
| projects: () => LocalProject[] | |
| projectByID: () => Map<string, LocalProject> | |
| }) { | |
| return [ | |
| ...new Map( | |
| input | |
| .projectDirectories() | |
| .flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now())) | |
| .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const), | |
| ).values(), | |
| ] | |
| .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) | |
| .flatMap((session) => { | |
| const project = projectForSession(session, input.projects(), input.projectByID()) | |
| if (!project) return [] | |
| return { | |
| session, | |
| project, | |
| projectName: displayName(project), | |
| } | |
| }) | |
| } | |
| function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) { | |
| return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query) | |
| } | |
| function homeSessionSearchKey(record: HomeSessionRecord) { | |
| return `${pathKey(record.session.directory)}:${record.session.id}` | |
| } | |
| export default function Home() { | |
| const settings = useSettings() | |
| return ( | |
| <Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}> | |
| <HomeDesign /> | |
| </Show> | |
| ) | |
| } | |
| function HomeDesign() { | |
| const sync = useServerSync() | |
| const layout = useLayout() | |
| const platform = usePlatform() | |
| const pickDirectory = useDirectoryPicker() | |
| const dialog = useDialog() | |
| const navigate = useNavigate() | |
| const server = useServer() | |
| const language = useLanguage() | |
| const global = useGlobal() | |
| const command = useCommand() | |
| const notification = useNotification() | |
| let focusSessionSearch: (() => void) | undefined | |
| const [state, setState] = createStore({ | |
| search: "", | |
| selection: { server: server.key } as HomeProjectSelection, | |
| searchFocused: false, | |
| }) | |
| const focusedServer = createMemo( | |
| () => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current, | |
| ) | |
| const focusedServerCtx = createMemo(() => { | |
| const conn = focusedServer() | |
| if (!conn) return | |
| return global.createServerCtx(conn) | |
| }) | |
| const focusedSync = () => focusedServerCtx()?.sync ?? sync | |
| const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) | |
| const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory)) | |
| const newSessionProject = createMemo( | |
| () => | |
| selectedProject() ?? | |
| projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? | |
| projects()[0], | |
| ) | |
| const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] | |
| const projectDirectories = createMemo(() => { | |
| const project = selectedProject() | |
| if (!project) return projects().flatMap(directories) | |
| return directories(project) | |
| }) | |
| const search = createMemo(() => state.search.trim()) | |
| const sessionLoad = useQuery(() => ({ | |
| queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const, | |
| queryFn: async () => { | |
| await Promise.all( | |
| projectDirectories().map((directory) => | |
| focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }), | |
| ), | |
| ) | |
| return null | |
| }, | |
| })) | |
| const projectByID = createMemo( | |
| () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), | |
| ) | |
| const allRecords = createMemo(() => | |
| buildHomeSessionRecords({ | |
| sync: focusedSync(), | |
| projectDirectories, | |
| projects, | |
| projectByID, | |
| }), | |
| ) | |
| const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) | |
| const searchResults = createMemo(() => { | |
| const query = search().toLowerCase() | |
| if (!query) return [] | |
| return allRecords().filter((record) => matchesHomeSessionSearch(record, query)) | |
| }) | |
| const searchOpen = createMemo(() => state.searchFocused && search().length > 0) | |
| const groups = createMemo(() => groupSessions(records(), language)) | |
| function setSelection(next: HomeProjectSelection) { | |
| batch(() => { | |
| if (state.selection.server !== next.server) setState("selection", "server", next.server) | |
| if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory) | |
| }) | |
| } | |
| function closeSearch() { | |
| setState("search", "") | |
| setState("searchFocused", false) | |
| } | |
| function selectSearchSession(session: Session) { | |
| openSession(session) | |
| closeSearch() | |
| } | |
| command.register("home", () => [ | |
| { | |
| id: "home.sessions.search.focus", | |
| title: language.t("home.sessions.search.placeholder"), | |
| keybind: "mod+f", | |
| hidden: true, | |
| onSelect: () => focusSessionSearch?.(), | |
| }, | |
| ]) | |
| createEffect(() => { | |
| const list = global.servers.list() | |
| if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return | |
| const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] | |
| if (conn) setSelection({ server: ServerConnection.key(conn) }) | |
| }) | |
| createEffect(() => { | |
| const pending = pendingHomeNavigation | |
| if (!pending || pending.server !== server.key) return | |
| pendingHomeNavigation = undefined | |
| navigate(pending.href) | |
| }) | |
| function focusServer(conn: ServerConnection.Any) { | |
| setSelection({ server: ServerConnection.key(conn) }) | |
| } | |
| function selectProject(conn: ServerConnection.Any, directory: string) { | |
| const key = ServerConnection.key(conn) | |
| if ( | |
| !global | |
| .createServerCtx(conn) | |
| .projects.list() | |
| .some((project) => project.worktree === directory) | |
| ) | |
| return | |
| setSelection(toggleHomeProjectSelection(state.selection, key, directory)) | |
| } | |
| function addProjects(conn: ServerConnection.Any, directories: string[]) { | |
| const directory = directories[0] | |
| if (!directory) return | |
| const ctx = global.createServerCtx(conn) | |
| directories.forEach(ctx.projects.open) | |
| ctx.projects.touch(directory) | |
| setSelection({ server: ServerConnection.key(conn), directory }) | |
| } | |
| function openNewSession() { | |
| const conn = focusedServer() | |
| const project = newSessionProject() | |
| if (!conn || !project) return | |
| openProjectNewSession(conn, project.worktree) | |
| } | |
| function navigateOnServer(conn: ServerConnection.Any, href: string) { | |
| const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href) | |
| if (!next.server) { | |
| navigate(next.href) | |
| return | |
| } | |
| pendingHomeNavigation = next | |
| server.setActive(next.server) | |
| } | |
| function openProjectNewSession(conn: ServerConnection.Any, directory: string) { | |
| const ctx = global.createServerCtx(conn) | |
| ctx.projects.open(directory) | |
| ctx.projects.touch(directory) | |
| navigateOnServer(conn, `/${base64Encode(directory)}/session`) | |
| } | |
| function editProject(conn: ServerConnection.Any, project: LocalProject) { | |
| void import("@/components/dialog-edit-project").then((x) => { | |
| dialog.show(() => <x.DialogEditProject server={conn} project={project} />) | |
| }) | |
| } | |
| function unseenCount(conn: ServerConnection.Any, project: LocalProject) { | |
| if (ServerConnection.key(conn) !== server.key) return 0 | |
| return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) | |
| } | |
| function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { | |
| if (ServerConnection.key(conn) !== server.key) return | |
| directories(project) | |
| .filter((directory) => notification.project.unseenCount(directory) > 0) | |
| .forEach((directory) => notification.project.markViewed(directory)) | |
| } | |
| function openSession(session: Session) { | |
| const project = projectForSession(session, projects(), projectByID()) | |
| const conn = focusedServer() | |
| if (!conn) return | |
| const directory = project?.worktree ?? session.directory | |
| const ctx = global.createServerCtx(conn) | |
| ctx.projects.open(directory) | |
| ctx.projects.touch(directory) | |
| navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) | |
| } | |
| function chooseProject(conn: ServerConnection.Any) { | |
| function resolve(result: string | string[] | null) { | |
| addProjects(conn, homeProjectDirectories(result)) | |
| } | |
| const server = global.createServerCtx(conn) | |
| pickDirectory({ | |
| server: conn, | |
| title: language.t("command.project.open"), | |
| multiple: true, | |
| onSelect: resolve, | |
| }) | |
| } | |
| function openSettings() { | |
| void import("@/components/settings-v2").then((x) => { | |
| dialog.show(() => <x.DialogSettings />) | |
| }) | |
| } | |
| return ( | |
| <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> | |
| <div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]"> | |
| <HomeProjectColumn | |
| projects={projects()} | |
| selected={state.selection} | |
| focusServer={focusServer} | |
| selectProject={selectProject} | |
| openNewSession={openProjectNewSession} | |
| chooseProject={(conn) => void chooseProject(conn)} | |
| editProject={editProject} | |
| closeProject={(conn, directory) => { | |
| const next = closeHomeProject( | |
| state.selection, | |
| ServerConnection.key(conn), | |
| global.createServerCtx(conn).projects, | |
| directory, | |
| ) | |
| if (next) setSelection(next) | |
| }} | |
| clearNotifications={clearNotifications} | |
| unseenCount={unseenCount} | |
| openSettings={openSettings} | |
| openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} | |
| language={language} | |
| /> | |
| <section | |
| class="min-h-0 min-w-0 flex-1 flex flex-col pt-12" | |
| aria-label={language.t("sidebar.project.recentSessions")} | |
| > | |
| <HomeSessionSearch | |
| value={state.search} | |
| placeholder={language.t("home.sessions.search.placeholder")} | |
| open={searchOpen()} | |
| loading={sessionLoad.isLoading} | |
| results={searchResults()} | |
| server={state.selection.server} | |
| activeServer={state.selection.server === server.key} | |
| noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} | |
| bindFocus={(focus) => { | |
| focusSessionSearch = focus | |
| }} | |
| onInput={(value) => setState("search", value)} | |
| onFocus={() => setState("searchFocused", true)} | |
| onClose={closeSearch} | |
| onSelect={selectSearchSession} | |
| /> | |
| <ScrollView class="mt-3 min-h-0 flex-1"> | |
| <div class="pt-3 flex flex-col gap-6"> | |
| <Show | |
| when={!sessionLoad.isLoading} | |
| fallback={<HomeSessionSkeleton label={language.t("common.loading")} />} | |
| > | |
| <Show | |
| when={groups().length > 0} | |
| fallback={ | |
| <div class="flex min-w-0 flex-col gap-4"> | |
| <HomeSessionGroupHeader | |
| title={language.t("home.sessions.empty")} | |
| onNewSession={newSessionProject() ? openNewSession : undefined} | |
| /> | |
| </div> | |
| } | |
| > | |
| <For each={groups()}> | |
| {(group, index) => ( | |
| <div class="flex min-w-0 flex-col gap-4"> | |
| <HomeSessionGroupHeader | |
| title={group.title} | |
| onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined} | |
| /> | |
| <div class="flex min-w-0 flex-col gap-px"> | |
| <For each={group.sessions}> | |
| {(record) => ( | |
| <HomeSessionRow | |
| record={record} | |
| server={state.selection.server} | |
| activeServer={state.selection.server === server.key} | |
| openSession={openSession} | |
| /> | |
| )} | |
| </For> | |
| </div> | |
| </div> | |
| )} | |
| </For> | |
| </Show> | |
| </Show> | |
| </div> | |
| </ScrollView> | |
| </section> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function HomeProjectColumn(props: { | |
| projects: LocalProject[] | |
| selected: HomeProjectSelection | |
| focusServer: (server: ServerConnection.Any) => void | |
| selectProject: (server: ServerConnection.Any, directory: string) => void | |
| openNewSession: (server: ServerConnection.Any, directory: string) => void | |
| chooseProject: (server: ServerConnection.Any) => void | |
| editProject: (server: ServerConnection.Any, project: LocalProject) => void | |
| closeProject: (server: ServerConnection.Any, directory: string) => void | |
| clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void | |
| unseenCount: (server: ServerConnection.Any, project: LocalProject) => number | |
| openSettings: () => void | |
| openHelp: () => void | |
| language: ReturnType<typeof useLanguage> | |
| }) { | |
| const global = useGlobal() | |
| const dialog = useDialog() | |
| const controller = useServerManagementController({ navigateOnAdd: false }) | |
| return ( | |
| <aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}> | |
| <div class="flex h-7 min-w-0 items-center justify-between pl-1.5"> | |
| <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div> | |
| <Show when={global.servers.list().length === 1}> | |
| <IconButtonV2 | |
| data-action="home-add-project" | |
| variant="ghost-muted" | |
| size="large" | |
| class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted" | |
| icon={<IconV2 name="folder-add-left" />} | |
| onClick={() => props.chooseProject(global.servers.list()[0]!)} | |
| aria-label={props.language.t("home.project.add")} | |
| /> | |
| </Show> | |
| </div> | |
| <Show | |
| when={global.servers.list().length > 1} | |
| fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />} | |
| > | |
| <For each={global.servers.list()}> | |
| {(item) => { | |
| const key = ServerConnection.key(item) | |
| const healthy = () => !!global.servers.health[key]?.healthy | |
| const serverCtx = global.createServerCtx(item) | |
| return ( | |
| <div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| <HomeServerRow | |
| server={item} | |
| selected={props.selected.server === key && !props.selected.directory} | |
| healthy={healthy()} | |
| health={global.servers.health[key]} | |
| controller={controller} | |
| focusServer={props.focusServer} | |
| chooseProject={props.chooseProject} | |
| openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)} | |
| language={props.language} | |
| /> | |
| <Show when={healthy()}> | |
| <div class="mx-3 h-px bg-v2-border-border-base" /> | |
| <HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} /> | |
| </Show> | |
| </div> | |
| ) | |
| }} | |
| </For> | |
| </Show> | |
| <div class="mt-4 flex min-w-0 flex-col gap-1"> | |
| <button | |
| type="button" | |
| class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`} | |
| onClick={props.openSettings} | |
| > | |
| <IconV2 name="settings-gear" size="small" /> | |
| <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span> | |
| </button> | |
| <button | |
| type="button" | |
| class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`} | |
| onClick={props.openHelp} | |
| > | |
| <IconV2 name="help" size="small" /> | |
| <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span> | |
| </button> | |
| </div> | |
| </aside> | |
| ) | |
| } | |
| function HomeServerRow(props: { | |
| server: ServerConnection.Any | |
| selected: boolean | |
| healthy: boolean | |
| health: ServerHealth | undefined | |
| controller: ReturnType<typeof useServerManagementController> | |
| focusServer: (server: ServerConnection.Any) => void | |
| chooseProject: (server: ServerConnection.Any) => void | |
| openEdit: (server: ServerConnection.Http) => void | |
| language: ReturnType<typeof useLanguage> | |
| }) { | |
| const [state, setState] = createStore({ menuOpen: false }) | |
| return ( | |
| <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]"> | |
| <button | |
| type="button" | |
| class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`} | |
| data-selected={props.selected ? "" : undefined} | |
| disabled={!props.healthy} | |
| onClick={() => props.focusServer(props.server)} | |
| > | |
| <div class="flex size-4 shrink-0 items-center justify-center"> | |
| <ServerHealthIndicator health={props.health} /> | |
| </div> | |
| <span class="flex min-w-0 items-center gap-1"> | |
| <span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span> | |
| <Show when={props.server.label}> | |
| {(label) => ( | |
| <span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted"> | |
| {label()} | |
| </span> | |
| )} | |
| </Show> | |
| </span> | |
| </button> | |
| <div | |
| class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100" | |
| data-menu={state.menuOpen} | |
| > | |
| <ServerRowMenu | |
| server={props.server} | |
| controller={props.controller} | |
| onEdit={props.openEdit} | |
| open={state.menuOpen} | |
| onOpenChange={(open) => setState("menuOpen", open)} | |
| /> | |
| <IconButtonV2 | |
| data-action="home-add-project" | |
| variant="ghost-muted" | |
| size="small" | |
| icon={<IconV2 name="folder-add-left" />} | |
| aria-label={props.language.t("home.project.add")} | |
| onClick={() => props.chooseProject(props.server)} | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function HomeProjectList(props: { | |
| server: ServerConnection.Any | |
| projects: LocalProject[] | |
| selected: HomeProjectSelection | |
| selectProject: (server: ServerConnection.Any, directory: string) => void | |
| openNewSession: (server: ServerConnection.Any, directory: string) => void | |
| editProject: (server: ServerConnection.Any, project: LocalProject) => void | |
| closeProject: (server: ServerConnection.Any, directory: string) => void | |
| clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void | |
| unseenCount: (server: ServerConnection.Any, project: LocalProject) => number | |
| language: ReturnType<typeof useLanguage> | |
| }) { | |
| return ( | |
| <div class="flex min-w-0 flex-col gap-1"> | |
| <For each={props.projects}> | |
| {(project) => ( | |
| <HomeProjectRow | |
| project={project} | |
| server={props.server} | |
| selected={ | |
| props.selected.server === ServerConnection.key(props.server) && | |
| props.selected.directory === project.worktree | |
| } | |
| unseenCount={props.unseenCount(props.server, project)} | |
| selectProject={props.selectProject} | |
| openNewSession={props.openNewSession} | |
| editProject={props.editProject} | |
| closeProject={props.closeProject} | |
| clearNotifications={props.clearNotifications} | |
| language={props.language} | |
| /> | |
| )} | |
| </For> | |
| </div> | |
| ) | |
| } | |
| function HomeProjectRow(props: { | |
| project: LocalProject | |
| server: ServerConnection.Any | |
| selected: boolean | |
| unseenCount: number | |
| selectProject: (server: ServerConnection.Any, directory: string) => void | |
| openNewSession: (server: ServerConnection.Any, directory: string) => void | |
| editProject: (server: ServerConnection.Any, project: LocalProject) => void | |
| closeProject: (server: ServerConnection.Any, directory: string) => void | |
| clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void | |
| language: ReturnType<typeof useLanguage> | |
| }) { | |
| const [state, setState] = createStore({ menuOpen: false }) | |
| return ( | |
| <div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"> | |
| <button | |
| type="button" | |
| data-component="home-project-row" | |
| class={`${HOME_PROJECT_NAV_ROW} pr-16`} | |
| data-selected={props.selected ? "" : undefined} | |
| aria-current={props.selected ? "page" : undefined} | |
| onClick={() => props.selectProject(props.server, props.project.worktree)} | |
| > | |
| <HomeProjectAvatar project={props.project} /> | |
| <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span> | |
| </button> | |
| <div | |
| class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100" | |
| data-menu={state.menuOpen} | |
| > | |
| <IconButtonV2 | |
| data-action="home-project-new-session" | |
| variant="ghost-muted" | |
| size="small" | |
| icon={<IconV2 name="edit" />} | |
| aria-label={props.language.t("command.session.new")} | |
| onClick={() => props.openNewSession(props.server, props.project.worktree)} | |
| /> | |
| <MenuV2 | |
| gutter={4} | |
| modal={false} | |
| placement="bottom-end" | |
| open={state.menuOpen} | |
| onOpenChange={(open) => setState("menuOpen", open)} | |
| > | |
| <MenuV2.Trigger | |
| as={IconButtonV2} | |
| data-action="home-project-menu" | |
| variant="ghost-muted" | |
| size="small" | |
| icon={<IconV2 name="outline-dots" />} | |
| aria-label={props.language.t("common.moreOptions")} | |
| /> | |
| <MenuV2.Portal> | |
| <MenuV2.Content> | |
| <MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}> | |
| {props.language.t("command.session.new")} | |
| </MenuV2.Item> | |
| <MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}> | |
| {props.language.t("common.edit")} | |
| </MenuV2.Item> | |
| <MenuV2.Item | |
| disabled={props.unseenCount === 0} | |
| onSelect={() => props.clearNotifications(props.server, props.project)} | |
| > | |
| {props.language.t("sidebar.project.clearNotifications")} | |
| </MenuV2.Item> | |
| <MenuV2.Separator /> | |
| <MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}> | |
| {props.language.t("common.close")} | |
| </MenuV2.Item> | |
| </MenuV2.Content> | |
| </MenuV2.Portal> | |
| </MenuV2> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function HomeProjectAvatar(props: { project: LocalProject }) { | |
| const name = createMemo(() => displayName(props.project)) | |
| return ( | |
| <ProjectAvatar | |
| fallback={name()} | |
| src={getProjectAvatarSource(props.project.id, props.project.icon)} | |
| variant={getProjectAvatarVariant(props.project.icon?.color)} | |
| /> | |
| ) | |
| } | |
| function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) { | |
| const directory = () => props.session.directory | |
| const sessionId = () => props.session.id | |
| const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) | |
| return ( | |
| <ProjectAvatar | |
| fallback={displayName(props.project)} | |
| src={getProjectAvatarSource(props.project.id, props.project.icon)} | |
| variant={getProjectAvatarVariant(props.project.icon?.color)} | |
| unread={state.unread()} | |
| loading={state.loading()} | |
| /> | |
| ) | |
| } | |
| function HomeSessionLeading(props: { | |
| project: LocalProject | |
| session: Session | |
| server: ServerConnection.Key | |
| activeServer: boolean | |
| }) { | |
| const tabs = useTabs() | |
| const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) | |
| return ( | |
| <div class="relative shrink-0"> | |
| <Show when={hasOpenTab()}> | |
| <span | |
| aria-hidden="true" | |
| class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04" | |
| style={{ right: "calc(100% + 12px)" }} | |
| /> | |
| </Show> | |
| <HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} /> | |
| </div> | |
| ) | |
| } | |
| function HomeSessionSearch(props: { | |
| value: string | |
| placeholder: string | |
| open: boolean | |
| loading: boolean | |
| results: HomeSessionRecord[] | |
| server: ServerConnection.Key | |
| activeServer: boolean | |
| noResultsLabel: string | |
| bindFocus: (focus: () => void) => void | |
| onInput: (value: string) => void | |
| onFocus: () => void | |
| onClose: () => void | |
| onSelect: (session: Session) => void | |
| }) { | |
| const language = useLanguage() | |
| const [store, setStore] = createStore({ active: "" }) | |
| let root: HTMLDivElement | undefined | |
| let input: HTMLInputElement | undefined | |
| let listRef: HTMLDivElement | undefined | |
| const focusInput = () => { | |
| input?.focus() | |
| props.onFocus() | |
| } | |
| onMount(() => { | |
| props.bindFocus(focusInput) | |
| }) | |
| const syncActive = (results: HomeSessionRecord[]) => { | |
| if (results.length === 0) { | |
| setStore("active", "") | |
| return | |
| } | |
| if (!results.some((record) => homeSessionSearchKey(record) === store.active)) { | |
| setStore("active", homeSessionSearchKey(results[0])) | |
| } | |
| } | |
| createEffect(() => syncActive(props.results)) | |
| createEffect( | |
| on( | |
| () => props.value, | |
| () => syncActive(props.results), | |
| ), | |
| ) | |
| const scrollActiveIntoView = () => { | |
| const key = store.active | |
| if (!key || !listRef) return | |
| const element = listRef.querySelector<HTMLElement>(`[data-key="${key}"]`) | |
| element?.scrollIntoView({ block: "nearest" }) | |
| } | |
| const moveActive = (delta: number) => { | |
| const results = props.results | |
| if (results.length === 0) return | |
| const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active) | |
| const start = index === -1 ? 0 : index | |
| const next = (start + delta + results.length) % results.length | |
| setStore("active", homeSessionSearchKey(results[next])) | |
| scrollActiveIntoView() | |
| } | |
| const selectActive = () => { | |
| const record = props.results.find((item) => homeSessionSearchKey(item) === store.active) | |
| if (!record) return | |
| props.onSelect(record.session) | |
| } | |
| onCleanup( | |
| makeEventListener(document, "pointerdown", (event) => { | |
| if (!props.open) return | |
| const target = event.target | |
| if (!(target instanceof Node)) return | |
| if (root?.contains(target)) return | |
| props.onClose() | |
| }), | |
| ) | |
| return ( | |
| <div class="ml-4 mr-2 w-[calc(100%_-_24px)]"> | |
| <div ref={root} data-component="home-session-search" class="relative z-10 w-full"> | |
| <Show when={props.open}> | |
| <div | |
| data-component="home-session-search-panel" | |
| class="absolute flex flex-col rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]" | |
| style={{ | |
| top: "-6px", | |
| left: "-6px", | |
| width: "calc(100% + 14px)", | |
| }} | |
| > | |
| <div class="flex flex-col pt-9"> | |
| <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4 pb-2"> | |
| <Show | |
| when={!props.loading} | |
| fallback={ | |
| <div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]"> | |
| <Spinner class="size-4" /> | |
| </div> | |
| } | |
| > | |
| <Show | |
| when={props.results.length > 0} | |
| fallback={ | |
| <p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"> | |
| {props.noResultsLabel} | |
| </p> | |
| } | |
| > | |
| <div class="flex flex-col"> | |
| <p class="my-1.5 px-4 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"> | |
| {language.t("home.sessions.search.sessions")} | |
| </p> | |
| <div ref={listRef} class="flex max-h-80 flex-col gap-px overflow-y-auto"> | |
| <For each={props.results}> | |
| {(record) => ( | |
| <HomeSessionSearchResultRow | |
| record={record} | |
| server={props.server} | |
| activeServer={props.activeServer} | |
| selected={store.active === homeSessionSearchKey(record)} | |
| onHighlight={() => setStore("active", homeSessionSearchKey(record))} | |
| onSelect={(session) => props.onSelect(session)} | |
| /> | |
| )} | |
| </For> | |
| </div> | |
| </div> | |
| </Show> | |
| </Show> | |
| </div> | |
| </div> | |
| </div> | |
| </Show> | |
| <label | |
| class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out" | |
| classList={{ | |
| "bg-v2-background-bg-layer-03 focus-within:bg-v2-background-bg-layer-03 focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": | |
| !props.open, | |
| "bg-transparent shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open, | |
| }} | |
| > | |
| <IconV2 name="magnifying-glass" /> | |
| <input | |
| ref={input} | |
| class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint" | |
| value={props.value} | |
| placeholder={props.placeholder} | |
| aria-label={props.placeholder} | |
| aria-expanded={props.open} | |
| aria-controls={HOME_SESSION_SEARCH_RESULTS_ID} | |
| aria-autocomplete="list" | |
| aria-activedescendant={ | |
| store.active && props.open ? `home-session-search-option-${store.active}` : undefined | |
| } | |
| onFocus={() => props.onFocus()} | |
| onInput={(event) => props.onInput(event.currentTarget.value)} | |
| onKeyDown={(event) => { | |
| if (event.key === "Escape") { | |
| event.preventDefault() | |
| props.onClose() | |
| input?.blur() | |
| return | |
| } | |
| if (!props.open || props.results.length === 0) return | |
| if (event.altKey || event.metaKey) return | |
| if (event.key === "ArrowDown") { | |
| event.preventDefault() | |
| moveActive(1) | |
| return | |
| } | |
| if (event.key === "ArrowUp") { | |
| event.preventDefault() | |
| moveActive(-1) | |
| return | |
| } | |
| if (event.key === "Enter" && !event.isComposing) { | |
| event.preventDefault() | |
| selectActive() | |
| } | |
| }} | |
| /> | |
| <Show when={props.value}> | |
| <IconButtonV2 | |
| type="button" | |
| variant="ghost-muted" | |
| size="small" | |
| class="relative z-20 shrink-0" | |
| icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />} | |
| aria-label={props.placeholder} | |
| onClick={() => { | |
| props.onClose() | |
| input?.focus() | |
| }} | |
| /> | |
| </Show> | |
| </label> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function HomeSessionSearchResultRow(props: { | |
| record: HomeSessionRecord | |
| server: ServerConnection.Key | |
| activeServer: boolean | |
| selected: boolean | |
| onHighlight: () => void | |
| onSelect: (session: Session) => void | |
| }) { | |
| const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) | |
| const key = () => homeSessionSearchKey(props.record) | |
| return ( | |
| <button | |
| type="button" | |
| id={`home-session-search-option-${key()}`} | |
| data-key={key()} | |
| data-component="home-session-search-row" | |
| role="option" | |
| aria-selected={props.selected} | |
| classList={{ | |
| [HOME_SEARCH_RESULT_ROW]: true, | |
| "bg-v2-overlay-simple-overlay-hover": props.selected, | |
| }} | |
| onMouseEnter={() => props.onHighlight()} | |
| onClick={() => props.onSelect(props.record.session)} | |
| > | |
| <HomeSessionLeading | |
| project={props.record.project} | |
| session={props.record.session} | |
| server={props.server} | |
| activeServer={props.activeServer} | |
| /> | |
| <div class="flex min-w-0 flex-1 items-center gap-1.5"> | |
| <span | |
| class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`} | |
| > | |
| {title()} | |
| </span> | |
| <Show when={props.record.projectName}> | |
| <span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span> | |
| </Show> | |
| </div> | |
| </button> | |
| ) | |
| } | |
| function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { | |
| const language = useLanguage() | |
| return ( | |
| <div class="flex h-7 min-w-0 items-center justify-between pl-4 pr-2"> | |
| <div class={HOME_SECTION_LABEL}>{props.title}</div> | |
| <Show when={props.onNewSession}> | |
| {(onNewSession) => ( | |
| <ButtonV2 | |
| data-action="home-new-session" | |
| variant="ghost-muted" | |
| size="normal" | |
| icon="edit" | |
| class="h-7 px-2 [font-weight:530]" | |
| onClick={onNewSession()} | |
| > | |
| {language.t("command.session.new")} | |
| </ButtonV2> | |
| )} | |
| </Show> | |
| </div> | |
| ) | |
| } | |
| function HomeSessionRow(props: { | |
| record: HomeSessionRecord | |
| server: ServerConnection.Key | |
| activeServer: boolean | |
| openSession: (session: Session) => void | |
| }) { | |
| const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) | |
| return ( | |
| <button | |
| type="button" | |
| data-component="home-session-row" | |
| class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} | |
| onClick={() => props.openSession(props.record.session)} | |
| > | |
| <HomeSessionLeading | |
| project={props.record.project} | |
| session={props.record.session} | |
| server={props.server} | |
| activeServer={props.activeServer} | |
| /> | |
| <span | |
| class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`} | |
| > | |
| {title()} | |
| </span> | |
| <Show when={props.record.projectName}> | |
| <span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]"> | |
| {props.record.projectName} | |
| </span> | |
| </Show> | |
| </button> | |
| ) | |
| } | |
| function HomeSessionSkeleton(props: { label: string }) { | |
| return ( | |
| <div class="flex min-w-0 flex-col gap-4"> | |
| <div class="flex h-7 min-w-0 items-center justify-between px-4"> | |
| <div class={HOME_SECTION_LABEL}>{props.label}</div> | |
| </div> | |
| <div class="flex min-w-0 flex-col gap-px" aria-hidden="true"> | |
| <For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] { | |
| const now = DateTime.local() | |
| const yesterday = now.minus({ days: 1 }) | |
| const todaySessions = records.filter((record) => | |
| DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), | |
| ) | |
| const yesterdaySessions = records.filter((record) => | |
| DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), | |
| ) | |
| const olderSessions = records.filter((record) => { | |
| const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) | |
| return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") | |
| }) | |
| const olderTitle = | |
| todaySessions.length === 0 && yesterdaySessions.length === 0 | |
| ? language.t("sidebar.project.recentSessions") | |
| : language.t("home.sessions.group.older") | |
| return [ | |
| { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, | |
| { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, | |
| { id: "older" as const, title: olderTitle, sessions: olderSessions }, | |
| ].filter((group) => group.sessions.length > 0) | |
| } | |
| function LegacyHome() { | |
| const sync = useServerSync() | |
| const platform = usePlatform() | |
| const pickDirectory = useDirectoryPicker() | |
| const dialog = useDialog() | |
| const navigate = useNavigate() | |
| const global = useGlobal() | |
| const server = useServer() | |
| const language = useLanguage() | |
| const homedir = createMemo(() => sync.data.path.home) | |
| const recent = createMemo(() => { | |
| return sync.data.project | |
| .slice() | |
| .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) | |
| .slice(0, 5) | |
| }) | |
| const serverDotClass = createMemo(() => { | |
| const healthy = global.servers.health[server.key]?.healthy | |
| if (healthy === true) return "bg-icon-success-base" | |
| if (healthy === false) return "bg-icon-critical-base" | |
| return "bg-border-weak-base" | |
| }) | |
| function openProject(server: ServerConnection.Any, directory: string) { | |
| const serverCtx = global.createServerCtx(server) | |
| serverCtx.projects.open(directory) | |
| serverCtx.projects.touch(directory) | |
| navigate(`/${base64Encode(directory)}`) | |
| } | |
| function chooseProject() { | |
| const s = server.current | |
| if (!s) return | |
| const resolve = (result: string | string[] | null) => { | |
| if (Array.isArray(result)) { | |
| for (const directory of result) { | |
| openProject(s, directory) | |
| } | |
| } else if (result) { | |
| openProject(s, result) | |
| } | |
| } | |
| pickDirectory({ | |
| server: s, | |
| title: language.t("command.project.open"), | |
| multiple: true, | |
| onSelect: resolve, | |
| }) | |
| } | |
| return ( | |
| <div class="mx-auto mt-55 w-full md:w-auto px-4"> | |
| <Logo class="md:w-xl opacity-12" /> | |
| <Button | |
| size="large" | |
| variant="ghost" | |
| class="mt-4 mx-auto text-14-regular text-text-weak" | |
| onClick={() => dialog.show(() => <DialogSelectServer />)} | |
| > | |
| <div | |
| classList={{ | |
| "size-2 rounded-full": true, | |
| [serverDotClass()]: true, | |
| }} | |
| /> | |
| {server.name} | |
| </Button> | |
| <Switch> | |
| <Match when={sync.data.project.length > 0}> | |
| <div class="mt-20 w-full flex flex-col gap-4"> | |
| <div class="flex gap-2 items-center justify-between pl-3"> | |
| <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div> | |
| <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}> | |
| {language.t("command.project.open")} | |
| </Button> | |
| </div> | |
| <ul class="flex flex-col gap-2"> | |
| <For each={recent()}> | |
| {(project) => ( | |
| <Button | |
| size="large" | |
| variant="ghost" | |
| class="text-14-mono text-left justify-between px-3" | |
| onClick={() => openProject(server.current!, project.worktree)} | |
| > | |
| {project.worktree.replace(homedir(), "~")} | |
| <div class="text-14-regular text-text-weak"> | |
| {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()} | |
| </div> | |
| </Button> | |
| )} | |
| </For> | |
| </ul> | |
| </div> | |
| </Match> | |
| <Match when={!sync.ready}> | |
| <div class="mt-30 mx-auto flex flex-col items-center gap-3"> | |
| <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div> | |
| <Button class="px-3" onClick={chooseProject}> | |
| {language.t("command.project.open")} | |
| </Button> | |
| </div> | |
| </Match> | |
| <Match when={true}> | |
| <div class="mt-30 mx-auto flex flex-col items-center gap-3"> | |
| <Icon name="folder-add-left" size="large" /> | |
| <div class="flex flex-col gap-1 items-center justify-center"> | |
| <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div> | |
| <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div> | |
| </div> | |
| <Button class="px-3 mt-1" onClick={chooseProject}> | |
| {language.t("command.project.open")} | |
| </Button> | |
| </div> | |
| </Match> | |
| </Switch> | |
| </div> | |
| ) | |
| } | |
Xet Storage Details
- Size:
- 47 kB
- Xet hash:
- 7de0e6b8236a2a16997cf1d12e2b0cc3366f3fcba19038bf3c551637329db736
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.