mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 00:23:10 +00:00
500 lines
19 KiB
TypeScript
500 lines
19 KiB
TypeScript
import { useNavigate, useParams } from "@solidjs/router"
|
|
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import { createSortable } from "@thisbeyond/solid-dnd"
|
|
import { createMediaQuery } from "@solid-primitives/media"
|
|
import { base64Encode } from "@opencode-ai/util/encode"
|
|
import { getFilename } from "@opencode-ai/util/path"
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
import { Icon } from "@opencode-ai/ui/icon"
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
|
import { type LocalProject } from "@/context/layout"
|
|
import { useGlobalSync } from "@/context/global-sync"
|
|
import { useLanguage } from "@/context/language"
|
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
|
import { childMapByParent, sortedRootSessions } from "./helpers"
|
|
|
|
type InlineEditorComponent = (props: {
|
|
id: string
|
|
value: Accessor<string>
|
|
onSave: (next: string) => void
|
|
class?: string
|
|
displayClass?: string
|
|
editing?: boolean
|
|
stopPropagation?: boolean
|
|
openOnDblClick?: boolean
|
|
}) => JSX.Element
|
|
|
|
export type WorkspaceSidebarContext = {
|
|
currentDir: Accessor<string>
|
|
sidebarExpanded: Accessor<boolean>
|
|
sidebarHovering: Accessor<boolean>
|
|
nav: Accessor<HTMLElement | undefined>
|
|
hoverSession: Accessor<string | undefined>
|
|
setHoverSession: (id: string | undefined) => void
|
|
clearHoverProjectSoon: () => void
|
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
|
archiveSession: (session: Session) => Promise<void>
|
|
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
|
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
|
editorOpen: (id: string) => boolean
|
|
openEditor: (id: string, value: string) => void
|
|
closeEditor: () => void
|
|
setEditor: (key: "value", value: string) => void
|
|
InlineEditor: InlineEditorComponent
|
|
isBusy: (directory: string) => boolean
|
|
workspaceExpanded: (directory: string, local: boolean) => boolean
|
|
setWorkspaceExpanded: (directory: string, value: boolean) => void
|
|
showResetWorkspaceDialog: (root: string, directory: string) => void
|
|
showDeleteWorkspaceDialog: (root: string, directory: string) => void
|
|
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
|
|
}
|
|
|
|
export const WorkspaceDragOverlay = (props: {
|
|
sidebarProject: Accessor<LocalProject | undefined>
|
|
activeWorkspace: Accessor<string | undefined>
|
|
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
|
}): JSX.Element => {
|
|
const globalSync = useGlobalSync()
|
|
const language = useLanguage()
|
|
const label = createMemo(() => {
|
|
const project = props.sidebarProject()
|
|
if (!project) return
|
|
const directory = props.activeWorkspace()
|
|
if (!directory) return
|
|
|
|
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
|
|
const kind =
|
|
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
|
|
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
|
return `${kind} : ${name}`
|
|
})
|
|
|
|
return (
|
|
<Show when={label()}>
|
|
{(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
|
|
</Show>
|
|
)
|
|
}
|
|
|
|
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>
|
|
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>
|
|
<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
|
|
popover?: 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-3">
|
|
<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}
|
|
popover={props.popover}
|
|
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
|
|
project: LocalProject
|
|
sortNow: Accessor<number>
|
|
mobile?: boolean
|
|
popover?: boolean
|
|
}): JSX.Element => {
|
|
const navigate = useNavigate()
|
|
const params = useParams()
|
|
const globalSync = useGlobalSync()
|
|
const language = useLanguage()
|
|
const sortable = createSortable(props.directory)
|
|
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
|
|
const [menu, setMenu] = createStore({
|
|
open: false,
|
|
pendingRename: false,
|
|
})
|
|
const slug = createMemo(() => base64Encode(props.directory))
|
|
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
|
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
|
const local = createMemo(() => props.directory === props.project.worktree)
|
|
const active = createMemo(() => props.ctx.currentDir() === props.directory)
|
|
const workspaceValue = createMemo(() => {
|
|
const branch = workspaceStore.vcs?.branch
|
|
const name = branch ?? getFilename(props.directory)
|
|
return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
|
|
})
|
|
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
|
|
const boot = createMemo(() => open() || active())
|
|
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
|
|
const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
|
|
const busy = createMemo(() => props.ctx.isBusy(props.directory))
|
|
const wasBusy = createMemo((prev) => prev || busy(), false)
|
|
const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
|
|
const touch = createMediaQuery("(hover: none)")
|
|
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
|
|
const loadMore = async () => {
|
|
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
|
|
await globalSync.project.loadSessions(props.directory)
|
|
}
|
|
|
|
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
|
|
const 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}
|
|
/>
|
|
)
|
|
|
|
const openWrapper = (value: boolean) => {
|
|
props.ctx.setWorkspaceExpanded(props.directory, value)
|
|
if (value) return
|
|
if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
|
|
}
|
|
|
|
createEffect(() => {
|
|
if (!boot()) return
|
|
globalSync.child(props.directory, { bootstrap: true })
|
|
})
|
|
|
|
return (
|
|
<div
|
|
// @ts-ignore
|
|
use:sortable
|
|
classList={{
|
|
"opacity-30": sortable.isActiveDraggable,
|
|
"opacity-50 pointer-events-none": busy(),
|
|
}}
|
|
>
|
|
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
|
<div class="px-2 py-1">
|
|
<div
|
|
class="group/workspace relative"
|
|
data-component="workspace-item"
|
|
data-workspace={base64Encode(props.directory)}
|
|
>
|
|
<div class="flex items-center gap-1">
|
|
<Show
|
|
when={workspaceEditActive()}
|
|
fallback={
|
|
<Collapsible.Trigger
|
|
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-[padding] duration-200 ${
|
|
menu.open ? "pr-16" : "pr-2"
|
|
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
|
data-action="workspace-toggle"
|
|
data-workspace={base64Encode(props.directory)}
|
|
>
|
|
{header()}
|
|
</Collapsible.Trigger>
|
|
}
|
|
>
|
|
<div
|
|
class={`flex items-center justify-between w-full pl-2 py-1.5 rounded-md transition-[padding] duration-200 ${
|
|
menu.open ? "pr-16" : "pr-2"
|
|
} group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
|
|
>
|
|
{header()}
|
|
</div>
|
|
</Show>
|
|
<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}
|
|
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>
|
|
<WorkspaceSessionList
|
|
slug={slug}
|
|
mobile={props.mobile}
|
|
popover={props.popover}
|
|
ctx={props.ctx}
|
|
showNew={showNew}
|
|
loading={loading}
|
|
sessions={sessions}
|
|
children={children}
|
|
hasMore={hasMore}
|
|
loadMore={loadMore}
|
|
language={language}
|
|
/>
|
|
</Collapsible.Content>
|
|
</Collapsible>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const LocalWorkspace = (props: {
|
|
ctx: WorkspaceSidebarContext
|
|
project: LocalProject
|
|
sortNow: Accessor<number>
|
|
mobile?: boolean
|
|
popover?: boolean
|
|
}): JSX.Element => {
|
|
const globalSync = useGlobalSync()
|
|
const language = useLanguage()
|
|
const workspace = createMemo(() => {
|
|
const [store, setStore] = globalSync.child(props.project.worktree)
|
|
return { store, setStore }
|
|
})
|
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
|
const children = createMemo(() => childMapByParent(workspace().store.session))
|
|
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
|
const loading = createMemo(() => !booted() && sessions().length === 0)
|
|
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
|
|
const loadMore = async () => {
|
|
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
|
|
await globalSync.project.loadSessions(props.project.worktree)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
|
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
|
>
|
|
<WorkspaceSessionList
|
|
slug={slug}
|
|
mobile={props.mobile}
|
|
popover={props.popover}
|
|
ctx={props.ctx}
|
|
showNew={() => false}
|
|
loading={loading}
|
|
sessions={sessions}
|
|
children={children}
|
|
hasMore={hasMore}
|
|
loadMore={loadMore}
|
|
language={language}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|