|
|
@@ -1,1926 +1,50 @@
|
|
|
-import type { Session } from "@opencode-ai/sdk/v2/client"
|
|
|
-import {
|
|
|
- type ComponentProps,
|
|
|
- createEffect,
|
|
|
- createMemo,
|
|
|
- createResource,
|
|
|
- createRoot,
|
|
|
- createSignal,
|
|
|
- For,
|
|
|
- Match,
|
|
|
- on,
|
|
|
- onCleanup,
|
|
|
- onMount,
|
|
|
- Show,
|
|
|
- startTransition,
|
|
|
- Switch,
|
|
|
-} from "solid-js"
|
|
|
-import { makeEventListener } from "@solid-primitives/event-listener"
|
|
|
-import { createStore, produce } from "solid-js/store"
|
|
|
-import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
|
|
-import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
|
|
|
-import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
|
|
-import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
|
|
|
-import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
|
|
-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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
|
-import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, 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 { useSettingsCommand } from "@/components/settings-dialog"
|
|
|
-import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
|
|
-import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
|
|
-import { ServerConnection, serverName, 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,
|
|
|
- errorMessage,
|
|
|
- getProjectAvatarSource,
|
|
|
- homeProjectDirectories,
|
|
|
- projectForSession,
|
|
|
- toggleHomeProjectSelection,
|
|
|
-} from "@/pages/layout/helpers"
|
|
|
-import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
|
|
-import { sessionTitle } from "@/utils/session-title"
|
|
|
-import { pathKey } from "@/utils/path-key"
|
|
|
-import { useGlobal } from "@/context/global"
|
|
|
-import { useCommand } from "@/context/command"
|
|
|
-import { Binary } from "@opencode-ai/core/util/binary"
|
|
|
-import { ServerRowMenu } from "@/components/server/server-row-menu"
|
|
|
-import { ServerHealthIndicator } from "@/components/server/server-row"
|
|
|
-import { type ServerHealth } from "@/utils/server-health"
|
|
|
-import { Persist, persisted } from "@/utils/persist"
|
|
|
-import { useMarked } from "@opencode-ai/ui/context/marked"
|
|
|
-import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
|
|
-import { archiveHomeSession } from "./home-session-archive"
|
|
|
-import { shouldOpenSessionInBackground } from "./home-session-open"
|
|
|
-import { showToast } from "@/utils/toast"
|
|
|
-import { fileManagerApp } from "@/utils/file-manager"
|
|
|
-import {
|
|
|
- loadHomeSessionIndex,
|
|
|
- retainHomeSessions,
|
|
|
- type HomeSessionEvents,
|
|
|
-} from "@/context/global-sync/home-session-index"
|
|
|
-
|
|
|
-const HOME_SESSION_LIMIT = 64
|
|
|
-const HOME_SESSION_HEADER_STICKY_TOP = 12
|
|
|
-const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
|
|
-const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
|
|
-
|
|
|
-function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
|
|
|
- if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
|
|
|
- if (!(event.target instanceof Element)) return
|
|
|
-
|
|
|
- const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
|
|
|
- if (
|
|
|
- scrollable !== viewport &&
|
|
|
- scrollable &&
|
|
|
- (event.deltaY < 0
|
|
|
- ? scrollable.scrollTop > 0
|
|
|
- : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
|
|
|
- )
|
|
|
- return
|
|
|
-
|
|
|
- event.preventDefault()
|
|
|
-}
|
|
|
-const SHOW_HOME_SESSION_ARCHIVE = false
|
|
|
-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-[18px] 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: {
|
|
|
- sessions: () => Session[]
|
|
|
- projectDirectories: () => string[]
|
|
|
- projects: () => LocalProject[]
|
|
|
- projectByID: () => Map<string, LocalProject>
|
|
|
-}) {
|
|
|
- const directories = new Set(input.projectDirectories().map(pathKey))
|
|
|
- const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
|
|
- return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
|
|
- .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
|
|
- .flatMap((session) => {
|
|
|
- const directory = pathKey(session.directory)
|
|
|
- const project =
|
|
|
- input
|
|
|
- .projects()
|
|
|
- .find(
|
|
|
- (item) =>
|
|
|
- pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
|
|
- ) ?? 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}`
|
|
|
-}
|
|
|
-
|
|
|
-function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|
|
- let viewport: HTMLDivElement | undefined
|
|
|
- let content: HTMLDivElement | undefined
|
|
|
- let positionFrame: number | undefined
|
|
|
- let resizeObserver: ResizeObserver | undefined
|
|
|
- let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
|
|
|
- const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
|
|
- const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
|
|
- const [state, setState] = createStore({
|
|
|
- titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- const items = groups()
|
|
|
- const ids = new Set(items.map((group) => group.id))
|
|
|
- headerRefs.forEach((_, id) => {
|
|
|
- if (!ids.has(id)) headerRefs.delete(id)
|
|
|
- })
|
|
|
- headerOffsets.forEach((_, id) => {
|
|
|
- if (!ids.has(id)) headerOffsets.delete(id)
|
|
|
- })
|
|
|
- if (items.length === 0) {
|
|
|
- content = undefined
|
|
|
- bindResizeObserver()
|
|
|
- }
|
|
|
- queuePositionUpdate()
|
|
|
- })
|
|
|
-
|
|
|
- onCleanup(() => {
|
|
|
- if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
|
|
- resizeObserver?.disconnect()
|
|
|
- })
|
|
|
-
|
|
|
- function setViewport(el: HTMLDivElement) {
|
|
|
- viewport = el
|
|
|
- bindResizeObserver()
|
|
|
- queuePositionUpdate()
|
|
|
- }
|
|
|
-
|
|
|
- function setContentRef(el: HTMLDivElement) {
|
|
|
- content = el
|
|
|
- bindResizeObserver()
|
|
|
- queuePositionUpdate()
|
|
|
- }
|
|
|
-
|
|
|
- function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
|
|
|
- headerRefs.set(id, el)
|
|
|
- queuePositionUpdate()
|
|
|
- }
|
|
|
-
|
|
|
- function queuePositionUpdate() {
|
|
|
- if (typeof requestAnimationFrame === "undefined") {
|
|
|
- updatePositionCache()
|
|
|
- return
|
|
|
- }
|
|
|
- if (positionFrame !== undefined) return
|
|
|
- positionFrame = requestAnimationFrame(() => {
|
|
|
- positionFrame = undefined
|
|
|
- updatePositionCache()
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- function updatePositionCache() {
|
|
|
- if (!viewport) return
|
|
|
- const header = groups()
|
|
|
- .map((group) => headerRefs.get(group.id))
|
|
|
- .find((el) => el !== undefined)
|
|
|
- if (header && typeof getComputedStyle === "function") {
|
|
|
- const top = Number.parseFloat(getComputedStyle(header).top)
|
|
|
- if (Number.isFinite(top)) stickyTop = top
|
|
|
- }
|
|
|
- groups().forEach((group) => {
|
|
|
- const el = headerRefs.get(group.id)
|
|
|
- if (!el) return
|
|
|
- headerOffsets.set(group.id, el.offsetTop)
|
|
|
- })
|
|
|
- update(viewport.scrollTop)
|
|
|
- }
|
|
|
-
|
|
|
- function update(scrollTop: number) {
|
|
|
- const items = groups()
|
|
|
- items.forEach((group, index) => {
|
|
|
- const nextOffset = items
|
|
|
- .slice(index + 1)
|
|
|
- .map((item) => headerOffsets.get(item.id))
|
|
|
- .find((offset) => offset !== undefined)
|
|
|
- const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
|
|
|
- const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
|
|
- const opacity =
|
|
|
- nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
|
|
- setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- function titleOpacity(id: HomeSessionGroup["id"]) {
|
|
|
- return state.titleOpacity[id] ?? 1
|
|
|
- }
|
|
|
-
|
|
|
- function bindResizeObserver() {
|
|
|
- resizeObserver?.disconnect()
|
|
|
- if (typeof ResizeObserver === "undefined") return
|
|
|
- resizeObserver = new ResizeObserver(() => queuePositionUpdate())
|
|
|
- if (viewport) resizeObserver.observe(viewport)
|
|
|
- if (content) resizeObserver.observe(content)
|
|
|
- }
|
|
|
-
|
|
|
- return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
|
|
-}
|
|
|
-
|
|
|
-// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
|
|
-// tab in the background without navigating, matching browser conventions.
|
|
|
-function isBackgroundOpen(event: MouseEvent) {
|
|
|
- return shouldOpenSessionInBackground({
|
|
|
- button: event.button,
|
|
|
- mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
|
|
- meta: event.metaKey,
|
|
|
- ctrl: event.ctrlKey,
|
|
|
- shift: event.shiftKey,
|
|
|
- alt: event.altKey,
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-type OpenSessionOptions = { background?: boolean }
|
|
|
+import { createHomeController } from "./home/home-controller"
|
|
|
+import { createHomeProjectsController } from "./home/home-projects-controller"
|
|
|
+import { HomeUtilityNav } from "./home/home-projects-view"
|
|
|
+import { HomeProjects } from "./home/home-projects"
|
|
|
+import { createHomeScrollController } from "./home/home-scroll-controller"
|
|
|
+import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
|
|
+import { createHomeSessionsController } from "./home/home-sessions-controller"
|
|
|
+import { HomeSessions } from "./home/home-sessions"
|
|
|
|
|
|
export function NewHome() {
|
|
|
- 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 tabs = useTabs()
|
|
|
- const command = useCommand()
|
|
|
- const notification = useNotification()
|
|
|
- const marked = useMarked()
|
|
|
- const openSettings = useSettingsCommand()
|
|
|
- let focusSessionSearch: (() => void) | undefined
|
|
|
- let sessionViewport: HTMLDivElement | undefined
|
|
|
- const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
|
|
|
- const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
|
|
|
- const [state, setState] = createStore({
|
|
|
- search: "",
|
|
|
- searchFocused: false,
|
|
|
- })
|
|
|
- const selection = layout.home.selection
|
|
|
-
|
|
|
- const focusedServer = createMemo(
|
|
|
- () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
|
|
|
- )
|
|
|
- const focusedServerCtx = createMemo(() => {
|
|
|
- const conn = focusedServer()
|
|
|
- if (!conn) return
|
|
|
- return global.ensureServerCtx(conn)
|
|
|
- })
|
|
|
- const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
|
|
- const homeSessions = () => focusedSync().homeSessions
|
|
|
- const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
|
|
- const recentlyClosed = createMemo(
|
|
|
- () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
|
|
- )
|
|
|
- const homedir = createMemo(() => focusedSync().data.path.home ?? "")
|
|
|
- const selectedProject = createMemo(() => projects().find((project) => project.worktree === 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 searchPlaceholder = createMemo(() => {
|
|
|
- const project = selectedProject()
|
|
|
- if (project) {
|
|
|
- return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
|
|
- }
|
|
|
- if (global.servers.list().length > 1) {
|
|
|
- const conn = focusedServer()
|
|
|
- if (conn) {
|
|
|
- return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
|
|
- }
|
|
|
- }
|
|
|
- return language.t("home.sessions.search.placeholder")
|
|
|
- })
|
|
|
- const sessionEventLoad = useQuery(() => ({
|
|
|
- queryKey: homeSessions().eventsKey,
|
|
|
- queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
|
|
|
- initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
|
|
|
- enabled: false,
|
|
|
- }))
|
|
|
- const sessionLoad = useQuery(() => ({
|
|
|
- queryKey: homeSessions().indexKey,
|
|
|
- enabled: !!focusedServerCtx(),
|
|
|
- queryFn: async ({ signal }) => {
|
|
|
- const ctx = focusedServerCtx()
|
|
|
- if (!ctx) return { sessions: [], eventSequence: 0 }
|
|
|
- const cache = homeSessions()
|
|
|
- const eventSequence = cache.eventSequence()
|
|
|
- const index = await loadHomeSessionIndex(
|
|
|
- (input, options) => ctx.sdk.client.v2.session.list(input, options),
|
|
|
- eventSequence,
|
|
|
- signal,
|
|
|
- )
|
|
|
- cache.complete(eventSequence)
|
|
|
- return index
|
|
|
- },
|
|
|
- retry: false,
|
|
|
- staleTime: 30_000,
|
|
|
- refetchOnMount: true,
|
|
|
- refetchOnReconnect: true,
|
|
|
- }))
|
|
|
-
|
|
|
- const projectByID = createMemo(
|
|
|
- () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
|
|
- )
|
|
|
- const indexedSessions = createMemo(() =>
|
|
|
- retainHomeSessions(
|
|
|
- homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
|
|
- HOME_SESSION_LIMIT,
|
|
|
- Date.now(),
|
|
|
- ),
|
|
|
- )
|
|
|
- const allRecords = createMemo(() =>
|
|
|
- buildHomeSessionRecords({
|
|
|
- sessions: indexedSessions,
|
|
|
- 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))
|
|
|
- const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
|
|
- const prefetched = new Set<string>()
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- const ctx = focusedServerCtx()
|
|
|
- if (!ctx) return
|
|
|
- records()
|
|
|
- .slice(0, 2)
|
|
|
- .forEach((record) => {
|
|
|
- const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
|
|
|
- if (prefetched.has(key)) return
|
|
|
- prefetched.add(key)
|
|
|
- createRoot((dispose) => {
|
|
|
- try {
|
|
|
- void ctx.sync.session
|
|
|
- .sync(record.session.id)
|
|
|
- .then(() => {
|
|
|
- return Promise.all(
|
|
|
- (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
|
|
|
- (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
|
|
|
- if (part.type !== "text" || !part.text) return []
|
|
|
- return preloadMarkdown(part.text, part.id, marked)
|
|
|
- }),
|
|
|
- ),
|
|
|
- )
|
|
|
- })
|
|
|
- .catch(() => {})
|
|
|
- .finally(dispose)
|
|
|
- } catch {
|
|
|
- dispose()
|
|
|
- }
|
|
|
- })
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- function setSelection(next: HomeProjectSelection) {
|
|
|
- layout.home.setSelection(next)
|
|
|
- }
|
|
|
-
|
|
|
- function closeSearch() {
|
|
|
- setState("search", "")
|
|
|
- setState("searchFocused", false)
|
|
|
- }
|
|
|
-
|
|
|
- function selectSearchSession(session: Session, options?: OpenSessionOptions) {
|
|
|
- openSession(session, options)
|
|
|
- // Background opens keep the search visible so several results can be
|
|
|
- // opened in a row.
|
|
|
- if (!options?.background) closeSearch()
|
|
|
- }
|
|
|
-
|
|
|
- command.register("home", () => [
|
|
|
- {
|
|
|
- id: "command.palette",
|
|
|
- title: language.t("command.palette"),
|
|
|
- hidden: true,
|
|
|
- onSelect: async () => {
|
|
|
- const conn = focusedServer()
|
|
|
- if (!conn) return
|
|
|
- const ctx = global.ensureServerCtx(conn)
|
|
|
- const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
|
|
|
- void dialog.show(() => (
|
|
|
- <DialogHomeCommandPaletteV2
|
|
|
- server={conn}
|
|
|
- onSelectSession={(entry) => {
|
|
|
- if (!entry.sessionID || !entry.directory || !entry.server) return
|
|
|
- const sessionID = entry.sessionID
|
|
|
- const server = entry.server
|
|
|
- const directory = entry.project?.worktree ?? entry.directory
|
|
|
- ctx.projects.open(directory)
|
|
|
- ctx.projects.touch(directory)
|
|
|
- void startTransition(() => {
|
|
|
- const tab = tabs.addSessionTab({ server, sessionId: sessionID })
|
|
|
- tabs.select(tab)
|
|
|
- })
|
|
|
- }}
|
|
|
- />
|
|
|
- ))
|
|
|
- },
|
|
|
- },
|
|
|
- {
|
|
|
- id: "home.sessions.search.focus",
|
|
|
- title: searchPlaceholder(),
|
|
|
- keybind: "mod+f",
|
|
|
- hidden: true,
|
|
|
- onSelect: () => focusSessionSearch?.(),
|
|
|
- },
|
|
|
- ])
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- const list = global.servers.list()
|
|
|
- if (list.some((conn) => ServerConnection.key(conn) === 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.servers.health[key]?.healthy === false) return
|
|
|
- if (
|
|
|
- !global
|
|
|
- .ensureServerCtx(conn)
|
|
|
- .projects.list()
|
|
|
- .some((project) => project.worktree === directory)
|
|
|
- )
|
|
|
- return
|
|
|
- setSelection(toggleHomeProjectSelection(selection(), key, directory))
|
|
|
- }
|
|
|
-
|
|
|
- function addProjects(conn: ServerConnection.Any, directories: string[]) {
|
|
|
- const directory = directories[0]
|
|
|
- if (!directory) return
|
|
|
- const ctx = global.ensureServerCtx(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 openProjectNewSession(conn: ServerConnection.Any, directory: string) {
|
|
|
- const ctx = global.ensureServerCtx(conn)
|
|
|
- ctx.projects.open(directory)
|
|
|
- ctx.projects.touch(directory)
|
|
|
- tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
|
|
- }
|
|
|
-
|
|
|
- function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
|
|
- void import("@/components/dialog-edit-project-v2").then((x) => {
|
|
|
- void dialog.show(() => <x.DialogEditProjectV2 server={conn} project={project} />)
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
|
|
- const state = notification.ensureServerState(ServerConnection.key(conn))
|
|
|
- return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
|
|
- }
|
|
|
-
|
|
|
- function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
|
|
- const state = notification.ensureServerState(ServerConnection.key(conn))
|
|
|
- directories(project)
|
|
|
- .filter((directory) => state.project.unseenCount(directory) > 0)
|
|
|
- .forEach((directory) => state.project.markViewed(directory))
|
|
|
- }
|
|
|
-
|
|
|
- function openSession(session: Session, options?: OpenSessionOptions) {
|
|
|
- const directoryKey = pathKey(session.directory)
|
|
|
- const project =
|
|
|
- projects().find(
|
|
|
- (item) =>
|
|
|
- pathKey(item.worktree) === directoryKey ||
|
|
|
- item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
|
|
- ) ?? projectForSession(session, projects(), projectByID())
|
|
|
- const conn = focusedServer()
|
|
|
- if (!conn) return
|
|
|
- const directory = project?.worktree ?? session.directory
|
|
|
- const ctx = global.ensureServerCtx(conn)
|
|
|
- ctx.projects.open(directory)
|
|
|
- if (options?.background) {
|
|
|
- tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
|
|
- return
|
|
|
- }
|
|
|
- ctx.projects.touch(directory)
|
|
|
- startTransition(() => {
|
|
|
- const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
|
|
- tabs.select(tab)
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- async function archiveSession(session: Session) {
|
|
|
- const conn = focusedServer()
|
|
|
- const ctx = focusedServerCtx()
|
|
|
- if (!conn || !ctx) return
|
|
|
- const [, setStore] = ctx.sync.child(session.directory)
|
|
|
- await archiveHomeSession({
|
|
|
- server: ServerConnection.key(conn),
|
|
|
- session,
|
|
|
- update: (value) => ctx.sdk.client.session.update(value),
|
|
|
- remove: () =>
|
|
|
- setStore(
|
|
|
- produce((draft) => {
|
|
|
- const match = Binary.search(draft.session, session.id, (s) => s.id)
|
|
|
- if (match.found) draft.session.splice(match.index, 1)
|
|
|
- }),
|
|
|
- ),
|
|
|
- onError: (error) =>
|
|
|
- showToast({
|
|
|
- title: language.t("common.requestFailed"),
|
|
|
- description: errorMessage(error, language.t("common.requestFailed")),
|
|
|
- }),
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- function chooseProject(conn: ServerConnection.Any) {
|
|
|
- if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
|
|
-
|
|
|
- function resolve(result: string | string[] | null) {
|
|
|
- addProjects(conn, homeProjectDirectories(result))
|
|
|
- }
|
|
|
-
|
|
|
- pickDirectory({
|
|
|
- server: conn,
|
|
|
- title: language.t("command.project.open"),
|
|
|
- multiple: true,
|
|
|
- onSelect: resolve,
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
+ const home = createHomeController()
|
|
|
+ const projects = createHomeProjectsController(home)
|
|
|
+ const sessions = createHomeSessionsController(home)
|
|
|
+ const search = createHomeSessionSearchController(home, sessions)
|
|
|
+ const scroll = createHomeScrollController(sessions.data.groups)
|
|
|
return (
|
|
|
- <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
|
|
+ <div
|
|
|
+ class={`
|
|
|
+ m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
|
|
+ bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
|
|
+ `}
|
|
|
+ >
|
|
|
<ScrollView
|
|
|
class="h-full [container-type:size]"
|
|
|
- thumbContainer={sessionThumbTrack}
|
|
|
- thumbHoverTarget={sessionHoverTarget}
|
|
|
- viewportRef={(el) => {
|
|
|
- sessionViewport = el
|
|
|
- sessionHeaderOpacity.setViewport(el)
|
|
|
- }}
|
|
|
- onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
|
|
- onWheel={(event) => {
|
|
|
- if (!sessionViewport) return
|
|
|
- if (event.target instanceof Node && sessionViewport.contains(event.target)) return
|
|
|
- containHomeWheel(event, sessionViewport)
|
|
|
- }}
|
|
|
+ thumbContainer={scroll.viewport.thumbTrack}
|
|
|
+ thumbHoverTarget={scroll.viewport.hoverTarget}
|
|
|
+ viewportRef={scroll.viewport.setViewport}
|
|
|
+ onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
|
|
|
+ onWheel={scroll.viewport.containOuterWheel}
|
|
|
>
|
|
|
- <div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
|
|
- <HomeProjectColumn
|
|
|
- projects={projects()}
|
|
|
- recentlyClosed={recentlyClosed()}
|
|
|
- homedir={homedir()}
|
|
|
- selected={selection()}
|
|
|
- focusServer={focusServer}
|
|
|
- selectProject={selectProject}
|
|
|
- openNewSession={openProjectNewSession}
|
|
|
- openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
|
|
- chooseProject={(conn) => void chooseProject(conn)}
|
|
|
- editProject={editProject}
|
|
|
- closeProject={(conn, directory) => {
|
|
|
- const next = closeHomeProject(
|
|
|
- selection(),
|
|
|
- ServerConnection.key(conn),
|
|
|
- global.ensureServerCtx(conn).projects,
|
|
|
- directory,
|
|
|
- )
|
|
|
- if (next) setSelection(next)
|
|
|
- }}
|
|
|
- clearNotifications={clearNotifications}
|
|
|
- unseenCount={unseenCount}
|
|
|
- openSettings={openSettings}
|
|
|
- openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
|
|
- language={language}
|
|
|
- onWheel={(event) => {
|
|
|
- if (sessionViewport) containHomeWheel(event, sessionViewport)
|
|
|
- }}
|
|
|
- />
|
|
|
-
|
|
|
- <section
|
|
|
- ref={setSessionHoverTarget}
|
|
|
- class="min-h-0 min-w-0 flex-1 flex flex-col"
|
|
|
- aria-label={language.t("sidebar.project.recentSessions")}
|
|
|
- >
|
|
|
- <div
|
|
|
- class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
|
|
|
- onWheel={(event) => {
|
|
|
- if (sessionViewport) containHomeWheel(event, sessionViewport)
|
|
|
- }}
|
|
|
- >
|
|
|
- <HomeSessionSearch
|
|
|
- value={state.search}
|
|
|
- placeholder={searchPlaceholder()}
|
|
|
- open={searchOpen()}
|
|
|
- loading={sessionLoad.isLoading}
|
|
|
- results={searchResults()}
|
|
|
- showProjectName={!selectedProject()}
|
|
|
- server={selection().server}
|
|
|
- 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}
|
|
|
- />
|
|
|
- <Show when={groups().length > 0 && newSessionProject()}>
|
|
|
- <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
|
|
- <ButtonV2
|
|
|
- data-action="home-new-session"
|
|
|
- variant="ghost-muted"
|
|
|
- size="normal"
|
|
|
- icon="edit"
|
|
|
- class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
|
|
- onClick={openNewSession}
|
|
|
- >
|
|
|
- {language.t("command.session.new")}
|
|
|
- </ButtonV2>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
|
|
|
- <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
|
|
- <div
|
|
|
- ref={setSessionThumbTrack}
|
|
|
- data-component="home-session-scroll-track"
|
|
|
- class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
|
|
- />
|
|
|
- </div>
|
|
|
- <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
|
|
- <Show
|
|
|
- when={!sessionLoad.isLoading}
|
|
|
- fallback={
|
|
|
- <div class="pt-3">
|
|
|
- <HomeSessionSkeleton label={language.t("common.loading")} />
|
|
|
- </div>
|
|
|
- }
|
|
|
- >
|
|
|
- <Show
|
|
|
- when={groups().length > 0}
|
|
|
- fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
|
|
- >
|
|
|
- <div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
|
|
- <For each={groups()}>
|
|
|
- {(group, index) => (
|
|
|
- <>
|
|
|
- <HomeSessionGroupHeader
|
|
|
- title={group.title}
|
|
|
- titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
|
|
- ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
|
|
- elevated={index() === 0}
|
|
|
- />
|
|
|
- <div
|
|
|
- class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
|
|
- >
|
|
|
- <For each={group.sessions}>
|
|
|
- {(record) => (
|
|
|
- <HomeSessionRow
|
|
|
- record={record}
|
|
|
- showProjectName={!selectedProject()}
|
|
|
- server={selection().server}
|
|
|
- openSession={openSession}
|
|
|
- archiveSession={archiveSession}
|
|
|
- />
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </section>
|
|
|
+ <div
|
|
|
+ class={`
|
|
|
+ mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3
|
|
|
+ lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6
|
|
|
+ `}
|
|
|
+ >
|
|
|
+ <HomeProjects projects={projects} scroll={scroll} />
|
|
|
+ <HomeSessions sessions={sessions} search={search} scroll={scroll} />
|
|
|
<HomeUtilityNav
|
|
|
class="flex lg:hidden"
|
|
|
- openSettings={openSettings}
|
|
|
- openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
|
|
- language={language}
|
|
|
+ onOpenSettings={projects.utility.settings}
|
|
|
+ onOpenHelp={projects.utility.help}
|
|
|
+ language={projects.copy.language}
|
|
|
/>
|
|
|
</div>
|
|
|
</ScrollView>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
-
|
|
|
-function HomeProjectColumn(props: {
|
|
|
- projects: LocalProject[]
|
|
|
- recentlyClosed: LocalProject[]
|
|
|
- homedir: string
|
|
|
- selected: HomeProjectSelection
|
|
|
- focusServer: (server: ServerConnection.Any) => void
|
|
|
- selectProject: (server: ServerConnection.Any, directory: string) => void
|
|
|
- openNewSession: (server: ServerConnection.Any, directory: string) => void
|
|
|
- openRecentProject: (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>
|
|
|
- onWheel: (event: WheelEvent) => void
|
|
|
-}) {
|
|
|
- const global = useGlobal()
|
|
|
- const dialog = useDialog()
|
|
|
- const controller = useServerManagementController({ navigateOnAdd: false })
|
|
|
- const [_state, setState, _, ready] = persisted(
|
|
|
- Persist.global("home.servers", ["home.servers.v1"]),
|
|
|
- createStore({ collapsed: {} as Record<string, boolean> }),
|
|
|
- )
|
|
|
- const [state] = createResource(
|
|
|
- () => ready.promise ?? Promise.resolve(),
|
|
|
- (p) => p.then(() => _state),
|
|
|
- { initialValue: _state },
|
|
|
- )
|
|
|
-
|
|
|
- return (
|
|
|
- <aside
|
|
|
- class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
|
|
|
- aria-label={props.language.t("home.projects")}
|
|
|
- onWheel={(event) => {
|
|
|
- if (event.target === event.currentTarget) return
|
|
|
- props.onWheel(event)
|
|
|
- }}
|
|
|
- >
|
|
|
- <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
|
|
- <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
|
|
- <Show
|
|
|
- when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
|
|
|
- >
|
|
|
- <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
|
|
- <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" />}
|
|
|
- disabled={global.servers.health[ServerConnection.key(global.servers.list()[0]!)]?.healthy === false}
|
|
|
- onClick={() => props.chooseProject(global.servers.list()[0]!)}
|
|
|
- aria-label={props.language.t("home.project.add")}
|
|
|
- />
|
|
|
- </TooltipV2>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- <ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
|
|
- <Show
|
|
|
- when={global.servers.list().length > 1}
|
|
|
- fallback={
|
|
|
- <div class="pr-3">
|
|
|
- <Show
|
|
|
- when={props.projects.length > 0}
|
|
|
- fallback={
|
|
|
- <HomeProjectEmpty
|
|
|
- server={global.servers.list()[0]!}
|
|
|
- recentlyClosed={props.recentlyClosed}
|
|
|
- homedir={props.homedir}
|
|
|
- chooseProject={props.chooseProject}
|
|
|
- openRecentProject={props.openRecentProject}
|
|
|
- language={props.language}
|
|
|
- />
|
|
|
- }
|
|
|
- >
|
|
|
- <HomeProjectList {...props} server={global.servers.list()[0]!} />
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- }
|
|
|
- >
|
|
|
- <div class="flex min-w-0 flex-col gap-4 pr-3">
|
|
|
- <For each={global.servers.list()}>
|
|
|
- {(item) => {
|
|
|
- const key = ServerConnection.key(item)
|
|
|
- const healthy = () => !!global.servers.health[key]?.healthy
|
|
|
- const serverCtx = global.ensureServerCtx(item)
|
|
|
- const projects = () => serverCtx.projects.list()
|
|
|
- const hasProjects = () => projects().length > 0
|
|
|
- const collapsed = () => !!state().collapsed[key]
|
|
|
- return (
|
|
|
- <div class="flex min-w-0 flex-col gap-1">
|
|
|
- <HomeServerRow
|
|
|
- server={item}
|
|
|
- selected={props.selected.server === key && !props.selected.directory}
|
|
|
- collapsed={collapsed()}
|
|
|
- health={global.servers.health[key]}
|
|
|
- controller={controller}
|
|
|
- focusServer={props.focusServer}
|
|
|
- chooseProject={props.chooseProject}
|
|
|
- openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
|
|
- toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
|
|
- language={props.language}
|
|
|
- />
|
|
|
- <Show when={healthy() && hasProjects() && !collapsed()}>
|
|
|
- <div class="mx-3 h-px bg-v2-border-border-base" />
|
|
|
- <HomeProjectList {...props} server={item} projects={projects()} />
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )
|
|
|
- }}
|
|
|
- </For>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </ScrollView>
|
|
|
- <HomeUtilityNav
|
|
|
- class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
|
|
- openSettings={props.openSettings}
|
|
|
- openHelp={props.openHelp}
|
|
|
- language={props.language}
|
|
|
- />
|
|
|
- </aside>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeUtilityNav(props: {
|
|
|
- class?: string
|
|
|
- openSettings: () => void
|
|
|
- openHelp: () => void
|
|
|
- language: ReturnType<typeof useLanguage>
|
|
|
-}) {
|
|
|
- return (
|
|
|
- <div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
|
|
- <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>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeServerRow(props: {
|
|
|
- server: ServerConnection.Any
|
|
|
- selected: boolean
|
|
|
- collapsed: boolean
|
|
|
- health: ServerHealth | undefined
|
|
|
- controller: ReturnType<typeof useServerManagementController>
|
|
|
- focusServer: (server: ServerConnection.Any) => void
|
|
|
- chooseProject: (server: ServerConnection.Any) => void
|
|
|
- openEdit: (server: ServerConnection.Http) => void
|
|
|
- toggleCollapsed: () => void
|
|
|
- language: ReturnType<typeof useLanguage>
|
|
|
-}) {
|
|
|
- const global = useGlobal()
|
|
|
- const [state, setState] = createStore({ menuOpen: false })
|
|
|
- const healthy = () => !!props.health?.healthy
|
|
|
- const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
|
|
|
- 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={!healthy()}
|
|
|
- onClick={() => props.focusServer(props.server)}
|
|
|
- >
|
|
|
- <span
|
|
|
- data-action="home-server-collapse"
|
|
|
- class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
|
|
|
- classList={{
|
|
|
- "hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
|
|
- "cursor-default opacity-40": !canToggle(),
|
|
|
- }}
|
|
|
- aria-label={
|
|
|
- props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
|
|
- }
|
|
|
- aria-disabled={!canToggle()}
|
|
|
- aria-expanded={canToggle() ? !props.collapsed : undefined}
|
|
|
- onClick={(event) => {
|
|
|
- event.preventDefault()
|
|
|
- event.stopPropagation()
|
|
|
- if (!canToggle()) return
|
|
|
- props.toggleCollapsed()
|
|
|
- }}
|
|
|
- onPointerDown={(event) => event.preventDefault()}
|
|
|
- >
|
|
|
- <IconV2
|
|
|
- name="chevron-down"
|
|
|
- size="small"
|
|
|
- class="transition-transform duration-150 ease-in-out"
|
|
|
- style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
|
|
- />
|
|
|
- </span>
|
|
|
- <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
|
|
- <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="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 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)}
|
|
|
- />
|
|
|
- <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
|
|
|
- <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")}
|
|
|
- disabled={props.health?.healthy === false}
|
|
|
- onClick={() => props.chooseProject(props.server)}
|
|
|
- />
|
|
|
- </TooltipV2>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-type HomeProjectListProps = {
|
|
|
- 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>
|
|
|
-}
|
|
|
-
|
|
|
-function HomeProjectList(props: HomeProjectListProps) {
|
|
|
- const global = useGlobal()
|
|
|
- let listRef!: HTMLDivElement
|
|
|
- const projects = () => global.ensureServerCtx(props.server).projects
|
|
|
-
|
|
|
- return (
|
|
|
- <DragDropProvider
|
|
|
- sensors={(defaults) => [
|
|
|
- ...defaults.filter((sensor) => sensor !== PointerSensor),
|
|
|
- PointerSensor.configure({
|
|
|
- activationConstraints: (event) =>
|
|
|
- event.pointerType === "touch"
|
|
|
- ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
|
|
|
- : [new PointerActivationConstraints.Distance({ value: 4 })],
|
|
|
- preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
|
|
|
- }),
|
|
|
- ]}
|
|
|
- modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
|
|
|
- plugins={(defaults) => [
|
|
|
- ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
|
|
|
- AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
|
|
|
- Feedback.configure({ dropAnimation: null }),
|
|
|
- ]}
|
|
|
- onDragEnd={(event) => {
|
|
|
- const source = event.operation.source
|
|
|
- if (event.canceled || !isSortable(source)) return
|
|
|
- if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
|
|
|
- if (props.selected.server !== ServerConnection.key(props.server))
|
|
|
- props.selectProject(props.server, source.id.toString())
|
|
|
- }}
|
|
|
- >
|
|
|
- <div class="flex min-w-0 flex-col gap-1" ref={listRef}>
|
|
|
- {/* Keyed on worktree strings: the enriched project objects are
|
|
|
- recreated on every store or sync update, so iterating them directly
|
|
|
- remounts all rows — killing any in-flight drag activation (the
|
|
|
- row's sortable unregisters on unmount) and discarding animations.
|
|
|
- String keys keep row elements alive and move them on reorder. */}
|
|
|
- <For each={props.projects.map((project) => project.worktree)}>
|
|
|
- {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
|
|
|
- </For>
|
|
|
- </div>
|
|
|
- </DragDropProvider>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeProjectSlot(
|
|
|
- props: HomeProjectListProps & {
|
|
|
- worktree: string
|
|
|
- index: () => number
|
|
|
- },
|
|
|
-) {
|
|
|
- const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
|
|
|
-
|
|
|
- return (
|
|
|
- <Show when={project()}>
|
|
|
- {(item) => (
|
|
|
- <HomeProjectRow
|
|
|
- project={item()}
|
|
|
- server={props.server}
|
|
|
- index={props.index}
|
|
|
- serverSelected={props.selected.server === ServerConnection.key(props.server)}
|
|
|
- selected={
|
|
|
- props.selected.server === ServerConnection.key(props.server) && props.selected.directory === props.worktree
|
|
|
- }
|
|
|
- unseenCount={props.unseenCount(props.server, item())}
|
|
|
- selectProject={props.selectProject}
|
|
|
- openNewSession={props.openNewSession}
|
|
|
- editProject={props.editProject}
|
|
|
- closeProject={props.closeProject}
|
|
|
- clearNotifications={props.clearNotifications}
|
|
|
- language={props.language}
|
|
|
- />
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeProjectEmpty(props: {
|
|
|
- server: ServerConnection.Any
|
|
|
- recentlyClosed: LocalProject[]
|
|
|
- homedir: string
|
|
|
- chooseProject: (server: ServerConnection.Any) => void
|
|
|
- openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
|
|
- language: ReturnType<typeof useLanguage>
|
|
|
-}) {
|
|
|
- const global = useGlobal()
|
|
|
- const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
|
|
- return (
|
|
|
- <div class="flex min-w-0 flex-col gap-1">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- data-action="home-add-project-row"
|
|
|
- class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
|
|
- disabled={unreachable()}
|
|
|
- onClick={() => props.chooseProject(props.server)}
|
|
|
- >
|
|
|
- <IconV2 name="folder-add-left" size="small" />
|
|
|
- <span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
|
|
- </button>
|
|
|
- <Show when={props.recentlyClosed.length > 0}>
|
|
|
- <div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
|
|
|
- <div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
|
|
|
- </div>
|
|
|
- <For each={props.recentlyClosed}>
|
|
|
- {(project) => (
|
|
|
- <HomeRecentlyClosedRow
|
|
|
- project={project}
|
|
|
- server={props.server}
|
|
|
- homedir={props.homedir}
|
|
|
- openRecentProject={props.openRecentProject}
|
|
|
- language={props.language}
|
|
|
- />
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeRecentlyClosedRow(props: {
|
|
|
- project: LocalProject
|
|
|
- server: ServerConnection.Any
|
|
|
- homedir: string
|
|
|
- openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
|
|
- language: ReturnType<typeof useLanguage>
|
|
|
-}) {
|
|
|
- const global = useGlobal()
|
|
|
- const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
|
|
- const path = () => {
|
|
|
- const home = props.homedir
|
|
|
- const worktree = props.project.worktree
|
|
|
- if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
|
|
- return worktree
|
|
|
- }
|
|
|
- return (
|
|
|
- <TooltipV2 placement="right" value={path()}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- data-component="home-recently-closed-row"
|
|
|
- class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
|
|
|
- disabled={unreachable()}
|
|
|
- onClick={() => props.openRecentProject(props.server, props.project.worktree)}
|
|
|
- >
|
|
|
- <HomeProjectAvatar project={props.project} outline />
|
|
|
- <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
|
|
- </button>
|
|
|
- </TooltipV2>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeProjectRow(props: {
|
|
|
- project: LocalProject
|
|
|
- server: ServerConnection.Any
|
|
|
- index: () => number
|
|
|
- serverSelected: boolean
|
|
|
- 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 global = useGlobal()
|
|
|
- const platform = usePlatform()
|
|
|
- const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
|
|
- const [state, setState] = createStore({ menuOpen: false })
|
|
|
- const sortable = useSortable({
|
|
|
- get id() {
|
|
|
- return props.project.worktree
|
|
|
- },
|
|
|
- get index() {
|
|
|
- return props.index()
|
|
|
- },
|
|
|
- })
|
|
|
- let pointerDownSelected: boolean | undefined
|
|
|
- const canRevealInFileManager = () =>
|
|
|
- platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
|
|
|
- const fileManagerActionLabel = () =>
|
|
|
- props.language.t(
|
|
|
- fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel,
|
|
|
- )
|
|
|
- const revealInFileManager = () => {
|
|
|
- if (!platform.openPath) return
|
|
|
- platform.openPath(props.project.worktree).catch((err: unknown) =>
|
|
|
- showToast({
|
|
|
- title: props.language.t("common.requestFailed"),
|
|
|
- description: errorMessage(err, props.language.t("common.requestFailed")),
|
|
|
- }),
|
|
|
- )
|
|
|
- }
|
|
|
- return (
|
|
|
- <div
|
|
|
- ref={sortable.ref}
|
|
|
- class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
|
|
|
- classList={{ "z-10": sortable.isDragSource() }}
|
|
|
- >
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- data-component="home-project-row"
|
|
|
- class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
|
|
- classList={{
|
|
|
- "bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
|
|
|
- sortable.isDragSource(),
|
|
|
- }}
|
|
|
- data-selected={props.selected ? "" : undefined}
|
|
|
- aria-current={props.selected ? "page" : undefined}
|
|
|
- disabled={serverUnreachable()}
|
|
|
- onPointerDown={(event) => {
|
|
|
- // Same-server mouse selection happens on pointerdown (like tabs),
|
|
|
- // but only ever selects; selectProject toggles, and deselecting here
|
|
|
- // would fire on every drag before the threshold is met. Cross-server
|
|
|
- // selection waits for click so reordering a remote server's projects
|
|
|
- // does not focus that server and load its session index. Touch is
|
|
|
- // excluded so flick-scrolling the list cannot select rows.
|
|
|
- pointerDownSelected = undefined
|
|
|
- if (event.button !== 0 || event.pointerType === "touch") return
|
|
|
- if (!props.serverSelected) return
|
|
|
- pointerDownSelected = props.selected
|
|
|
- if (!props.selected) props.selectProject(props.server, props.project.worktree)
|
|
|
- }}
|
|
|
- onClick={(event) => {
|
|
|
- // The drag sensor calls preventDefault on post-drag clicks; never
|
|
|
- // toggle selection as part of a reorder.
|
|
|
- if (event.defaultPrevented) return
|
|
|
- // Keyboard activation and touch taps keep the original toggle.
|
|
|
- if (event.detail === 0 || pointerDownSelected === undefined) {
|
|
|
- props.selectProject(props.server, props.project.worktree)
|
|
|
- return
|
|
|
- }
|
|
|
- // Mouse: pointerdown already selected unselected rows; a plain click
|
|
|
- // on an already-selected row toggles it off.
|
|
|
- if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
|
|
|
- pointerDownSelected = undefined
|
|
|
- }}
|
|
|
- >
|
|
|
- <HomeProjectAvatar project={props.project} />
|
|
|
- <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
|
|
- </button>
|
|
|
- <div
|
|
|
- class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
|
|
|
- data-menu={state.menuOpen}
|
|
|
- >
|
|
|
- <MenuV2
|
|
|
- gutter={6}
|
|
|
- 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("dialog.project.edit.title")}
|
|
|
- </MenuV2.Item>
|
|
|
- <Show when={canRevealInFileManager()}>
|
|
|
- <MenuV2.Item onSelect={revealInFileManager}>{fileManagerActionLabel()}</MenuV2.Item>
|
|
|
- </Show>
|
|
|
- <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>
|
|
|
- <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)}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
|
|
|
- const name = createMemo(() => displayName(props.project))
|
|
|
- return (
|
|
|
- <ProjectAvatar
|
|
|
- fallback={name()}
|
|
|
- src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
|
|
- variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
|
|
- />
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeSessionLeading(props: {
|
|
|
- project: LocalProject
|
|
|
- session: Session
|
|
|
- server: ServerConnection.Key
|
|
|
- revealProjectOnHover: 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-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
|
|
- style={{ right: "calc(100% + 4px)" }}
|
|
|
- />
|
|
|
- </Show>
|
|
|
- <SessionTabAvatar
|
|
|
- project={props.project}
|
|
|
- directory={props.session.directory}
|
|
|
- sessionId={props.session.id}
|
|
|
- server={props.server}
|
|
|
- revealProjectOnHover={props.revealProjectOnHover}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeSessionSearch(props: {
|
|
|
- value: string
|
|
|
- placeholder: string
|
|
|
- open: boolean
|
|
|
- loading: boolean
|
|
|
- results: HomeSessionRecord[]
|
|
|
- showProjectName: boolean
|
|
|
- server: ServerConnection.Key
|
|
|
- noResultsLabel: string
|
|
|
- bindFocus: (focus: () => void) => void
|
|
|
- onInput: (value: string) => void
|
|
|
- onFocus: () => void
|
|
|
- onClose: () => void
|
|
|
- onSelect: (session: Session, options?: OpenSessionOptions) => 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="w-full">
|
|
|
- <div ref={root} data-component="home-session-search" class="relative z-30 w-full">
|
|
|
- <Show when={props.open}>
|
|
|
- <div
|
|
|
- data-component="home-session-search-panel"
|
|
|
- class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
|
|
|
- style={{
|
|
|
- top: "-6px",
|
|
|
- left: "-6px",
|
|
|
- width: "calc(100% + 12px)",
|
|
|
- }}
|
|
|
- >
|
|
|
- <div class="flex flex-col pt-9">
|
|
|
- <div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
|
|
|
- <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 pb-2 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 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
|
|
- {language.t("home.sessions.search.sessions")}
|
|
|
- </p>
|
|
|
- <ScrollView class="max-h-80" viewportRef={(el) => (listRef = el)}>
|
|
|
- <div class="flex flex-col gap-px pb-2">
|
|
|
- <For each={props.results}>
|
|
|
- {(record) => (
|
|
|
- <HomeSessionSearchResultRow
|
|
|
- record={record}
|
|
|
- showProjectName={props.showProjectName}
|
|
|
- server={props.server}
|
|
|
- selected={store.active === homeSessionSearchKey(record)}
|
|
|
- onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
|
|
- onSelect={(session, options) => props.onSelect(session, options)}
|
|
|
- />
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </div>
|
|
|
- </ScrollView>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
|
|
|
- <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
|
|
|
- showProjectName: boolean
|
|
|
- server: ServerConnection.Key
|
|
|
- selected: boolean
|
|
|
- onHighlight: () => void
|
|
|
- onSelect: (session: Session, options?: OpenSessionOptions) => void
|
|
|
-}) {
|
|
|
- const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
|
|
- const showProjectName = () => props.showProjectName && props.record.projectName
|
|
|
-
|
|
|
- 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,
|
|
|
- group: !!showProjectName(),
|
|
|
- }}
|
|
|
- onMouseEnter={() => props.onHighlight()}
|
|
|
- onMouseDown={(event) => {
|
|
|
- if (event.button === 1) event.preventDefault()
|
|
|
- }}
|
|
|
- onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
|
|
|
- onAuxClick={(event) => {
|
|
|
- if (!isBackgroundOpen(event)) return
|
|
|
- event.preventDefault()
|
|
|
- props.onSelect(props.record.session, { background: true })
|
|
|
- }}
|
|
|
- >
|
|
|
- <HomeSessionLeading
|
|
|
- project={props.record.project}
|
|
|
- session={props.record.session}
|
|
|
- server={props.server}
|
|
|
- revealProjectOnHover={!!showProjectName()}
|
|
|
- />
|
|
|
- <div class="flex min-w-0 flex-1 items-center gap-1.5">
|
|
|
- <span
|
|
|
- class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
|
|
- >
|
|
|
- {title()}
|
|
|
- </span>
|
|
|
- <Show when={showProjectName()}>
|
|
|
- <span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </button>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeSessionGroupHeader(props: {
|
|
|
- title: string
|
|
|
- titleOpacity: number
|
|
|
- ref: ComponentProps<"div">["ref"]
|
|
|
- elevated?: boolean
|
|
|
-}) {
|
|
|
- return (
|
|
|
- <div
|
|
|
- ref={props.ref}
|
|
|
- class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
|
|
- >
|
|
|
- <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
|
|
- {props.title}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeSessionRow(props: {
|
|
|
- record: HomeSessionRecord
|
|
|
- showProjectName: boolean
|
|
|
- server: ServerConnection.Key
|
|
|
- openSession: (session: Session, options?: OpenSessionOptions) => void
|
|
|
- archiveSession: (session: Session) => Promise<void>
|
|
|
-}) {
|
|
|
- const language = useLanguage()
|
|
|
- const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
|
|
- const showProjectName = () => props.showProjectName && props.record.projectName
|
|
|
-
|
|
|
- return (
|
|
|
- <div
|
|
|
- class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
|
|
- classList={{ group: !!showProjectName() }}
|
|
|
- >
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- data-component="home-session-row"
|
|
|
- class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
|
|
- onMouseDown={(event) => {
|
|
|
- if (event.button === 1) event.preventDefault()
|
|
|
- }}
|
|
|
- onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
|
|
|
- onAuxClick={(event) => {
|
|
|
- if (!isBackgroundOpen(event)) return
|
|
|
- event.preventDefault()
|
|
|
- props.openSession(props.record.session, { background: true })
|
|
|
- }}
|
|
|
- >
|
|
|
- <HomeSessionLeading
|
|
|
- project={props.record.project}
|
|
|
- session={props.record.session}
|
|
|
- server={props.server}
|
|
|
- revealProjectOnHover={!!showProjectName()}
|
|
|
- />
|
|
|
- <span
|
|
|
- class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
|
|
|
- >
|
|
|
- {title()}
|
|
|
- </span>
|
|
|
- <Show when={showProjectName()}>
|
|
|
- <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>
|
|
|
- <Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
|
|
- <div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
|
|
- <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
|
|
- <IconButtonV2
|
|
|
- data-action="home-session-archive"
|
|
|
- variant="ghost-muted"
|
|
|
- size="large"
|
|
|
- icon={<IconV2 name="archive" />}
|
|
|
- aria-label={language.t("common.archive")}
|
|
|
- onClick={(event) => {
|
|
|
- event.preventDefault()
|
|
|
- event.stopPropagation()
|
|
|
- void props.archiveSession(props.record.session)
|
|
|
- }}
|
|
|
- />
|
|
|
- </TooltipV2>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function HomeSessionsEmpty(props: { onNewSession?: () => void }) {
|
|
|
- const language = useLanguage()
|
|
|
- return (
|
|
|
- <div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
|
|
- <div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
|
|
|
- {language.t("home.sessions.empty")}
|
|
|
- </div>
|
|
|
- <p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
|
|
|
- {language.t("home.sessions.empty.description")}
|
|
|
- </p>
|
|
|
- <Show when={props.onNewSession}>
|
|
|
- {(onNewSession) => (
|
|
|
- <ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
|
|
|
- {language.t("command.session.new")}
|
|
|
- </ButtonV2>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-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)
|
|
|
-}
|
|
|
-
|
|
|
-export 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 serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
|
|
|
- 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.ensureServerCtx(server)
|
|
|
- serverCtx.projects.open(directory)
|
|
|
- serverCtx.projects.touch(directory)
|
|
|
- navigate(`/${base64Encode(directory)}`)
|
|
|
- }
|
|
|
-
|
|
|
- function chooseProject() {
|
|
|
- if (serverUnreachable()) return
|
|
|
- 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"
|
|
|
- disabled={serverUnreachable()}
|
|
|
- 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" disabled={serverUnreachable()} 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" disabled={serverUnreachable()} onClick={chooseProject}>
|
|
|
- {language.t("command.project.open")}
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- </div>
|
|
|
- )
|
|
|
-}
|