diff options
| author | Adam <[email protected]> | 2026-02-12 09:49:14 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 09:49:14 -0600 |
| commit | ff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch) | |
| tree | 78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/pages/layout | |
| parent | 56ad2db02055955f926fda0e4a89055b22ead6f9 (diff) | |
| download | opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.tar.gz opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.zip | |
chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <[email protected]>
Diffstat (limited to 'packages/app/src/pages/layout')
| -rw-r--r-- | packages/app/src/pages/layout/inline-editor.tsx | 17 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-items.tsx | 238 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project.tsx | 381 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-shell.tsx | 11 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 445 |
5 files changed, 676 insertions, 416 deletions
diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx index 0bbfe244c..4189e4a72 100644 --- a/packages/app/src/pages/layout/inline-editor.tsx +++ b/packages/app/src/pages/layout/inline-editor.tsx @@ -1,8 +1,9 @@ import { createStore } from "solid-js/store" -import { Show, type Accessor } from "solid-js" +import { onCleanup, Show, type Accessor } from "solid-js" import { InlineInput } from "@opencode-ai/ui/inline-input" export function createInlineEditorController() { + // This controller intentionally supports one active inline editor at a time. const [editor, setEditor] = createStore({ active: "" as string, value: "", @@ -47,6 +48,13 @@ export function createInlineEditorController() { stopPropagation?: boolean openOnDblClick?: boolean }) => { + let frame: number | undefined + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + const isEditing = () => props.editing ?? editorOpen(props.id) const stopEvents = () => props.stopPropagation ?? false const allowDblClick = () => props.openOnDblClick ?? true @@ -78,7 +86,12 @@ export function createInlineEditorController() { > <InlineInput ref={(el) => { - requestAnimationFrame(() => el.focus()) + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + if (!el.isConnected) return + el.focus() + }) }} value={editorValue()} class={props.class} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 678bfa0d8..d55090370 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -13,7 +13,7 @@ import { MessageNav } from "@opencode-ai/ui/message-nav" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/util/path" -import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client" +import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client" import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" import { agentColor } from "@/utils/agent" @@ -70,6 +70,116 @@ export type SessionItemProps = { archiveSession: (session: Session) => Promise<void> } +const SessionRow = (props: { + session: Session + slug: string + mobile?: boolean + dense?: boolean + tint: Accessor<string | undefined> + isWorking: Accessor<boolean> + hasPermissions: Accessor<boolean> + hasError: Accessor<boolean> + unseenCount: Accessor<number> + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + sidebarOpened: Accessor<boolean> + prefetchSession: (session: Session, priority?: "high" | "low") => void + scheduleHoverPrefetch: () => void + cancelHoverPrefetch: () => void +}): JSX.Element => ( + <A + href={`/${props.slug}/session/${props.session.id}`} + class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} + onPointerEnter={props.scheduleHoverPrefetch} + onPointerLeave={props.cancelHoverPrefetch} + onMouseEnter={props.scheduleHoverPrefetch} + onMouseLeave={props.cancelHoverPrefetch} + onFocus={() => props.prefetchSession(props.session, "high")} + onClick={() => { + props.setHoverSession(undefined) + if (props.sidebarOpened()) return + props.clearHoverProjectSoon() + }} + > + <div class="flex items-center gap-1 w-full"> + <div + class="shrink-0 size-6 flex items-center justify-center" + style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} + > + <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> + <Match when={props.isWorking()}> + <Spinner class="size-[15px]" /> + </Match> + <Match when={props.hasPermissions()}> + <div class="size-1.5 rounded-full bg-surface-warning-strong" /> + </Match> + <Match when={props.hasError()}> + <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={props.unseenCount() > 0}> + <div class="size-1.5 rounded-full bg-text-interactive-base" /> + </Match> + </Switch> + </div> + <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> + {props.session.title} + </span> + <Show when={props.session.summary}> + {(summary) => ( + <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> + <DiffChanges changes={summary()} /> + </div> + )} + </Show> + </div> + </A> +) + +const SessionHoverPreview = (props: { + mobile?: boolean + nav: Accessor<HTMLElement | undefined> + hoverSession: Accessor<string | undefined> + session: Session + sidebarHovering: Accessor<boolean> + hoverReady: Accessor<boolean> + hoverMessages: Accessor<UserMessage[] | undefined> + language: ReturnType<typeof useLanguage> + isActive: Accessor<boolean> + slug: string + setHoverSession: (id: string | undefined) => void + messageLabel: (message: Message) => string | undefined + onMessageSelect: (message: Message) => void + trigger: JSX.Element +}): JSX.Element => ( + <HoverCard + openDelay={1000} + closeDelay={props.sidebarHovering() ? 600 : 0} + placement="right-start" + gutter={16} + shift={-2} + trigger={props.trigger} + mount={!props.mobile ? props.nav() : undefined} + open={props.hoverSession() === props.session.id} + onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} + > + <Show + when={props.hoverReady()} + fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} + > + <div class="overflow-y-auto max-h-72 h-full"> + <MessageNav + messages={props.hoverMessages() ?? []} + current={undefined} + getLabel={props.messageLabel} + onMessageSelect={props.onMessageSelect} + size="normal" + class="w-60" + /> + </div> + </Show> + </HoverCard> +) + export const SessionItem = (props: SessionItemProps): JSX.Element => { const params = useParams() const navigate = useNavigate() @@ -113,7 +223,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const hoverMessages = createMemo(() => - sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), ) const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) @@ -141,54 +251,24 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) return text?.text } - const item = ( - <A - href={`/${props.slug}/session/${props.session.id}`} - class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} - onPointerEnter={scheduleHoverPrefetch} - onPointerLeave={cancelHoverPrefetch} - onMouseEnter={scheduleHoverPrefetch} - onMouseLeave={cancelHoverPrefetch} - onFocus={() => props.prefetchSession(props.session, "high")} - onClick={() => { - props.setHoverSession(undefined) - if (layout.sidebar.opened()) return - props.clearHoverProjectSoon() - }} - > - <div class="flex items-center gap-1 w-full"> - <div - class="shrink-0 size-6 flex items-center justify-center" - style={{ color: tint() ?? "var(--icon-interactive-base)" }} - > - <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> - <Match when={isWorking()}> - <Spinner class="size-[15px]" /> - </Match> - <Match when={hasPermissions()}> - <div class="size-1.5 rounded-full bg-surface-warning-strong" /> - </Match> - <Match when={hasError()}> - <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={unseenCount() > 0}> - <div class="size-1.5 rounded-full bg-text-interactive-base" /> - </Match> - </Switch> - </div> - <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> - {props.session.title} - </span> - <Show when={props.session.summary}> - {(summary) => ( - <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <DiffChanges changes={summary()} /> - </div> - )} - </Show> - </div> - </A> + <SessionRow + session={props.session} + slug={props.slug} + mobile={props.mobile} + dense={props.dense} + tint={tint} + isWorking={isWorking} + hasPermissions={hasPermissions} + hasError={hasError} + unseenCount={unseenCount} + setHoverSession={props.setHoverSession} + clearHoverProjectSoon={props.clearHoverProjectSoon} + sidebarOpened={layout.sidebar.opened} + prefetchSession={props.prefetchSession} + scheduleHoverPrefetch={scheduleHoverPrefetch} + cancelHoverPrefetch={cancelHoverPrefetch} + /> ) return ( @@ -205,44 +285,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { </Tooltip> } > - <HoverCard - openDelay={1000} - closeDelay={props.sidebarHovering() ? 600 : 0} - placement="right-start" - gutter={16} - shift={-2} + <SessionHoverPreview + mobile={props.mobile} + nav={props.nav} + hoverSession={props.hoverSession} + session={props.session} + sidebarHovering={props.sidebarHovering} + hoverReady={hoverReady} + hoverMessages={hoverMessages} + language={language} + isActive={isActive} + slug={props.slug} + setHoverSession={props.setHoverSession} + messageLabel={messageLabel} + onMessageSelect={(message) => { + if (!isActive()) { + layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) + navigate(`${props.slug}/session/${props.session.id}`) + return + } + window.history.replaceState(null, "", `#message-${message.id}`) + window.dispatchEvent(new HashChangeEvent("hashchange")) + }} trigger={item} - mount={!props.mobile ? props.nav() : undefined} - open={props.hoverSession() === props.session.id} - onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} - > - <Show - when={hoverReady()} - fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>} - > - <div class="overflow-y-auto max-h-72 h-full"> - <MessageNav - messages={hoverMessages() ?? []} - current={undefined} - getLabel={messageLabel} - onMessageSelect={(message) => { - if (!isActive()) { - layout.pendingMessage.set( - `${base64Encode(props.session.directory)}/${props.session.id}`, - message.id, - ) - navigate(`${props.slug}/session/${props.session.id}`) - return - } - window.history.replaceState(null, "", `#message-${message.id}`) - window.dispatchEvent(new HashChangeEvent("hashchange")) - }} - size="normal" - class="w-60" - /> - </div> - </Show> - </HoverCard> + /> </Show> <div class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index c91dc987d..9afa205b6 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -51,6 +51,195 @@ export const ProjectDragOverlay = (props: { ) } +const ProjectTile = (props: { + project: LocalProject + mobile?: boolean + nav: Accessor<HTMLElement | undefined> + sidebarHovering: Accessor<boolean> + selected: Accessor<boolean> + active: Accessor<boolean> + overlay: Accessor<boolean> + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + closeProject: (directory: string) => void + setMenu: (value: boolean) => void + setOpen: (value: boolean) => void + language: ReturnType<typeof useLanguage> +}): JSX.Element => ( + <ContextMenu + modal={!props.sidebarHovering()} + onOpenChange={(value) => { + props.setMenu(value) + if (value) props.setOpen(false) + }} + > + <ContextMenu.Trigger + as="button" + type="button" + aria-label={displayName(props.project)} + data-action="project-switch" + data-project={base64Encode(props.project.worktree)} + classList={{ + "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, + "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(), + "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": + !props.selected() && !props.active(), + "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), + }} + onMouseEnter={(event: MouseEvent) => { + if (!props.overlay()) return + props.onProjectMouseEnter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (!props.overlay()) return + props.onProjectMouseLeave(props.project.worktree) + }} + onFocus={() => { + if (!props.overlay()) return + props.onProjectFocus(props.project.worktree) + }} + onClick={() => props.navigateToProject(props.project.worktree)} + onBlur={() => props.setOpen(false)} + > + <ProjectIcon project={props.project} notify /> + </ContextMenu.Trigger> + <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}> + <ContextMenu.Content> + <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}> + <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel> + </ContextMenu.Item> + <ContextMenu.Item + data-action="project-workspaces-toggle" + data-project={base64Encode(props.project.worktree)} + disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)} + onSelect={() => props.toggleProjectWorkspaces(props.project)} + > + <ContextMenu.ItemLabel> + {props.workspacesEnabled(props.project) + ? props.language.t("sidebar.workspaces.disable") + : props.language.t("sidebar.workspaces.enable")} + </ContextMenu.ItemLabel> + </ContextMenu.Item> + <ContextMenu.Separator /> + <ContextMenu.Item + data-action="project-close-menu" + data-project={base64Encode(props.project.worktree)} + onSelect={() => props.closeProject(props.project.worktree)} + > + <ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel> + </ContextMenu.Item> + </ContextMenu.Content> + </ContextMenu.Portal> + </ContextMenu> +) + +const ProjectPreviewPanel = (props: { + project: LocalProject + mobile?: boolean + selected: Accessor<boolean> + workspaceEnabled: Accessor<boolean> + workspaces: Accessor<string[]> + label: (directory: string) => string + projectSessions: Accessor<ReturnType<typeof sortedRootSessions>> + projectChildren: Accessor<Map<string, string[]>> + workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> + workspaceChildren: (directory: string) => Map<string, string[]> + setOpen: (value: boolean) => void + ctx: ProjectSidebarContext + language: ReturnType<typeof useLanguage> +}): JSX.Element => ( + <div class="-m-3 p-2 flex flex-col w-72"> + <div class="px-4 pt-2 pb-1 flex items-center gap-2"> + <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div> + <Tooltip value={props.language.t("common.close")} placement="top" gutter={6}> + <IconButton + icon="circle-x" + variant="ghost" + class="shrink-0" + data-action="project-close-hover" + data-project={base64Encode(props.project.worktree)} + aria-label={props.language.t("common.close")} + onClick={(event) => { + event.stopPropagation() + props.setOpen(false) + props.ctx.closeProject(props.project.worktree) + }} + /> + </Tooltip> + </div> + <div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div> + <div class="px-2 pb-2 flex flex-col gap-2"> + <Show + when={props.workspaceEnabled()} + fallback={ + <For each={props.projectSessions()}> + {(session) => ( + <SessionItem + {...props.ctx.sessionProps} + session={session} + slug={base64Encode(props.project.worktree)} + dense + mobile={props.mobile} + popover={false} + children={props.projectChildren()} + /> + )} + </For> + } + > + <For each={props.workspaces()}> + {(directory) => { + const sessions = createMemo(() => props.workspaceSessions(directory)) + const children = createMemo(() => props.workspaceChildren(directory)) + return ( + <div class="flex flex-col gap-1"> + <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> + <div class="shrink-0 size-6 flex items-center justify-center"> + <Icon name="branch" size="small" class="text-icon-base" /> + </div> + <span class="truncate text-14-medium text-text-base">{props.label(directory)}</span> + </div> + <For each={sessions()}> + {(session) => ( + <SessionItem + {...props.ctx.sessionProps} + session={session} + slug={base64Encode(directory)} + dense + mobile={props.mobile} + popover={false} + children={children()} + /> + )} + </For> + </div> + ) + }} + </For> + </Show> + </div> + <div class="px-2 py-2 border-t border-border-weak-base"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" + onClick={() => { + props.ctx.openSidebar() + props.setOpen(false) + if (props.selected()) return + props.ctx.navigateToProject(props.project.worktree) + }} + > + {props.language.t("sidebar.project.viewAllSessions")} + </Button> + </div> + </div> +) + export const SortableProject = (props: { project: LocalProject mobile?: boolean @@ -105,177 +294,61 @@ export const SortableProject = (props: { const [data] = globalSync.child(directory, { bootstrap: false }) return childMapByParent(data.session) } - - const Trigger = () => ( - <ContextMenu - modal={!props.ctx.sidebarHovering()} - onOpenChange={(value) => { - setMenu(value) - if (value) setOpen(false) - }} - > - <ContextMenu.Trigger - as="button" - type="button" - aria-label={displayName(props.project)} - data-action="project-switch" - data-project={base64Encode(props.project.worktree)} - classList={{ - "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, - "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), - "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": - !selected() && !active(), - "bg-surface-base-hover border border-border-weak-base": !selected() && active(), - }} - onMouseEnter={(event: MouseEvent) => { - if (!overlay()) return - props.ctx.onProjectMouseEnter(props.project.worktree, event) - }} - onMouseLeave={() => { - if (!overlay()) return - props.ctx.onProjectMouseLeave(props.project.worktree) - }} - onFocus={() => { - if (!overlay()) return - props.ctx.onProjectFocus(props.project.worktree) - }} - onClick={() => props.ctx.navigateToProject(props.project.worktree)} - onBlur={() => setOpen(false)} - > - <ProjectIcon project={props.project} notify /> - </ContextMenu.Trigger> - <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}> - <ContextMenu.Content> - <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}> - <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel> - </ContextMenu.Item> - <ContextMenu.Item - data-action="project-workspaces-toggle" - data-project={base64Encode(props.project.worktree)} - disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)} - onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)} - > - <ContextMenu.ItemLabel> - {props.ctx.workspacesEnabled(props.project) - ? language.t("sidebar.workspaces.disable") - : language.t("sidebar.workspaces.enable")} - </ContextMenu.ItemLabel> - </ContextMenu.Item> - <ContextMenu.Separator /> - <ContextMenu.Item - data-action="project-close-menu" - data-project={base64Encode(props.project.worktree)} - onSelect={() => props.ctx.closeProject(props.project.worktree)} - > - <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel> - </ContextMenu.Item> - </ContextMenu.Content> - </ContextMenu.Portal> - </ContextMenu> + const trigger = ( + <ProjectTile + project={props.project} + mobile={props.mobile} + nav={props.ctx.nav} + sidebarHovering={props.ctx.sidebarHovering} + selected={selected} + active={active} + overlay={overlay} + onProjectMouseEnter={props.ctx.onProjectMouseEnter} + onProjectMouseLeave={props.ctx.onProjectMouseLeave} + onProjectFocus={props.ctx.onProjectFocus} + navigateToProject={props.ctx.navigateToProject} + showEditProjectDialog={props.ctx.showEditProjectDialog} + toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces} + workspacesEnabled={props.ctx.workspacesEnabled} + closeProject={props.ctx.closeProject} + setMenu={setMenu} + setOpen={setOpen} + language={language} + /> ) return ( // @ts-ignore <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> - <Show when={preview()} fallback={<Trigger />}> + <Show when={preview()} fallback={trigger}> <HoverCard open={open() && !menu()} openDelay={0} closeDelay={0} placement="right-start" gutter={6} - trigger={<Trigger />} + trigger={trigger} onOpenChange={(value) => { if (menu()) return setOpen(value) if (value) props.ctx.setHoverSession(undefined) }} > - <div class="-m-3 p-2 flex flex-col w-72"> - <div class="px-4 pt-2 pb-1 flex items-center gap-2"> - <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div> - <Tooltip value={language.t("common.close")} placement="top" gutter={6}> - <IconButton - icon="circle-x" - variant="ghost" - class="shrink-0" - data-action="project-close-hover" - data-project={base64Encode(props.project.worktree)} - aria-label={language.t("common.close")} - onClick={(event) => { - event.stopPropagation() - setOpen(false) - props.ctx.closeProject(props.project.worktree) - }} - /> - </Tooltip> - </div> - <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div> - <div class="px-2 pb-2 flex flex-col gap-2"> - <Show - when={workspaceEnabled()} - fallback={ - <For each={projectSessions()}> - {(session) => ( - <SessionItem - {...props.ctx.sessionProps} - session={session} - slug={base64Encode(props.project.worktree)} - dense - mobile={props.mobile} - popover={false} - children={projectChildren()} - /> - )} - </For> - } - > - <For each={workspaces()}> - {(directory) => { - const sessions = createMemo(() => workspaceSessions(directory)) - const children = createMemo(() => workspaceChildren(directory)) - return ( - <div class="flex flex-col gap-1"> - <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> - <div class="shrink-0 size-6 flex items-center justify-center"> - <Icon name="branch" size="small" class="text-icon-base" /> - </div> - <span class="truncate text-14-medium text-text-base">{label(directory)}</span> - </div> - <For each={sessions()}> - {(session) => ( - <SessionItem - {...props.ctx.sessionProps} - session={session} - slug={base64Encode(directory)} - dense - mobile={props.mobile} - popover={false} - children={children()} - /> - )} - </For> - </div> - ) - }} - </For> - </Show> - </div> - <div class="px-2 py-2 border-t border-border-weak-base"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" - onClick={() => { - props.ctx.openSidebar() - setOpen(false) - if (selected()) return - props.ctx.navigateToProject(props.project.worktree) - }} - > - {language.t("sidebar.project.viewAllSessions")} - </Button> - </div> - </div> + <ProjectPreviewPanel + project={props.project} + mobile={props.mobile} + selected={selected} + workspaceEnabled={workspaceEnabled} + workspaces={workspaces} + label={label} + projectSessions={projectSessions} + projectChildren={projectChildren} + workspaceSessions={workspaceSessions} + workspaceChildren={workspaceChildren} + setOpen={setOpen} + ctx={props.ctx} + language={language} + /> </HoverCard> </Show> </div> diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index ce96a09d1..23abdf157 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -34,6 +34,7 @@ export const SidebarContent = (props: { renderPanel: () => JSX.Element }): JSX.Element => { const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) + const placement = () => (props.mobile ? "bottom" : "right") return ( <div class="flex h-full w-full overflow-hidden"> @@ -55,7 +56,7 @@ export const SidebarContent = (props: { <For each={props.projects()}>{(project) => props.renderProject(project)}</For> </SortableProvider> <Tooltip - placement={props.mobile ? "bottom" : "right"} + placement={placement()} value={ <div class="flex items-center gap-2"> <span>{props.openProjectLabel}</span> @@ -78,11 +79,7 @@ export const SidebarContent = (props: { </DragDropProvider> </div> <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> - <TooltipKeybind - placement={props.mobile ? "bottom" : "right"} - title={props.settingsLabel()} - keybind={props.settingsKeybind() ?? ""} - > + <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}> <IconButton icon="settings-gear" variant="ghost" @@ -91,7 +88,7 @@ export const SidebarContent = (props: { aria-label={props.settingsLabel()} /> </TooltipKeybind> - <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}> + <Tooltip placement={placement()} value={props.helpLabel()}> <IconButton icon="help" variant="ghost" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 13c1e55ef..1d9c2e685 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -82,6 +82,222 @@ export const WorkspaceDragOverlay = (props: { ) } +const WorkspaceHeader = (props: { + local: Accessor<boolean> + busy: Accessor<boolean> + open: Accessor<boolean> + directory: string + language: ReturnType<typeof useLanguage> + branch: Accessor<string | undefined> + workspaceValue: Accessor<string> + workspaceEditActive: Accessor<boolean> + InlineEditor: WorkspaceSidebarContext["InlineEditor"] + renameWorkspace: WorkspaceSidebarContext["renameWorkspace"] + setEditor: WorkspaceSidebarContext["setEditor"] + projectId?: string +}): JSX.Element => ( + <div class="flex items-center gap-1 min-w-0 flex-1"> + <div class="flex items-center justify-center shrink-0 size-6"> + <Show when={props.busy()} fallback={<Icon name="branch" size="small" />}> + <Spinner class="size-[15px]" /> + </Show> + </div> + <span class="text-14-medium text-text-base shrink-0"> + {props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} : + </span> + <Show + when={!props.local()} + fallback={ + <span class="text-14-medium text-text-base min-w-0 truncate"> + {props.branch() ?? getFilename(props.directory)} + </span> + } + > + <props.InlineEditor + id={`workspace:${props.directory}`} + value={props.workspaceValue} + onSave={(next) => { + const trimmed = next.trim() + if (!trimmed) return + props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch()) + props.setEditor("value", props.workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={props.workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + </Show> + <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100"> + <Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" /> + </div> + </div> +) + +const WorkspaceActions = (props: { + directory: string + local: Accessor<boolean> + busy: Accessor<boolean> + menuOpen: Accessor<boolean> + pendingRename: Accessor<boolean> + setMenuOpen: (open: boolean) => void + setPendingRename: (value: boolean) => void + sidebarHovering: Accessor<boolean> + mobile?: boolean + nav: Accessor<HTMLElement | undefined> + touch: Accessor<boolean> + language: ReturnType<typeof useLanguage> + workspaceValue: Accessor<string> + openEditor: WorkspaceSidebarContext["openEditor"] + showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] + showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] + root: string + setHoverSession: WorkspaceSidebarContext["setHoverSession"] + clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] + navigateToNewSession: () => void +}): JSX.Element => ( + <div + class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" + classList={{ + "opacity-100 pointer-events-auto": props.menuOpen(), + "opacity-0 pointer-events-none": !props.menuOpen(), + "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true, + "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true, + }} + > + <DropdownMenu + modal={!props.sidebarHovering()} + open={props.menuOpen()} + onOpenChange={(open) => props.setMenuOpen(open)} + > + <Tooltip value={props.language.t("common.moreOptions")} placement="top"> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="size-6 rounded-md" + data-action="workspace-menu" + data-workspace={base64Encode(props.directory)} + aria-label={props.language.t("common.moreOptions")} + /> + </Tooltip> + <DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}> + <DropdownMenu.Content + onCloseAutoFocus={(event) => { + if (!props.pendingRename()) return + event.preventDefault() + props.setPendingRename(false) + props.openEditor(`workspace:${props.directory}`, props.workspaceValue()) + }} + > + <DropdownMenu.Item + disabled={props.local()} + onSelect={() => { + props.setPendingRename(true) + props.setMenuOpen(false) + }} + > + <DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item + disabled={props.local() || props.busy()} + onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)} + > + <DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item + disabled={props.local() || props.busy()} + onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)} + > + <DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + <Show when={!props.touch()}> + <Tooltip value={props.language.t("command.session.new")} placement="top"> + <IconButton + icon="plus-small" + variant="ghost" + class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" + data-action="workspace-new-session" + data-workspace={base64Encode(props.directory)} + aria-label={props.language.t("command.session.new")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + props.setHoverSession(undefined) + props.clearHoverProjectSoon() + props.navigateToNewSession() + }} + /> + </Tooltip> + </Show> + </div> +) + +const WorkspaceSessionList = (props: { + slug: Accessor<string> + mobile?: boolean + ctx: WorkspaceSidebarContext + showNew: Accessor<boolean> + loading: Accessor<boolean> + sessions: Accessor<Session[]> + children: Accessor<Map<string, string[]>> + hasMore: Accessor<boolean> + loadMore: () => Promise<void> + language: ReturnType<typeof useLanguage> +}): JSX.Element => ( + <nav class="flex flex-col gap-1 px-2"> + <Show when={props.showNew()}> + <NewSessionItem + slug={props.slug()} + mobile={props.mobile} + sidebarExpanded={props.ctx.sidebarExpanded} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + setHoverSession={props.ctx.setHoverSession} + /> + </Show> + <Show when={props.loading()}> + <SessionSkeleton /> + </Show> + <For each={props.sessions()}> + {(session) => ( + <SessionItem + session={session} + slug={props.slug()} + mobile={props.mobile} + children={props.children()} + sidebarExpanded={props.ctx.sidebarExpanded} + sidebarHovering={props.ctx.sidebarHovering} + nav={props.ctx.nav} + hoverSession={props.ctx.hoverSession} + setHoverSession={props.ctx.setHoverSession} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + prefetchSession={props.ctx.prefetchSession} + archiveSession={props.ctx.archiveSession} + /> + )} + </For> + <Show when={props.hasMore()}> + <div class="relative w-full py-1"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" + size="large" + onClick={(e: MouseEvent) => { + props.loadMore() + ;(e.currentTarget as HTMLButtonElement).blur() + }} + > + {props.language.t("common.loadMore")} + </Button> + </div> + </Show> + </nav> +) + export const SortableWorkspace = (props: { ctx: WorkspaceSidebarContext directory: string @@ -135,46 +351,6 @@ export const SortableWorkspace = (props: { globalSync.child(props.directory, { bootstrap: true }) }) - const header = () => ( - <div class="flex items-center gap-1 min-w-0 flex-1"> - <div class="flex items-center justify-center shrink-0 size-6"> - <Show when={busy()} fallback={<Icon name="branch" size="small" />}> - <Spinner class="size-[15px]" /> - </Show> - </div> - <span class="text-14-medium text-text-base shrink-0"> - {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : - </span> - <Show - when={!local()} - fallback={ - <span class="text-14-medium text-text-base min-w-0 truncate"> - {workspaceStore.vcs?.branch ?? getFilename(props.directory)} - </span> - } - > - <props.ctx.InlineEditor - id={`workspace:${props.directory}`} - value={workspaceValue} - onSave={(next) => { - const trimmed = next.trim() - if (!trimmed) return - props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) - props.ctx.setEditor("value", workspaceValue()) - }} - class="text-14-medium text-text-base min-w-0 truncate" - displayClass="text-14-medium text-text-base min-w-0 truncate" - editing={workspaceEditActive()} - stopPropagation={false} - openOnDblClick={false} - /> - </Show> - <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100"> - <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" /> - </div> - </div> - ) - return ( <div // @ts-ignore @@ -202,7 +378,20 @@ export const SortableWorkspace = (props: { data-action="workspace-toggle" data-workspace={base64Encode(props.directory)} > - {header()} + <WorkspaceHeader + local={local} + busy={busy} + open={open} + directory={props.directory} + language={language} + branch={() => workspaceStore.vcs?.branch} + workspaceValue={workspaceValue} + workspaceEditActive={workspaceEditActive} + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} + projectId={props.project.id} + /> </Collapsible.Trigger> } > @@ -211,139 +400,61 @@ export const SortableWorkspace = (props: { menu.open ? "pr-16" : "pr-2" } group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`} > - {header()} + <WorkspaceHeader + local={local} + busy={busy} + open={open} + directory={props.directory} + language={language} + branch={() => workspaceStore.vcs?.branch} + workspaceValue={workspaceValue} + workspaceEditActive={workspaceEditActive} + InlineEditor={props.ctx.InlineEditor} + renameWorkspace={props.ctx.renameWorkspace} + setEditor={props.ctx.setEditor} + projectId={props.project.id} + /> </div> </Show> - <div - class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" - classList={{ - "opacity-100 pointer-events-auto": menu.open, - "opacity-0 pointer-events-none": !menu.open, - "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true, - "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true, - }} - > - <DropdownMenu - modal={!props.ctx.sidebarHovering()} - open={menu.open} - onOpenChange={(open) => setMenu("open", open)} - > - <Tooltip value={language.t("common.moreOptions")} placement="top"> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="size-6 rounded-md" - data-action="workspace-menu" - data-workspace={base64Encode(props.directory)} - aria-label={language.t("common.moreOptions")} - /> - </Tooltip> - <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}> - <DropdownMenu.Content - onCloseAutoFocus={(event) => { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue()) - }} - > - <DropdownMenu.Item - disabled={local()} - onSelect={() => { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item - disabled={local() || busy()} - onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)} - > - <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item - disabled={local() || busy()} - onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)} - > - <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - <Show when={!touch()}> - <Tooltip value={language.t("command.session.new")} placement="top"> - <IconButton - icon="plus-small" - variant="ghost" - class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto" - data-action="workspace-new-session" - data-workspace={base64Encode(props.directory)} - aria-label={language.t("command.session.new")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - props.ctx.setHoverSession(undefined) - props.ctx.clearHoverProjectSoon() - navigate(`/${slug()}/session`) - }} - /> - </Tooltip> - </Show> - </div> + <WorkspaceActions + directory={props.directory} + local={local} + busy={busy} + menuOpen={() => menu.open} + pendingRename={() => menu.pendingRename} + setMenuOpen={(open) => setMenu("open", open)} + setPendingRename={(value) => setMenu("pendingRename", value)} + sidebarHovering={props.ctx.sidebarHovering} + mobile={props.mobile} + nav={props.ctx.nav} + touch={touch} + language={language} + workspaceValue={workspaceValue} + openEditor={props.ctx.openEditor} + showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog} + showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog} + root={props.project.worktree} + setHoverSession={props.ctx.setHoverSession} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + navigateToNewSession={() => navigate(`/${slug()}/session`)} + /> </div> </div> </div> <Collapsible.Content> - <nav class="flex flex-col gap-1 px-2"> - <Show when={showNew()}> - <NewSessionItem - slug={slug()} - mobile={props.mobile} - sidebarExpanded={props.ctx.sidebarExpanded} - clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - setHoverSession={props.ctx.setHoverSession} - /> - </Show> - <Show when={loading()}> - <SessionSkeleton /> - </Show> - <For each={sessions()}> - {(session) => ( - <SessionItem - session={session} - slug={slug()} - mobile={props.mobile} - children={children()} - sidebarExpanded={props.ctx.sidebarExpanded} - sidebarHovering={props.ctx.sidebarHovering} - nav={props.ctx.nav} - hoverSession={props.ctx.hoverSession} - setHoverSession={props.ctx.setHoverSession} - clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} - prefetchSession={props.ctx.prefetchSession} - archiveSession={props.ctx.archiveSession} - /> - )} - </For> - <Show when={hasMore()}> - <div class="relative w-full py-1"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" - size="large" - onClick={(e: MouseEvent) => { - loadMore() - ;(e.currentTarget as HTMLButtonElement).blur() - }} - > - {language.t("common.loadMore")} - </Button> - </div> - </Show> - </nav> + <WorkspaceSessionList + slug={slug} + mobile={props.mobile} + ctx={props.ctx} + showNew={showNew} + loading={loading} + sessions={sessions} + children={children} + hasMore={hasMore} + loadMore={loadMore} + language={language} + /> </Collapsible.Content> </Collapsible> </div> |
