refactor(app): detach home views from controllers

This commit is contained in:
Brendan Allan 2026-07-24 12:02:42 +08:00
commit b751e26565
No known key found for this signature in database
GPG key ID: 41E835AEA046A32E
4 changed files with 360 additions and 230 deletions

View file

@ -18,12 +18,82 @@ export function NewHome() {
onWheel={sessions.scroll.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">
<HomeProjectsView controller={projects} onWheel={sessions.scroll.containWheel} />
<HomeSessionsView controller={sessions} />
<HomeProjectsView
language={projects.language}
servers={projects.servers}
projects={projects.projects}
recentlyClosed={projects.recentlyClosed}
selection={projects.selection}
homedir={projects.homedir}
serverHealth={projects.serverHealth}
projectsForServer={projects.projectsForServer}
collapsed={projects.collapsed}
menuOpen={projects.menuOpen}
canDefaultServer={projects.canDefaultServer}
isDefaultServer={projects.isDefaultServer}
canRevealProject={projects.canRevealProject}
fileManagerActionLabel={projects.fileManagerActionLabel}
unseenCount={projects.unseenCount}
serverMenuID={projects.serverMenuID}
projectMenuID={projects.projectMenuID}
onWheel={sessions.scroll.containWheel}
onChooseProject={projects.chooseProject}
onFocusServer={projects.focusServer}
onToggleCollapsed={projects.toggleCollapsed}
onEditServer={projects.openEditServer}
onSetDefaultServer={projects.setDefaultServer}
onRemoveServer={projects.removeServer}
onSetMenuOpen={projects.setMenuOpen}
onMoveProject={projects.moveProject}
onSelectProject={projects.selectProject}
onAddProjects={projects.addProjects}
onOpenProjectNewSession={projects.openProjectNewSession}
onEditProject={projects.editProject}
onRevealProject={projects.revealProject}
onClearNotifications={projects.clearNotifications}
onCloseProject={projects.closeProject}
onOpenSettings={projects.openSettings}
onOpenHelp={projects.openHelp}
/>
<HomeSessionsView
language={sessions.language}
groups={sessions.groups}
loading={sessions.loading}
showProjectName={sessions.showProjectName}
server={sessions.server}
canCreateSession={sessions.canCreateSession}
searchValue={sessions.search.value}
searchPlaceholder={sessions.search.placeholder}
searchOpen={sessions.search.open}
searchLoading={sessions.search.loading}
searchResults={sessions.search.results}
searchActive={sessions.search.active}
searchNoResultsLabel={sessions.search.noResultsLabel}
titleOpacity={sessions.header.titleOpacity}
isOpenTab={sessions.hasOpenTab}
onCreateSession={sessions.createSession}
onOpenSession={sessions.openSession}
onArchiveSession={sessions.archiveSession}
onSetHoverTarget={sessions.scroll.setHoverTarget}
onSetThumbTrack={sessions.scroll.setThumbTrack}
onSetContent={sessions.header.setContent}
onSetHeader={sessions.header.setHeader}
onWheel={sessions.scroll.containWheel}
onSetSearchRoot={sessions.search.setRoot}
onSetSearchInput={sessions.search.setInput}
onSetSearchList={sessions.search.setList}
onSearchFocus={sessions.search.focus}
onSearchInput={sessions.search.input}
onSearchClose={sessions.search.close}
onSearchMove={sessions.search.move}
onSearchSelectActive={sessions.search.selectActive}
onSearchHighlight={sessions.search.highlight}
onSearchSelect={sessions.search.select}
/>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={projects.openSettings}
openHelp={projects.openHelp}
onOpenSettings={projects.openSettings}
onOpenHelp={projects.openHelp}
language={projects.language}
/>
</div>

View file

@ -1,4 +1,4 @@
import { createMemo, For, Show } from "solid-js"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
@ -10,98 +10,119 @@ 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, type LocalProject } from "@/context/layout"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { type HomeProjectsController } from "./home-projects-controller"
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_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)]`
export function HomeProjectsView(props: { controller: HomeProjectsController; onWheel: (event: WheelEvent) => void }) {
export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage>
servers: Accessor<ServerConnection.Any[]>
projects: Accessor<LocalProject[]>
recentlyClosed: Accessor<LocalProject[]>
selection: Accessor<HomeProjectSelection>
homedir: Accessor<string>
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
menuOpen: (id: string) => boolean
canDefaultServer: Accessor<boolean>
isDefaultServer: (server: ServerConnection.Any) => boolean
canRevealProject: (server: ServerConnection.Any) => boolean
fileManagerActionLabel: Accessor<string>
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
serverMenuID: (server: ServerConnection.Any) => string
projectMenuID: (server: ServerConnection.Any, directory: string) => string
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
onRemoveServer: (server: ServerConnection.Any) => void
onSetMenuOpen: (id: string, open: boolean) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
onCloseProject: (server: ServerConnection.Any, directory: string) => void
onOpenSettings: () => void
onOpenHelp: () => void
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
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.controller.language.t("home.projects")}
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.controller.language.t("home.projects")}</div>
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show
when={
props.controller.servers().length === 1 &&
!(props.controller.projects().length === 0 && props.controller.recentlyClosed().length > 0)
}
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
>
<TooltipV2 placement="bottom" value={props.controller.language.t("home.project.add")}>
<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={props.controller.serverHealth(props.controller.servers()[0])?.healthy === false}
onClick={() => props.controller.chooseProject(props.controller.servers()[0])}
aria-label={props.controller.language.t("home.project.add")}
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers()[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={props.controller.servers().length > 1}
when={props.servers().length > 1}
fallback={
<div class="pr-3">
<Show
when={props.controller.projects().length > 0}
fallback={
<HomeProjectEmpty
controller={props.controller}
server={props.controller.servers()[0]}
recentlyClosed={props.controller.recentlyClosed()}
/>
}
when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
>
<HomeProjectList
controller={props.controller}
server={props.controller.servers()[0]}
projects={props.controller.projects()}
/>
<HomeProjectList {...props} server={props.servers()[0]} items={props.projects()} />
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={props.controller.servers()}>
<For each={props.servers()}>
{(item) => {
const projects = () => props.controller.projectsForServer(item)
const healthy = () => !!props.controller.serverHealth(item)?.healthy
const projects = () => props.projectsForServer(item)
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.controller.collapsed(item)
const collapsed = () => props.collapsed(item)
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
server={item}
controller={props.controller}
selected={
props.controller.selection().server === ServerConnection.key(item) &&
!props.controller.selection().directory
}
{...props}
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
collapsed={collapsed()}
health={props.controller.serverHealth(item)}
health={props.serverHealth(item)}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList controller={props.controller} server={item} projects={projects()} />
<HomeProjectList {...props} server={item} items={projects()} />
</Show>
</div>
)
@ -112,9 +133,9 @@ export function HomeProjectsView(props: { controller: HomeProjectsController; on
</ScrollView>
<HomeUtilityNav
class="mb-8 mt-4 hidden shrink-0 lg:flex"
openSettings={props.controller.openSettings}
openHelp={props.controller.openHelp}
language={props.controller.language}
onOpenSettings={props.onOpenSettings}
onOpenHelp={props.onOpenHelp}
language={props.language}
/>
</aside>
)
@ -122,8 +143,8 @@ export function HomeProjectsView(props: { controller: HomeProjectsController; on
export function HomeUtilityNav(props: {
class?: string
openSettings: () => void
openHelp: () => void
onOpenSettings: () => void
onOpenHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
@ -131,7 +152,7 @@ export function HomeUtilityNav(props: {
<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}
onClick={props.onOpenSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
@ -139,7 +160,7 @@ export function HomeUtilityNav(props: {
<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}
onClick={props.onOpenHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
@ -149,15 +170,27 @@ export function HomeUtilityNav(props: {
}
function HomeServerRow(props: {
controller: HomeProjectsController
language: HomeProjectsViewProps["language"]
projectsForServer: HomeProjectsViewProps["projectsForServer"]
menuOpen: HomeProjectsViewProps["menuOpen"]
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
isDefaultServer: HomeProjectsViewProps["isDefaultServer"]
serverMenuID: HomeProjectsViewProps["serverMenuID"]
onFocusServer: HomeProjectsViewProps["onFocusServer"]
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetMenuOpen: HomeProjectsViewProps["onSetMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
selected: boolean
collapsed: boolean
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && props.controller.projectsForServer(props.server).length > 0
const menuID = () => props.controller.serverMenuID(props.server)
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const menuID = () => props.serverMenuID(props.server)
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
@ -165,7 +198,7 @@ function HomeServerRow(props: {
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.controller.focusServer(props.server)}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
@ -175,9 +208,7 @@ function HomeServerRow(props: {
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed
? props.controller.language.t("home.server.expand")
: props.controller.language.t("home.server.collapse")
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
@ -185,7 +216,7 @@ function HomeServerRow(props: {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.controller.toggleCollapsed(props.server)
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
@ -212,33 +243,29 @@ function HomeServerRow(props: {
</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={props.controller.menuOpen(menuID())}
data-menu={props.menuOpen(menuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.controller.language)}
canDefault={props.controller.canDefaultServer()}
isDefault={props.controller.isDefaultServer(props.server)}
onEdit={props.controller.openEditServer}
onSetDefault={() => props.controller.setDefaultServer(props.server)}
onRemoveDefault={() => props.controller.setDefaultServer(undefined)}
onRemove={() => props.controller.removeServer(props.server)}
open={props.controller.menuOpen(menuID())}
onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.isDefaultServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
/>
<TooltipV2
class="flex shrink-0 items-center"
placement="bottom"
value={props.controller.language.t("home.project.add")}
>
<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.controller.language.t("home.project.add")}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.controller.chooseProject(props.server)}
onClick={() => props.onChooseProject(props.server)}
/>
</TooltipV2>
</div>
@ -246,10 +273,9 @@ function HomeServerRow(props: {
)
}
type HomeProjectListProps = {
controller: HomeProjectsController
type HomeProjectListProps = HomeProjectsViewProps & {
server: ServerConnection.Any
projects: LocalProject[]
items: LocalProject[]
}
function HomeProjectList(props: HomeProjectListProps) {
@ -276,10 +302,9 @@ function HomeProjectList(props: HomeProjectListProps) {
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index)
props.controller.moveProject(props.server, source.id.toString(), source.index)
if (props.controller.selection().server !== ServerConnection.key(props.server))
props.controller.selectProject(props.server, source.id.toString())
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
if (props.selection().server !== ServerConnection.key(props.server))
props.onSelectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
@ -288,7 +313,7 @@ function HomeProjectList(props: HomeProjectListProps) {
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)}>
<For each={props.items.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
@ -302,34 +327,35 @@ function HomeProjectSlot(
index: () => number
},
) {
const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
controller={props.controller}
{...props}
project={item()}
server={props.server}
index={props.index}
serverSelected={props.controller.selection().server === ServerConnection.key(props.server)}
serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={
props.controller.selection().server === ServerConnection.key(props.server) &&
props.controller.selection().directory === props.worktree
props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
}
unseenCount={props.controller.unseenCount(props.server, item())}
unseen={props.unseenCount(props.server, item())}
/>
)}
</Show>
)
}
function HomeProjectEmpty(props: {
controller: HomeProjectsController
server: ServerConnection.Any
recentlyClosed: LocalProject[]
}) {
const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
function HomeProjectEmpty(
props: HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<button
@ -337,33 +363,32 @@ function HomeProjectEmpty(props: {
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.controller.chooseProject(props.server)}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.controller.language.t("home.project.add")}</span>
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</button>
<Show when={props.recentlyClosed.length > 0}>
<Show when={props.items.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.controller.language.t("home.recentlyClosed")}
</div>
<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} controller={props.controller} server={props.server} />}
<For each={props.items}>
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(props: {
controller: HomeProjectsController
project: LocalProject
server: ServerConnection.Any
}) {
const unreachable = () => props.controller.serverHealth(props.server)?.healthy === false
function HomeRecentlyClosedRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => {
const home = props.controller.homedir()
const home = props.homedir()
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
@ -375,7 +400,7 @@ function HomeRecentlyClosedRow(props: {
data-component="home-recently-closed-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.controller.addProjects(props.server, [props.project.worktree])}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@ -384,16 +409,17 @@ function HomeRecentlyClosedRow(props: {
)
}
function HomeProjectRow(props: {
controller: HomeProjectsController
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseenCount: number
}) {
const serverUnreachable = () => props.controller.serverHealth(props.server)?.healthy === false
function HomeProjectRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseen: number
},
) {
const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
const sortable = useSortable({
get id() {
return props.project.worktree
@ -403,7 +429,7 @@ function HomeProjectRow(props: {
},
})
let pointerDownSelected: boolean | undefined
const menuID = () => props.controller.projectMenuID(props.server, props.project.worktree)
const menuID = () => props.projectMenuID(props.server, props.project.worktree)
return (
<div
ref={sortable.ref}
@ -432,7 +458,7 @@ function HomeProjectRow(props: {
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.controller.selectProject(props.server, props.project.worktree)
if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
@ -440,12 +466,12 @@ function HomeProjectRow(props: {
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.controller.selectProject(props.server, props.project.worktree)
props.onSelectProject(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.controller.selectProject(props.server, props.project.worktree)
if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
>
@ -454,14 +480,14 @@ function HomeProjectRow(props: {
</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={props.controller.menuOpen(menuID())}
data-menu={props.menuOpen(menuID())}
>
<MenuV2
gutter={6}
modal={false}
placement="bottom-end"
open={props.controller.menuOpen(menuID())}
onOpenChange={(open) => props.controller.setMenuOpen(menuID(), open)}
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
>
<MenuV2.Trigger
as={IconButtonV2}
@ -469,32 +495,30 @@ function HomeProjectRow(props: {
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.controller.language.t("common.moreOptions")}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item
onSelect={() => props.controller.openProjectNewSession(props.server, props.project.worktree)}
>
{props.controller.language.t("command.session.new")}
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.controller.editProject(props.server, props.project)}>
{props.controller.language.t("dialog.project.edit.title")}
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.controller.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.controller.revealProject(props.server, props.project)}>
{props.controller.fileManagerActionLabel()}
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.fileManagerActionLabel()}
</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={props.unseenCount === 0}
onSelect={() => props.controller.clearNotifications(props.server, props.project)}
disabled={props.unseen === 0}
onSelect={() => props.onClearNotifications(props.server, props.project)}
>
{props.controller.language.t("sidebar.project.clearNotifications")}
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.controller.closeProject(props.server, props.project.worktree)}>
{props.controller.language.t("common.close")}
<MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
@ -504,8 +528,8 @@ function HomeProjectRow(props: {
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.controller.language.t("command.session.new")}
onClick={() => props.controller.openProjectNewSession(props.server, props.project.worktree)}
aria-label={props.language.t("command.session.new")}
onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
/>
</div>
</div>

View file

@ -560,18 +560,19 @@ function createHomeSessionHeaderController(groups: () => HomeSessionGroup[]) {
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: {
controller: HomeSessionsController
server: Accessor<ServerConnection.Key>
record: HomeSessionRecord
isOpenTab: (record: HomeSessionRecord) => boolean
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
}) {
const avatar = useSessionTabAvatarState(
props.controller.server,
props.server,
() => props.record.session.directory,
() => props.record.session.id,
)
return props.render({
unread: avatar.unread,
loading: avatar.loading,
open: () => props.controller.hasOpenTab(props.record),
open: () => props.isOpenTab(props.record),
})
}

View file

@ -1,4 +1,5 @@
import { type ComponentProps, createMemo, For, Show } from "solid-js"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@ -6,14 +7,16 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
homeSessionSearchKey,
type HomeSessionGroup,
type HomeSessionRecord,
type HomeSessionsController,
type OpenSessionOptions,
} from "./home-sessions-controller"
const SHOW_HOME_SESSION_ARCHIVE = false
@ -43,19 +46,52 @@ function isBackgroundOpen(event: MouseEvent) {
})
}
export function HomeSessionsView(props: { controller: HomeSessionsController }) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
loading: Accessor<boolean>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
searchLoading: Accessor<boolean>
searchResults: Accessor<HomeSessionRecord[]>
searchActive: Accessor<string>
searchNoResultsLabel: Accessor<string>
titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
onArchiveSession: (session: Session) => Promise<void>
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
onWheel: (event: WheelEvent) => void
onSetSearchRoot: (element: HTMLDivElement) => void
onSetSearchInput: (element: HTMLInputElement) => void
onSetSearchList: (element: HTMLDivElement) => void
onSearchFocus: () => void
onSearchInput: (value: string) => void
onSearchClose: () => void
onSearchMove: (delta: number) => void
onSearchSelectActive: () => void
onSearchHighlight: (record: HomeSessionRecord) => void
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
}
export function HomeSessionsView(props: HomeSessionsViewProps) {
return (
<section
ref={props.controller.scroll.setHoverTarget}
ref={props.onSetHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={props.controller.language.t("sidebar.project.recentSessions")}
aria-label={props.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={props.controller.scroll.containWheel}
>
<HomeSessionSearch controller={props.controller} />
<Show when={props.controller.groups().length > 0 && props.controller.canCreateSession()}>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
@ -63,54 +99,52 @@ export function HomeSessionsView(props: { controller: HomeSessionsController })
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.controller.createSession}
onClick={props.onCreateSession}
>
{props.controller.language.t("command.session.new")}
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={props.controller.scroll.setThumbTrack}
ref={props.onSetThumbTrack}
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={!props.controller.loading()}
when={!props.loading()}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.controller.language.t("common.loading")} />
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
>
<Show
when={props.controller.groups().length > 0}
when={props.groups().length > 0}
fallback={
<HomeSessionsEmpty
onNewSession={props.controller.canCreateSession() ? props.controller.createSession : undefined}
language={props.controller.language}
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
language={props.language}
/>
}
>
<div ref={props.controller.header.setContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.controller.groups()}>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={props.controller.header.titleOpacity(group.id)}
ref={(element) => props.controller.header.setHeader(group.id, element)}
titleOpacity={props.titleOpacity(group.id)}
onSetRef={(element) => props.onSetHeader(group.id, element)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.controller.groups().length - 1 ? "" : "mb-6"}`}
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => <HomeSessionRow controller={props.controller} record={record} />}
</For>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
</>
)}
@ -124,14 +158,16 @@ export function HomeSessionsView(props: { controller: HomeSessionsController })
}
function HomeSessionLeadingController(props: {
controller: HomeSessionsController
server: HomeSessionsViewProps["server"]
isOpenTab: HomeSessionsViewProps["isOpenTab"]
record: HomeSessionRecord
revealProjectOnHover: boolean
}) {
return (
<HomeSessionStatusController
controller={props.controller}
server={props.server}
record={props.record}
isOpenTab={props.isOpenTab}
render={(state) => (
<HomeSessionLeading
record={props.record}
@ -172,11 +208,11 @@ function HomeSessionLeading(props: {
)
}
function HomeSessionSearch(props: { controller: HomeSessionsController }) {
function HomeSessionSearch(props: HomeSessionsViewProps) {
return (
<div class="w-full">
<div ref={props.controller.search.setRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.controller.search.open()}>
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.searchOpen()}>
<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)]"
@ -185,7 +221,7 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
<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.controller.search.loading()}
when={!props.searchLoading()}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
@ -193,25 +229,25 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
}
>
<Show
when={props.controller.search.results().length > 0}
when={props.searchResults().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.controller.search.noResultsLabel()}
{props.searchNoResultsLabel()}
</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]">
{props.controller.language.t("home.sessions.search.sessions")}
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.controller.search.setList}>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.controller.search.results()}>
<For each={props.searchResults()}>
{(record) => (
<HomeSessionSearchResultRow
controller={props.controller}
{...props}
record={record}
selected={props.controller.search.active() === homeSessionSearchKey(record)}
selected={props.searchActive() === homeSessionSearchKey(record)}
/>
)}
</For>
@ -227,57 +263,57 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
<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={props.controller.search.setInput}
ref={props.onSetSearchInput}
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.controller.search.value()}
placeholder={props.controller.search.placeholder()}
aria-label={props.controller.search.placeholder()}
aria-expanded={props.controller.search.open()}
value={props.searchValue()}
placeholder={props.searchPlaceholder()}
aria-label={props.searchPlaceholder()}
aria-expanded={props.searchOpen()}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list"
aria-activedescendant={
props.controller.search.active() && props.controller.search.open()
? `home-session-search-option-${props.controller.search.active()}`
props.searchActive() && props.searchOpen()
? `home-session-search-option-${props.searchActive()}`
: undefined
}
onFocus={props.controller.search.focus}
onInput={(event) => props.controller.search.input(event.currentTarget.value)}
onFocus={props.onSearchFocus}
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
props.controller.search.close()
props.onSearchClose()
event.currentTarget.blur()
return
}
if (!props.controller.search.open() || props.controller.search.results().length === 0) return
if (!props.searchOpen() || props.searchResults().length === 0) return
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
props.controller.search.move(1)
props.onSearchMove(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
props.controller.search.move(-1)
props.onSearchMove(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
props.controller.search.selectActive()
props.onSearchSelectActive()
}
}}
/>
<Show when={props.controller.search.value()}>
<Show when={props.searchValue()}>
<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.controller.search.placeholder()}
aria-label={props.searchPlaceholder()}
onClick={() => {
props.controller.search.close()
props.controller.search.focus()
props.onSearchClose()
props.onSearchFocus()
}}
/>
</Show>
@ -287,13 +323,14 @@ function HomeSessionSearch(props: { controller: HomeSessionsController }) {
)
}
function HomeSessionSearchResultRow(props: {
controller: HomeSessionsController
record: HomeSessionRecord
selected: boolean
}) {
function HomeSessionSearchResultRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
selected: boolean
},
) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.controller.showProjectName() && props.record.projectName
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
return (
@ -309,19 +346,20 @@ function HomeSessionSearchResultRow(props: {
"bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}}
onMouseEnter={() => props.controller.search.highlight(props.record)}
onMouseEnter={() => props.onSearchHighlight(props.record)}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.controller.search.select(props.record, { background: isBackgroundOpen(event) })}
onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.controller.search.select(props.record, { background: true })
props.onSearchSelect(props.record, { background: true })
}}
>
<HomeSessionLeadingController
controller={props.controller}
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
@ -342,12 +380,12 @@ function HomeSessionSearchResultRow(props: {
function HomeSessionGroupHeader(props: {
title: string
titleOpacity: number
ref: ComponentProps<"div">["ref"]
onSetRef: (element: HTMLDivElement) => void
elevated?: boolean
}) {
return (
<div
ref={props.ref}
ref={props.onSetRef}
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 }}>
@ -357,9 +395,9 @@ function HomeSessionGroupHeader(props: {
)
}
function HomeSessionRow(props: { controller: HomeSessionsController; record: HomeSessionRecord }) {
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.controller.showProjectName() && props.record.projectName
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
<div
@ -373,15 +411,16 @@ function HomeSessionRow(props: { controller: HomeSessionsController; record: Hom
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.controller.openSession(props.record.session, { background: isBackgroundOpen(event) })}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.controller.openSession(props.record.session, { background: true })
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
controller={props.controller}
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
@ -398,21 +437,17 @@ function HomeSessionRow(props: { controller: HomeSessionsController; record: Hom
</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={props.controller.language.t("common.archive")}
>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
aria-label={props.controller.language.t("common.archive")}
aria-label={props.language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.controller.archiveSession(props.record.session)
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>