refactor(app): split home view controllers
This commit is contained in:
parent
adba484df4
commit
93e186b5df
5 changed files with 1218 additions and 1100 deletions
|
|
@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{
|
|||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const key = ServerConnection.key(props.server)
|
||||
const builtin = ServerConnection.builtin(props.server)
|
||||
const isDefault = () => props.controller.defaultKey() === key
|
||||
return (
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||
return {
|
||||
more: language.t("common.moreOptions"),
|
||||
server: language.t("settings.section.server"),
|
||||
edit: language.t("dialog.server.menu.edit"),
|
||||
default: language.t("dialog.server.menu.default"),
|
||||
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
}
|
||||
}
|
||||
|
||||
export const ServerRowMenuView: Component<{
|
||||
server: ServerConnection.Any
|
||||
labels: ReturnType<typeof serverMenuLabels>
|
||||
canDefault: boolean
|
||||
isDefault: boolean
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
onSetDefault: () => void
|
||||
onRemoveDefault: () => void
|
||||
onRemove: () => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}> = (props) => {
|
||||
const builtin = () => ServerConnection.builtin(props.server)
|
||||
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
|
||||
return (
|
||||
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<MenuV2.Trigger
|
||||
|
|
@ -25,31 +63,30 @@ export const ServerRowMenu: Component<{
|
|||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<IconV2 name="outline-dots" />}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-label={props.labels.more}
|
||||
/>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
|
||||
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item
|
||||
disabled={builtin || props.server.type !== "http"}
|
||||
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
|
||||
disabled={builtin() || !httpServer()}
|
||||
onSelect={() => {
|
||||
const server = httpServer()
|
||||
if (server) props.onEdit(server)
|
||||
}}
|
||||
>
|
||||
{language.t("dialog.server.menu.edit")}
|
||||
{props.labels.edit}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.controller.canDefault() && !isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && !props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.controller.canDefault() && isDefault()}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
<Show when={props.canDefault && props.isDefault}>
|
||||
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
|
||||
{props.labels.delete}
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
</MenuV2.Content>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
265
packages/app/src/pages/home/home-projects-controller.tsx
Normal file
265
packages/app/src/pages/home/home-projects-controller.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { type HomeProjectSelection, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import {
|
||||
closeHomeProject,
|
||||
errorMessage,
|
||||
homeProjectDirectories,
|
||||
toggleHomeProjectSelection,
|
||||
} from "@/pages/layout/helpers"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function createHomeProjectsController() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const tabs = useTabs()
|
||||
const notification = useNotification()
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||
const [menu, setMenu] = createStore({ open: undefined as string | undefined })
|
||||
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(),
|
||||
(promise) => promise.then(() => _state),
|
||||
{ initialValue: _state },
|
||||
)
|
||||
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 undefined
|
||||
return global.ensureServerCtx(conn)
|
||||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
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],
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const id = menu.open
|
||||
if (!id) return
|
||||
const connections = global.servers.list()
|
||||
const valid = connections.some((conn) => {
|
||||
if (serverMenuID(conn) === id) return true
|
||||
if (
|
||||
connections.length > 1 &&
|
||||
(global.servers.health[ServerConnection.key(conn)]?.healthy !== true || collapsed(conn))
|
||||
)
|
||||
return false
|
||||
const list = connections.length === 1 ? projects() : projectsForServer(conn)
|
||||
return list.some((project) => projectMenuID(conn, project.worktree) === id)
|
||||
})
|
||||
if (!valid) setMenu("open", undefined)
|
||||
})
|
||||
|
||||
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) })
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
}
|
||||
|
||||
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((item) => ctx.projects.open(item))
|
||||
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)
|
||||
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
|
||||
}
|
||||
|
||||
function editProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
|
||||
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
|
||||
})
|
||||
}
|
||||
|
||||
function directories(project: LocalProject) {
|
||||
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||
}
|
||||
|
||||
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 chooseProject(conn: ServerConnection.Any) {
|
||||
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: (result) => addProjects(conn, homeProjectDirectories(result)),
|
||||
})
|
||||
}
|
||||
|
||||
function closeProject(conn: ServerConnection.Any, directory: string) {
|
||||
const next = closeHomeProject(
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
}
|
||||
|
||||
function moveProject(conn: ServerConnection.Any, worktree: string, index: number) {
|
||||
global.ensureServerCtx(conn).projects.move(worktree, index)
|
||||
}
|
||||
|
||||
function revealProject(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (!platform.openPath || !canRevealProject(conn)) return
|
||||
platform.openPath(project.worktree).catch((cause: unknown) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function canRevealProject(conn: ServerConnection.Any) {
|
||||
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
|
||||
}
|
||||
|
||||
function projectsForServer(conn: ServerConnection.Any) {
|
||||
return global.ensureServerCtx(conn).projects.list()
|
||||
}
|
||||
|
||||
function collapsed(conn: ServerConnection.Any) {
|
||||
return state().collapsed[ServerConnection.key(conn)] ?? false
|
||||
}
|
||||
|
||||
function serverMenuID(conn: ServerConnection.Any) {
|
||||
return `server:${ServerConnection.key(conn)}`
|
||||
}
|
||||
|
||||
function projectMenuID(conn: ServerConnection.Any, directory: string) {
|
||||
return `project:${ServerConnection.key(conn)}:${directory}`
|
||||
}
|
||||
|
||||
return {
|
||||
language,
|
||||
selection,
|
||||
focusedServer,
|
||||
focusedServerCtx,
|
||||
focusedSync,
|
||||
projects,
|
||||
recentlyClosed,
|
||||
homedir,
|
||||
selectedProject,
|
||||
newSessionProject,
|
||||
servers: global.servers.list,
|
||||
serverHealth: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
|
||||
projectsForServer,
|
||||
collapsed,
|
||||
toggleCollapsed: (conn: ServerConnection.Any) => {
|
||||
const key = ServerConnection.key(conn)
|
||||
setState("collapsed", key, !state().collapsed[key])
|
||||
},
|
||||
menuOpen: (id: string) => menu.open === id,
|
||||
setMenuOpen: (id: string, open: boolean) => setMenu("open", open ? id : undefined),
|
||||
serverMenuID,
|
||||
projectMenuID,
|
||||
canDefaultServer: serverManagement.canDefault,
|
||||
isDefaultServer: (conn: ServerConnection.Any) => serverManagement.defaultKey() === ServerConnection.key(conn),
|
||||
setDefaultServer: (conn: ServerConnection.Any | undefined) =>
|
||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||
removeServer: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||
openEditServer: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
focusServer,
|
||||
selectProject,
|
||||
addProjects,
|
||||
openNewSession,
|
||||
openProjectNewSession,
|
||||
editProject,
|
||||
unseenCount,
|
||||
clearNotifications,
|
||||
chooseProject,
|
||||
closeProject,
|
||||
moveProject,
|
||||
canRevealProject,
|
||||
revealProject,
|
||||
fileManagerActionLabel: () =>
|
||||
language.t(fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel),
|
||||
openSettings,
|
||||
openHelp: () => platform.openLink("https://opencode.ai/desktop-feedback"),
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
|
||||
577
packages/app/src/pages/home/home-sessions-controller.tsx
Normal file
577
packages/app/src/pages/home/home-sessions-controller.tsx
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
type Accessor,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRoot,
|
||||
createSignal,
|
||||
type JSX,
|
||||
on,
|
||||
onCleanup,
|
||||
startTransition,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeProjectsController } from "./home-projects-controller"
|
||||
|
||||
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
|
||||
|
||||
export type HomeSessionRecord = {
|
||||
session: Session
|
||||
project: LocalProject
|
||||
projectName: string
|
||||
}
|
||||
|
||||
export type HomeSessionGroup = {
|
||||
id: "today" | "yesterday" | "older"
|
||||
title: string
|
||||
sessions: HomeSessionRecord[]
|
||||
}
|
||||
|
||||
export type OpenSessionOptions = { background?: boolean }
|
||||
|
||||
export function createHomeSessionsController(projects: HomeProjectsController) {
|
||||
const tabs = useTabs()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const marked = useMarked()
|
||||
const [state, setState] = createStore({ search: "", searchFocused: false, active: "" })
|
||||
const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
|
||||
const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let searchRoot: HTMLDivElement | undefined
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let searchList: HTMLDivElement | undefined
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const projectDirectories = createMemo(() => {
|
||||
const project = projects.selectedProject()
|
||||
if (!project) return projects.projects().flatMap(directories)
|
||||
return directories(project)
|
||||
})
|
||||
const projectByID = createMemo(
|
||||
() => new Map(projects.projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
)
|
||||
const homeSessions = () => projects.focusedSync().homeSessions
|
||||
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: !!projects.focusedServerCtx(),
|
||||
queryFn: async ({ signal }) => {
|
||||
const ctx = projects.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 indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects: projects.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 searchPlaceholder = createMemo(() => {
|
||||
const project = projects.selectedProject()
|
||||
if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
|
||||
if (projects.servers().length > 1) {
|
||||
const conn = projects.focusedServer()
|
||||
if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
|
||||
}
|
||||
return language.t("home.sessions.search.placeholder")
|
||||
})
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const header = createHomeSessionHeaderController(groups)
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = projects.focusedServerCtx()
|
||||
const conn = projects.focusedServer()
|
||||
if (!ctx || !conn) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync.session
|
||||
.sync(record.session.id)
|
||||
.then(() =>
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => syncActive(searchResults()))
|
||||
createEffect(
|
||||
on(
|
||||
() => state.search,
|
||||
() => syncActive(searchResults()),
|
||||
),
|
||||
)
|
||||
onCleanup(
|
||||
makeEventListener(document, "pointerdown", (event) => {
|
||||
if (!searchOpen()) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node) || searchRoot?.contains(target)) return
|
||||
closeSearch()
|
||||
}),
|
||||
)
|
||||
|
||||
command.register("home", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const conn = projects.focusedServer()
|
||||
if (!conn) return
|
||||
const ctx = projects.focusedServerCtx()
|
||||
if (!ctx) return
|
||||
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: focusSearch,
|
||||
},
|
||||
])
|
||||
|
||||
function syncActive(results: HomeSessionRecord[]) {
|
||||
if (results.length === 0) {
|
||||
setState("active", "")
|
||||
return
|
||||
}
|
||||
if (!results.some((record) => homeSessionSearchKey(record) === state.active)) {
|
||||
setState("active", homeSessionSearchKey(results[0]))
|
||||
}
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
searchInput?.focus()
|
||||
setState("searchFocused", true)
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
setState("search", "")
|
||||
setState("searchFocused", false)
|
||||
}
|
||||
|
||||
function moveActive(delta: number) {
|
||||
const results = searchResults()
|
||||
if (results.length === 0) return
|
||||
const index = results.findIndex((record) => homeSessionSearchKey(record) === state.active)
|
||||
const next = ((index === -1 ? 0 : index) + delta + results.length) % results.length
|
||||
setState("active", homeSessionSearchKey(results[next]))
|
||||
searchList?.querySelector<HTMLElement>(`[data-key="${state.active}"]`)?.scrollIntoView({ block: "nearest" })
|
||||
}
|
||||
|
||||
function selectSearchSession(record: HomeSessionRecord, options?: OpenSessionOptions) {
|
||||
openSession(record.session, options)
|
||||
if (!options?.background) closeSearch()
|
||||
}
|
||||
|
||||
function openSession(session: Session, options?: OpenSessionOptions) {
|
||||
const directoryKey = pathKey(session.directory)
|
||||
const project =
|
||||
projects
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
) ?? projectForSession(session, projects.projects(), projectByID())
|
||||
const conn = projects.focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = projects.focusedServerCtx()
|
||||
if (!ctx) return
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
void startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
tabs.select(tab)
|
||||
})
|
||||
}
|
||||
|
||||
async function archiveSession(session: Session) {
|
||||
const conn = projects.focusedServer()
|
||||
const ctx = projects.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, (item) => item.id)
|
||||
if (match.found) draft.session.splice(match.index, 1)
|
||||
}),
|
||||
),
|
||||
onError: (cause) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function setViewport(element: HTMLDivElement) {
|
||||
viewport = element
|
||||
header.setViewport(element)
|
||||
}
|
||||
|
||||
function containWheel(event: WheelEvent) {
|
||||
if (!viewport) return
|
||||
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()
|
||||
}
|
||||
|
||||
return {
|
||||
language,
|
||||
records,
|
||||
groups,
|
||||
loading: () => sessionLoad.isLoading,
|
||||
showProjectName: () => !projects.selectedProject(),
|
||||
server: () => projects.selection().server,
|
||||
canCreateSession: () => !!projects.newSessionProject(),
|
||||
createSession: projects.openNewSession,
|
||||
openSession,
|
||||
archiveSession,
|
||||
hasOpenTab: (record: HomeSessionRecord) =>
|
||||
sessionHasOpenTab(tabs.store, projects.selection().server, record.session),
|
||||
header,
|
||||
scroll: {
|
||||
thumbTrack,
|
||||
hoverTarget,
|
||||
setThumbTrack,
|
||||
setHoverTarget,
|
||||
setViewport,
|
||||
update: (scrollTop: number) => header.update(scrollTop),
|
||||
containWheel,
|
||||
containOuterWheel: (event: WheelEvent) => {
|
||||
if (!viewport) return
|
||||
if (event.target instanceof Node && viewport.contains(event.target)) return
|
||||
containWheel(event)
|
||||
},
|
||||
},
|
||||
search: {
|
||||
value: () => state.search,
|
||||
query: search,
|
||||
placeholder: searchPlaceholder,
|
||||
open: searchOpen,
|
||||
loading: () => sessionLoad.isLoading,
|
||||
results: searchResults,
|
||||
active: () => state.active,
|
||||
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: search() }),
|
||||
setRoot: (element: HTMLDivElement) => (searchRoot = element),
|
||||
setInput: (element: HTMLInputElement) => (searchInput = element),
|
||||
setList: (element: HTMLDivElement) => (searchList = element),
|
||||
input: (value: string) => setState("search", value),
|
||||
focus: focusSearch,
|
||||
close: closeSearch,
|
||||
highlight: (record: HomeSessionRecord) => setState("active", homeSessionSearchKey(record)),
|
||||
move: moveActive,
|
||||
select: selectSearchSession,
|
||||
selectActive: () => {
|
||||
const record = searchResults().find((item) => homeSessionSearchKey(item) === state.active)
|
||||
if (record) selectSearchSession(record)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function directories(project: LocalProject) {
|
||||
return [project.worktree, ...(project.sandboxes ?? [])]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
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 createHomeSessionHeaderController(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(element: HTMLDivElement) {
|
||||
viewport = element
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setContent(element: HTMLDivElement) {
|
||||
content = element
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setHeader(id: HomeSessionGroup["id"], element: HTMLDivElement) {
|
||||
headerRefs.set(id, element)
|
||||
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((element) => element !== undefined)
|
||||
if (header && typeof getComputedStyle === "function") {
|
||||
const top = Number.parseFloat(getComputedStyle(header).top)
|
||||
if (Number.isFinite(top)) stickyTop = top
|
||||
}
|
||||
groups().forEach((group) => {
|
||||
const element = headerRefs.get(group.id)
|
||||
if (element) headerOffsets.set(group.id, element.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 bindResizeObserver() {
|
||||
resizeObserver?.disconnect()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
resizeObserver = new ResizeObserver(queuePositionUpdate)
|
||||
if (viewport) resizeObserver.observe(viewport)
|
||||
if (content) resizeObserver.observe(content)
|
||||
}
|
||||
|
||||
return {
|
||||
setViewport,
|
||||
setContent,
|
||||
setHeader,
|
||||
update,
|
||||
titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
|
||||
}
|
||||
}
|
||||
|
||||
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
|
||||
|
||||
export function HomeSessionStatusController(props: {
|
||||
controller: HomeSessionsController
|
||||
record: HomeSessionRecord
|
||||
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
|
||||
}) {
|
||||
const avatar = useSessionTabAvatarState(
|
||||
props.controller.server,
|
||||
() => props.record.session.directory,
|
||||
() => props.record.session.id,
|
||||
)
|
||||
return props.render({
|
||||
unread: avatar.unread,
|
||||
loading: avatar.loading,
|
||||
open: () => props.controller.hasOpenTab(props.record),
|
||||
})
|
||||
}
|
||||
|
|
@ -19,16 +19,34 @@ export function SessionTabAvatar(props: {
|
|||
() => props.directory,
|
||||
() => props.sessionId,
|
||||
)
|
||||
return (
|
||||
<SessionTabAvatarView
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
unread={state.unread()}
|
||||
loading={state.loading()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTabAvatarView(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
revealProjectOnHover?: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
unread={state.unread()}
|
||||
unread={props.unread}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||
<Show when={props.loading} fallback={projectAvatar()}>
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2
|
||||
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue