|
|
|
@@ -9,6 +9,7 @@ import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
|
|
|
|
|
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
|
|
|
|
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
|
|
|
|
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
|
|
|
|
|
import { MenuV2 } from "@opencode-ai/ui/v2/components/menu-v2.jsx"
|
|
|
|
|
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
|
|
|
|
|
import { useNavigate } from "@solidjs/router"
|
|
|
|
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
|
|
@@ -63,16 +64,18 @@ function HomeDesign() {
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const server = useServer()
|
|
|
|
|
const language = useLanguage()
|
|
|
|
|
const notification = useNotification()
|
|
|
|
|
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
|
|
|
|
|
|
|
|
|
|
const projects = createMemo(() => layout.projects.list())
|
|
|
|
|
const selectedProject = createMemo(
|
|
|
|
|
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
|
|
|
|
|
)
|
|
|
|
|
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
|
|
|
|
|
const projectDirectories = createMemo(() => {
|
|
|
|
|
const project = selectedProject()
|
|
|
|
|
if (!project) return []
|
|
|
|
|
return [project.worktree, ...(project.sandboxes ?? [])]
|
|
|
|
|
return directories(project)
|
|
|
|
|
})
|
|
|
|
|
const search = createMemo(() => state.search.trim())
|
|
|
|
|
const sessionLoad = useQuery(() => ({
|
|
|
|
@@ -135,6 +138,26 @@ function HomeDesign() {
|
|
|
|
|
navigate(`/${base64Encode(project.worktree)}/session`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openProjectNewSession(directory: string) {
|
|
|
|
|
layout.projects.open(directory)
|
|
|
|
|
server.projects.touch(directory)
|
|
|
|
|
navigate(`/${base64Encode(directory)}/session`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const showEditProjectDialog = (project: LocalProject) => {
|
|
|
|
|
void import("@/components/dialog-edit-project").then((x) => {
|
|
|
|
|
dialog.show(() => <x.DialogEditProject project={project} />)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const unseenCount = (project: LocalProject) =>
|
|
|
|
|
directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
|
|
|
|
|
|
|
|
|
const clearNotifications = (project: LocalProject) =>
|
|
|
|
|
directories(project)
|
|
|
|
|
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
|
|
|
|
.forEach((directory) => notification.project.markViewed(directory))
|
|
|
|
|
|
|
|
|
|
function openSession(session: Session) {
|
|
|
|
|
const project = projectForSession(session, projects(), projectByID())
|
|
|
|
|
layout.projects.open(project?.worktree ?? session.directory)
|
|
|
|
@@ -178,7 +201,15 @@ function HomeDesign() {
|
|
|
|
|
<HomeProjectColumn
|
|
|
|
|
selectedProject={state.project}
|
|
|
|
|
selectProject={selectProject}
|
|
|
|
|
openNewSession={openProjectNewSession}
|
|
|
|
|
chooseProject={() => void chooseProject()}
|
|
|
|
|
editProject={showEditProjectDialog}
|
|
|
|
|
closeProject={(directory) => {
|
|
|
|
|
layout.projects.close(directory)
|
|
|
|
|
if (state.project === directory) setState("project", undefined)
|
|
|
|
|
}}
|
|
|
|
|
clearNotifications={clearNotifications}
|
|
|
|
|
unseenCount={unseenCount}
|
|
|
|
|
openSettings={openSettings}
|
|
|
|
|
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
|
|
|
|
language={language}
|
|
|
|
@@ -188,41 +219,60 @@ function HomeDesign() {
|
|
|
|
|
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
|
|
|
|
|
aria-label={language.t("sidebar.project.recentSessions")}
|
|
|
|
|
>
|
|
|
|
|
<HomeSessionSearch
|
|
|
|
|
value={state.search}
|
|
|
|
|
placeholder={language.t("home.sessions.search.placeholder")}
|
|
|
|
|
onInput={(value) => setState("search", value)}
|
|
|
|
|
/>
|
|
|
|
|
<div class="mt-3 overflow-auto flex-1">
|
|
|
|
|
<div class="pt-3 flex flex-col gap-6">
|
|
|
|
|
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
|
|
|
|
|
<Show
|
|
|
|
|
when={groups().length > 0}
|
|
|
|
|
fallback={
|
|
|
|
|
<div class="flex min-w-0 flex-col gap-4">
|
|
|
|
|
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<For each={groups()}>
|
|
|
|
|
{(group, index) => (
|
|
|
|
|
<div class="flex min-w-0 flex-col gap-4">
|
|
|
|
|
<HomeSessionGroupHeader
|
|
|
|
|
title={group.title}
|
|
|
|
|
onNewSession={index() === 0 ? openNewSession : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<div class="flex min-w-0 flex-col gap-px">
|
|
|
|
|
<For each={group.sessions}>
|
|
|
|
|
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
|
|
|
|
|
</For>
|
|
|
|
|
<Show
|
|
|
|
|
when={selectedProject()}
|
|
|
|
|
fallback={
|
|
|
|
|
<HomeEmptyState
|
|
|
|
|
icon="folder-add-left"
|
|
|
|
|
title={language.t("home.empty.title")}
|
|
|
|
|
description={language.t("home.empty.description")}
|
|
|
|
|
action={language.t("home.project.add")}
|
|
|
|
|
onAction={() => void chooseProject()}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<HomeSessionSearch
|
|
|
|
|
value={state.search}
|
|
|
|
|
placeholder={language.t("home.sessions.search.placeholder")}
|
|
|
|
|
onInput={(value) => setState("search", value)}
|
|
|
|
|
clearLabel={language.t("common.clear")}
|
|
|
|
|
onClear={() => setState("search", "")}
|
|
|
|
|
/>
|
|
|
|
|
<div class="mt-3 overflow-auto flex-1">
|
|
|
|
|
<div class="pt-3 flex flex-col gap-6">
|
|
|
|
|
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
|
|
|
|
|
<Show
|
|
|
|
|
when={groups().length > 0}
|
|
|
|
|
fallback={
|
|
|
|
|
<HomeEmptyState
|
|
|
|
|
icon="edit"
|
|
|
|
|
title={language.t("home.sessions.empty")}
|
|
|
|
|
description={language.t("home.sessions.empty.description")}
|
|
|
|
|
action={language.t("command.session.new")}
|
|
|
|
|
onAction={openNewSession}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<For each={groups()}>
|
|
|
|
|
{(group, index) => (
|
|
|
|
|
<div class="flex min-w-0 flex-col gap-4">
|
|
|
|
|
<HomeSessionGroupHeader
|
|
|
|
|
title={group.title}
|
|
|
|
|
onNewSession={index() === 0 ? openNewSession : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<div class="flex min-w-0 flex-col gap-px">
|
|
|
|
|
<For each={group.sessions}>
|
|
|
|
|
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
|
|
|
|
|
</For>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</For>
|
|
|
|
|
)}
|
|
|
|
|
</For>
|
|
|
|
|
</Show>
|
|
|
|
|
</Show>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Show>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
@@ -231,7 +281,12 @@ function HomeDesign() {
|
|
|
|
|
function HomeProjectColumn(props: {
|
|
|
|
|
selectedProject?: string
|
|
|
|
|
selectProject: (directory: string) => void
|
|
|
|
|
openNewSession: (directory: string) => void
|
|
|
|
|
chooseProject: () => void
|
|
|
|
|
editProject: (project: LocalProject) => void
|
|
|
|
|
closeProject: (directory: string) => void
|
|
|
|
|
clearNotifications: (project: LocalProject) => void
|
|
|
|
|
unseenCount: (project: LocalProject) => number
|
|
|
|
|
openSettings: () => void
|
|
|
|
|
openHelp: () => void
|
|
|
|
|
language: ReturnType<typeof useLanguage>
|
|
|
|
@@ -239,10 +294,6 @@ function HomeProjectColumn(props: {
|
|
|
|
|
const servers = useServers()
|
|
|
|
|
const layout = useLayout()
|
|
|
|
|
const projects = createMemo(() => layout.projects.list())
|
|
|
|
|
const selectedProject = createMemo(
|
|
|
|
|
() => projects().find((project) => project.worktree === props.selectedProject) ?? projects()[0],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
|
|
|
|
|
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
|
|
|
@@ -265,6 +316,12 @@ function HomeProjectColumn(props: {
|
|
|
|
|
selectedProject={props.selectedProject}
|
|
|
|
|
onSelectedProjectChange={props.selectProject}
|
|
|
|
|
onChooseProject={props.chooseProject}
|
|
|
|
|
openNewSession={props.openNewSession}
|
|
|
|
|
editProject={props.editProject}
|
|
|
|
|
closeProject={props.closeProject}
|
|
|
|
|
clearNotifications={props.clearNotifications}
|
|
|
|
|
unseenCount={props.unseenCount}
|
|
|
|
|
language={props.language}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
@@ -310,6 +367,12 @@ function HomeProjectColumn(props: {
|
|
|
|
|
selectedProject={props.selectedProject}
|
|
|
|
|
onSelectedProjectChange={props.selectProject}
|
|
|
|
|
onChooseProject={props.chooseProject}
|
|
|
|
|
openNewSession={props.openNewSession}
|
|
|
|
|
editProject={props.editProject}
|
|
|
|
|
closeProject={props.closeProject}
|
|
|
|
|
clearNotifications={props.clearNotifications}
|
|
|
|
|
unseenCount={props.unseenCount}
|
|
|
|
|
language={props.language}
|
|
|
|
|
/>
|
|
|
|
|
</Show>
|
|
|
|
|
</div>
|
|
|
|
@@ -338,6 +401,83 @@ function HomeProjectColumn(props: {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HomeProjectRow(props: {
|
|
|
|
|
project: LocalProject
|
|
|
|
|
selected: boolean
|
|
|
|
|
unseenCount: number
|
|
|
|
|
selectProject: (directory: string) => void
|
|
|
|
|
openNewSession: (directory: string) => void
|
|
|
|
|
editProject: (project: LocalProject) => void
|
|
|
|
|
closeProject: (directory: string) => void
|
|
|
|
|
clearNotifications: (project: LocalProject) => void
|
|
|
|
|
language: ReturnType<typeof useLanguage>
|
|
|
|
|
}) {
|
|
|
|
|
const name = createMemo(() => displayName(props.project))
|
|
|
|
|
return (
|
|
|
|
|
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-component="home-project-row"
|
|
|
|
|
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
|
|
|
|
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
|
|
|
|
|
data-selected={props.selected ? "" : undefined}
|
|
|
|
|
aria-current={props.selected ? "page" : undefined}
|
|
|
|
|
onClick={() => props.selectProject(props.project.worktree)}
|
|
|
|
|
>
|
|
|
|
|
<HomeProjectAvatar project={props.project} />
|
|
|
|
|
<span>{name()}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
|
|
|
|
|
<IconButtonV2
|
|
|
|
|
data-action="home-project-new-session"
|
|
|
|
|
variant="ghost-muted"
|
|
|
|
|
size="small"
|
|
|
|
|
icon={<IconV2 name="edit" />}
|
|
|
|
|
aria-label={props.language.t("command.session.new")}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
props.openNewSession(props.project.worktree)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<MenuV2 gutter={4} modal={false} placement="bottom-end">
|
|
|
|
|
<MenuV2.Trigger
|
|
|
|
|
as={IconButtonV2}
|
|
|
|
|
data-action="home-project-menu"
|
|
|
|
|
variant="ghost-muted"
|
|
|
|
|
size="small"
|
|
|
|
|
icon={<IconV2 name="menu" />}
|
|
|
|
|
aria-label={props.language.t("common.moreOptions")}
|
|
|
|
|
/>
|
|
|
|
|
<MenuV2.Portal>
|
|
|
|
|
<MenuV2.Content>
|
|
|
|
|
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
|
|
|
|
|
<Icon name="new-session" size="small" />
|
|
|
|
|
{props.language.t("command.session.new")}
|
|
|
|
|
</MenuV2.Item>
|
|
|
|
|
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
|
|
|
|
|
<Icon name="edit" size="small" />
|
|
|
|
|
{props.language.t("common.edit")}
|
|
|
|
|
</MenuV2.Item>
|
|
|
|
|
<MenuV2.Item
|
|
|
|
|
disabled={props.unseenCount === 0}
|
|
|
|
|
onSelect={() => props.clearNotifications(props.project)}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="circle-check" size="small" />
|
|
|
|
|
{props.language.t("sidebar.project.clearNotifications")}
|
|
|
|
|
</MenuV2.Item>
|
|
|
|
|
<MenuV2.Separator />
|
|
|
|
|
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
|
|
|
|
|
<Icon name="close" size="small" />
|
|
|
|
|
{props.language.t("common.close")}
|
|
|
|
|
</MenuV2.Item>
|
|
|
|
|
</MenuV2.Content>
|
|
|
|
|
</MenuV2.Portal>
|
|
|
|
|
</MenuV2>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HomeProjectAvatar(props: { project: LocalProject }) {
|
|
|
|
|
const name = createMemo(() => displayName(props.project))
|
|
|
|
|
return (
|
|
|
|
@@ -352,7 +492,13 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
|
|
|
|
|
function HomeSessionSearch(props: {
|
|
|
|
|
value: string
|
|
|
|
|
placeholder: string
|
|
|
|
|
clearLabel: string
|
|
|
|
|
onInput: (value: string) => void
|
|
|
|
|
onClear: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
|
|
|
|
|
<IconV2 name="magnifying-glass" size="small" />
|
|
|
|
@@ -363,10 +509,46 @@ function HomeSessionSearch(props: { value: string; placeholder: string; onInput:
|
|
|
|
|
aria-label={props.placeholder}
|
|
|
|
|
onInput={(event) => props.onInput(event.currentTarget.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Show when={props.value.trim()}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="flex size-5 shrink-0 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
|
|
|
aria-label={props.clearLabel}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
props.onClear()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="close-small" size="small" />
|
|
|
|
|
</button>
|
|
|
|
|
</Show>
|
|
|
|
|
</label>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HomeEmptyState(props: {
|
|
|
|
|
icon: Parameters<typeof IconV2>[0]["name"]
|
|
|
|
|
title: string
|
|
|
|
|
description: string
|
|
|
|
|
action: string
|
|
|
|
|
onAction: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div class="flex min-h-[320px] flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
|
|
|
|
|
<div class="flex size-10 items-center justify-center rounded-[10px] bg-v2-background-bg-deep text-v2-icon-icon-muted shadow-[var(--v2-elevation-raised)]">
|
|
|
|
|
<IconV2 name={props.icon} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex max-w-[320px] flex-col gap-1">
|
|
|
|
|
<div class="text-v2-text-text-base [font-weight:530]">{props.title}</div>
|
|
|
|
|
<div class="text-v2-text-text-muted [font-weight:440]">{props.description}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<ButtonV2 variant="neutral" size="normal" icon={props.icon} onClick={props.onAction}>
|
|
|
|
|
{props.action}
|
|
|
|
|
</ButtonV2>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
|
|
|
|
const language = useLanguage()
|
|
|
|
|
return (
|
|
|
|
@@ -621,9 +803,13 @@ function ProjectList(props: {
|
|
|
|
|
selectedProject?: string
|
|
|
|
|
onSelectedProjectChange?(project: string): void
|
|
|
|
|
onChooseProject?(): void
|
|
|
|
|
openNewSession: (directory: string) => void
|
|
|
|
|
editProject: (project: LocalProject) => void
|
|
|
|
|
closeProject: (directory: string) => void
|
|
|
|
|
clearNotifications: (project: LocalProject) => void
|
|
|
|
|
unseenCount: (project: LocalProject) => number
|
|
|
|
|
language: ReturnType<typeof useLanguage>
|
|
|
|
|
}) {
|
|
|
|
|
const language = useLanguage()
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Show
|
|
|
|
|
when={props.projects.length > 0}
|
|
|
|
@@ -634,27 +820,24 @@ function ProjectList(props: {
|
|
|
|
|
onClick={() => props.onChooseProject?.()}
|
|
|
|
|
>
|
|
|
|
|
<IconV2 name="folder-add-left" size="small" />
|
|
|
|
|
<span>{language.t("home.project.add")}</span>
|
|
|
|
|
<span>{props.language.t("home.project.add")}</span>
|
|
|
|
|
</button>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<div class="flex flex-col gap-1">
|
|
|
|
|
<For each={props.projects}>
|
|
|
|
|
{(project) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-component="home-project-row"
|
|
|
|
|
class={HOME_PROJECT_NAV_ROW}
|
|
|
|
|
classList={{
|
|
|
|
|
"bg-v2-overlay-simple-overlay-hover": props.selectedProject === project.worktree,
|
|
|
|
|
}}
|
|
|
|
|
data-selected={props.selectedProject === project.worktree ? "" : undefined}
|
|
|
|
|
aria-current={props.selectedProject === project.worktree ? "page" : undefined}
|
|
|
|
|
onClick={() => props.onSelectedProjectChange?.(project.worktree)}
|
|
|
|
|
>
|
|
|
|
|
<HomeProjectAvatar project={project} />
|
|
|
|
|
<span>{displayName(project)}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<HomeProjectRow
|
|
|
|
|
project={project}
|
|
|
|
|
selected={props.selectedProject === project.worktree}
|
|
|
|
|
unseenCount={props.unseenCount(project)}
|
|
|
|
|
selectProject={(directory) => props.onSelectedProjectChange?.(directory)}
|
|
|
|
|
openNewSession={props.openNewSession}
|
|
|
|
|
editProject={props.editProject}
|
|
|
|
|
closeProject={props.closeProject}
|
|
|
|
|
clearNotifications={props.clearNotifications}
|
|
|
|
|
language={props.language}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</For>
|
|
|
|
|
</div>
|
|
|
|
|