diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 6b5a38ab7e..f95203f13d 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -18,12 +18,82 @@ export function NewHome() { onWheel={sessions.scroll.containOuterWheel} >
- - + +
diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx index cc8be8e75f..b694f24ba6 100644 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -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 + servers: Accessor + projects: Accessor + recentlyClosed: Accessor + selection: Accessor + homedir: Accessor + serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined + projectsForServer: (server: ServerConnection.Any) => LocalProject[] + collapsed: (server: ServerConnection.Any) => boolean + menuOpen: (id: string) => boolean + canDefaultServer: Accessor + isDefaultServer: (server: ServerConnection.Any) => boolean + canRevealProject: (server: ServerConnection.Any) => boolean + fileManagerActionLabel: Accessor + 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 ( ) @@ -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 }) { return ( @@ -131,7 +152,7 @@ export function HomeUtilityNav(props: {
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)} /> - + } - 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)} />
@@ -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()) }} >
@@ -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. */} - project.worktree)}> + project.worktree)}> {(worktree, index) => }
@@ -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 ( {(item) => ( )} ) } -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 (
- 0}> + 0}>
-
- {props.controller.language.t("home.recentlyClosed")} -
+
{props.language.t("home.recentlyClosed")}
- - {(project) => } + + {(project) => }
) } -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])} > {displayName(props.project)} @@ -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 (
{ // 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: {
props.controller.setMenuOpen(menuID(), open)} + open={props.menuOpen(menuID())} + onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)} > } - aria-label={props.controller.language.t("common.moreOptions")} + aria-label={props.language.t("common.moreOptions")} /> - props.controller.openProjectNewSession(props.server, props.project.worktree)} - > - {props.controller.language.t("command.session.new")} + props.onOpenProjectNewSession(props.server, props.project.worktree)}> + {props.language.t("command.session.new")} - props.controller.editProject(props.server, props.project)}> - {props.controller.language.t("dialog.project.edit.title")} + props.onEditProject(props.server, props.project)}> + {props.language.t("dialog.project.edit.title")} - - props.controller.revealProject(props.server, props.project)}> - {props.controller.fileManagerActionLabel()} + + props.onRevealProject(props.server, props.project)}> + {props.fileManagerActionLabel()} 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")} - props.controller.closeProject(props.server, props.project.worktree)}> - {props.controller.language.t("common.close")} + props.onCloseProject(props.server, props.project.worktree)}> + {props.language.t("common.close")} @@ -504,8 +528,8 @@ function HomeProjectRow(props: { variant="ghost-muted" size="small" icon={} - 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)} />
diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 6467c06436..723d338167 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -560,18 +560,19 @@ function createHomeSessionHeaderController(groups: () => HomeSessionGroup[]) { export type HomeSessionsController = ReturnType export function HomeSessionStatusController(props: { - controller: HomeSessionsController + server: Accessor record: HomeSessionRecord + isOpenTab: (record: HomeSessionRecord) => boolean render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => 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), }) } diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index 12b383c4a9..1e878ffe41 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -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 + groups: Accessor + loading: Accessor + showProjectName: Accessor + server: Accessor + canCreateSession: Accessor + searchValue: Accessor + searchPlaceholder: Accessor + searchOpen: Accessor + searchLoading: Accessor + searchResults: Accessor + searchActive: Accessor + searchNoResultsLabel: Accessor + titleOpacity: (id: HomeSessionGroup["id"]) => number + isOpenTab: (record: HomeSessionRecord) => boolean + onCreateSession: () => void + onOpenSession: (session: Session, options?: OpenSessionOptions) => void + onArchiveSession: (session: Session) => Promise + 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 (
-
- - 0 && props.controller.canCreateSession()}> +
+ + 0 && props.canCreateSession()}>
- {props.controller.language.t("command.session.new")} + {props.language.t("command.session.new")}