diff options
| author | Adam <[email protected]> | 2026-02-06 10:02:31 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 10:02:31 -0600 |
| commit | 2c58dd6203df7806f57ef6b29672091cb764e871 (patch) | |
| tree | 10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/pages/layout | |
| parent | a4bc883595df9ea0f752079519081bc602408553 (diff) | |
| download | opencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip | |
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/pages/layout')
| -rw-r--r-- | packages/app/src/pages/layout/deep-links.ts | 26 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.test.ts | 63 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.ts | 65 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/inline-editor.tsx | 113 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-items.tsx | 330 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project-helpers.test.ts | 63 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project-helpers.ts | 11 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project.tsx | 283 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-shell-helpers.ts | 1 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-shell.test.ts | 13 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-shell.tsx | 109 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace-helpers.ts | 2 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.test.ts | 13 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 387 |
14 files changed, 1479 insertions, 0 deletions
diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts new file mode 100644 index 000000000..772e6ece6 --- /dev/null +++ b/packages/app/src/pages/layout/deep-links.ts @@ -0,0 +1,26 @@ +export const deepLinkEvent = "opencode:deep-link" + +export const parseDeepLink = (input: string) => { + if (!input.startsWith("opencode://")) return + const url = new URL(input) + if (url.hostname !== "open-project") return + const directory = url.searchParams.get("directory") + if (!directory) return + return directory +} + +export const collectOpenProjectDeepLinks = (urls: string[]) => + urls.map(parseDeepLink).filter((directory): directory is string => !!directory) + +type OpenCodeWindow = Window & { + __OPENCODE__?: { + deepLinks?: string[] + } +} + +export const drainPendingDeepLinks = (target: OpenCodeWindow) => { + const pending = target.__OPENCODE__?.deepLinks ?? [] + if (pending.length === 0) return [] + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] + return pending +} diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts new file mode 100644 index 000000000..8a8ea78c7 --- /dev/null +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links" +import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" + +describe("layout deep links", () => { + test("parses open-project deep links", () => { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + }) + + test("ignores non-project deep links", () => { + expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined() + expect(parseDeepLink("https://example.com")).toBeUndefined() + }) + + test("collects only valid open-project directories", () => { + const result = collectOpenProjectDeepLinks([ + "opencode://open-project?directory=/a", + "opencode://other?directory=/b", + "opencode://open-project?directory=/c", + ]) + expect(result).toEqual(["/a", "/c"]) + }) + + test("drains global deep links once", () => { + const target = { + __OPENCODE__: { + deepLinks: ["opencode://open-project?directory=/a"], + }, + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } + + expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) + expect(drainPendingDeepLinks(target)).toEqual([]) + }) +}) + +describe("layout workspace helpers", () => { + test("normalizes trailing slash in workspace key", () => { + expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + }) + + test("keeps local first while preserving known order", () => { + const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) + expect(result).toEqual(["/root", "/c", "/b"]) + }) + + test("extracts draggable id safely", () => { + expect(getDraggableId({ draggable: { id: "x" } })).toBe("x") + expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined() + expect(getDraggableId(null)).toBeUndefined() + }) + + test("formats fallback project display name", () => { + expect(displayName({ worktree: "/tmp/app" })).toBe("app") + expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") + }) + + test("extracts api error message and fallback", () => { + expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom") + expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") + expect(errorMessage("unknown", "fallback")).toBe("fallback") + }) +}) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts new file mode 100644 index 000000000..4d144f34e --- /dev/null +++ b/packages/app/src/pages/layout/helpers.ts @@ -0,0 +1,65 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type Session } from "@opencode-ai/sdk/v2/client" + +export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + +export function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +export const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + +export const childMapByParent = (sessions: Session[]) => { + const map = new Map<string, string[]>() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + +export function getDraggableId(event: unknown): string | undefined { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} + +export const displayName = (project: { name?: string; worktree: string }) => + project.name || getFilename(project.worktree) + +export const errorMessage = (err: unknown, fallback: string) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return fallback +} + +export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => { + if (!existing) return dirs + const keep = existing.filter((d) => d !== local && dirs.includes(d)) + const missing = dirs.filter((d) => d !== local && !existing.includes(d)) + return [local, ...missing, ...keep] +} diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx new file mode 100644 index 000000000..0bbfe244c --- /dev/null +++ b/packages/app/src/pages/layout/inline-editor.tsx @@ -0,0 +1,113 @@ +import { createStore } from "solid-js/store" +import { Show, type Accessor } from "solid-js" +import { InlineInput } from "@opencode-ai/ui/inline-input" + +export function createInlineEditorController() { + const [editor, setEditor] = createStore({ + active: "" as string, + value: "", + }) + + const editorOpen = (id: string) => editor.active === id + const editorValue = () => editor.value + const openEditor = (id: string, value: string) => { + if (!id) return + setEditor({ active: id, value }) + } + const closeEditor = () => setEditor({ active: "", value: "" }) + + const saveEditor = (callback: (next: string) => void) => { + const next = editor.value.trim() + if (!next) { + closeEditor() + return + } + closeEditor() + callback(next) + } + + const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { + if (event.key === "Enter") { + event.preventDefault() + saveEditor(callback) + return + } + if (event.key !== "Escape") return + event.preventDefault() + closeEditor() + } + + const InlineEditor = (props: { + id: string + value: Accessor<string> + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean + }) => { + const isEditing = () => props.editing ?? editorOpen(props.id) + const stopEvents = () => props.stopPropagation ?? false + const allowDblClick = () => props.openOnDblClick ?? true + const stopPropagation = (event: Event) => { + if (!stopEvents()) return + event.stopPropagation() + } + const handleDblClick = (event: MouseEvent) => { + if (!allowDblClick()) return + stopPropagation(event) + openEditor(props.id, props.value()) + } + + return ( + <Show + when={isEditing()} + fallback={ + <span + class={props.displayClass ?? props.class} + onDblClick={handleDblClick} + onPointerDown={stopPropagation} + onMouseDown={stopPropagation} + onClick={stopPropagation} + onTouchStart={stopPropagation} + > + {props.value()} + </span> + } + > + <InlineInput + ref={(el) => { + requestAnimationFrame(() => el.focus()) + }} + value={editorValue()} + class={props.class} + onInput={(event) => setEditor("value", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + editorKeyDown(event, props.onSave) + }} + onBlur={closeEditor} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onDblClick={stopPropagation} + onMouseDown={stopPropagation} + onMouseUp={stopPropagation} + onTouchStart={stopPropagation} + /> + </Show> + ) + } + + return { + editor, + editorOpen, + editorValue, + openEditor, + closeEditor, + saveEditor, + editorKeyDown, + setEditor, + InlineEditor, + } +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx new file mode 100644 index 000000000..facfbddc7 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -0,0 +1,330 @@ +import { A, useNavigate, useParams } from "@solidjs/router" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" +import { useNotification } from "@/context/notification" +import { base64Encode } from "@opencode-ai/util/encode" +import { Avatar } from "@opencode-ai/ui/avatar" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +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 { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" +import { agentColor } from "@/utils/agent" + +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { + const notification = useNotification() + const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) + const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + return ( + <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> + <div class="size-full rounded overflow-clip"> + <Avatar + fallback={name()} + src={ + props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override + } + {...getAvatarColors(props.project.icon?.color)} + class="size-full rounded" + classList={{ "badge-mask": unseenCount() > 0 && props.notify }} + /> + </div> + <Show when={unseenCount() > 0 && props.notify}> + <div + classList={{ + "absolute top-px right-px size-1.5 rounded-full z-10": true, + "bg-icon-critical-base": hasError(), + "bg-text-interactive-base": !hasError(), + }} + /> + </Show> + </div> + ) +} + +export type SessionItemProps = { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean + children: Map<string, 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> +} + +export const SessionItem = (props: SessionItemProps): JSX.Element => { + const params = useParams() + const navigate = useNavigate() + const layout = useLayout() + const language = useLanguage() + const notification = useNotification() + const globalSync = useGlobalSync() + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) + const [sessionStore] = globalSync.child(props.session.directory) + const hasPermissions = createMemo(() => { + const permissions = sessionStore.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + + for (const id of props.children.get(props.session.id) ?? []) { + const childPermissions = sessionStore.permission?.[id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + const status = sessionStore.session_status[props.session.id] + return status?.type === "busy" || status?.type === "retry" + }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agentColor(user.agent, agent?.color) + }) + + const hoverMessages = createMemo(() => + sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + ) + const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + const isActive = createMemo(() => props.session.id === params.id) + + const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined } + const cancelHoverPrefetch = () => { + if (hoverPrefetch.current === undefined) return + clearTimeout(hoverPrefetch.current) + hoverPrefetch.current = undefined + } + const scheduleHoverPrefetch = () => { + if (hoverPrefetch.current !== undefined) return + hoverPrefetch.current = setTimeout(() => { + hoverPrefetch.current = undefined + props.prefetchSession(props.session) + }, 200) + } + + onCleanup(cancelHoverPrefetch) + + const messageLabel = (message: Message) => { + const parts = sessionStore.part[message.id] ?? [] + 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> + ) + + return ( + <div + data-session-id={props.session.id} + class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 + hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" + > + <Show + when={hoverEnabled()} + fallback={ + <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> + {item} + </Tooltip> + } + > + <HoverCard + openDelay={1000} + closeDelay={props.sidebarHovering() ? 600 : 0} + placement="right-start" + gutter={16} + shift={-2} + 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`} + classList={{ + "opacity-100 pointer-events-auto": !!props.mobile, + "opacity-0 pointer-events-none": !props.mobile, + "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, + "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, + }} + > + <Tooltip value={language.t("common.archive")} placement="top"> + <IconButton + icon="archive" + variant="ghost" + class="size-6 rounded-md" + aria-label={language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + </Tooltip> + </div> + </div> + ) +} + +export const NewSessionItem = (props: { + slug: string + mobile?: boolean + dense?: boolean + sidebarExpanded: Accessor<boolean> + clearHoverProjectSoon: () => void + setHoverSession: (id: string | undefined) => void +}): JSX.Element => { + const layout = useLayout() + const language = useLanguage() + const label = language.t("command.session.new") + const tooltip = () => props.mobile || !props.sidebarExpanded() + const item = ( + <A + href={`${props.slug}/session`} + end + class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} + 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"> + <Icon name="plus-small" size="small" class="text-icon-weak" /> + </div> + <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> + {label} + </span> + </div> + </A> + ) + + return ( + <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"> + <Show + when={!tooltip()} + fallback={ + <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}> + {item} + </Tooltip> + } + > + {item} + </Show> + </div> + ) +} + +export const SessionSkeleton = (props: { count?: number }): JSX.Element => { + const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) + return ( + <div class="flex flex-col gap-1"> + <For each={items}> + {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />} + </For> + </div> + ) +} diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts new file mode 100644 index 000000000..75958d49e --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +describe("projectSelected", () => { + test("matches direct worktree", () => { + expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true) + }) + + test("matches sandbox worktree", () => { + expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true) + expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false) + }) +}) + +describe("projectTileActive", () => { + test("menu state always wins", () => { + expect( + projectTileActive({ + menu: true, + preview: false, + open: false, + overlay: false, + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("preview mode uses open state", () => { + expect( + projectTileActive({ + menu: false, + preview: true, + open: true, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("overlay mode uses hovered project", () => { + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/root", + worktree: "/tmp/root", + }), + ).toBe(true) + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.ts b/packages/app/src/pages/layout/sidebar-project-helpers.ts new file mode 100644 index 000000000..06d38a3cd --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project-helpers.ts @@ -0,0 +1,11 @@ +export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) => + worktree === currentDir || sandboxes?.includes(currentDir) === true + +export const projectTileActive = (args: { + menu: boolean + preview: boolean + open: boolean + overlay: boolean + hoverProject?: string + worktree: string +}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx new file mode 100644 index 000000000..c91dc987d --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -0,0 +1,283 @@ +import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" +import { base64Encode } from "@opencode-ai/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { ContextMenu } from "@opencode-ai/ui/context-menu" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { createSortable } from "@thisbeyond/solid-dnd" +import { type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" +import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +export type ProjectSidebarContext = { + currentDir: Accessor<string> + sidebarOpened: Accessor<boolean> + sidebarHovering: Accessor<boolean> + hoverProject: Accessor<string | undefined> + nav: Accessor<HTMLElement | undefined> + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + openSidebar: () => void + closeProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + workspaceIds: (project: LocalProject) => string[] + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string + sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover"> + setHoverSession: (id: string | undefined) => void +} + +export const ProjectDragOverlay = (props: { + projects: Accessor<LocalProject[]> + activeProject: Accessor<string | undefined> +}): JSX.Element => { + const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject())) + return ( + <Show when={project()}> + {(p) => ( + <div class="bg-background-base rounded-xl p-1"> + <ProjectIcon project={p()} /> + </div> + )} + </Show> + ) +} + +export const SortableProject = (props: { + project: LocalProject + mobile?: boolean + ctx: ProjectSidebarContext +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => + projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes), + ) + const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) + const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) + + const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) + const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) + const active = createMemo(() => + projectTileActive({ + menu: menu(), + preview: preview(), + open: open(), + overlay: overlay(), + hoverProject: props.ctx.hoverProject(), + worktree: props.project.worktree, + }), + ) + + createEffect(() => { + if (preview()) return + if (!open()) return + setOpen(false) + }) + + const label = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id) + return `${kind} : ${name}` + } + + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return sortedRootSessions(data, Date.now()).slice(0, 2) + } + const workspaceChildren = (directory: string) => { + 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> + ) + + return ( + // @ts-ignore + <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> + <Show when={preview()} fallback={<Trigger />}> + <HoverCard + open={open() && !menu()} + openDelay={0} + closeDelay={0} + placement="right-start" + gutter={6} + 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> + </HoverCard> + </Show> + </div> + ) +} diff --git a/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/packages/app/src/pages/layout/sidebar-shell-helpers.ts new file mode 100644 index 000000000..93c286c15 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell-helpers.ts @@ -0,0 +1 @@ +export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened diff --git a/packages/app/src/pages/layout/sidebar-shell.test.ts b/packages/app/src/pages/layout/sidebar-shell.test.ts new file mode 100644 index 000000000..694025a65 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +describe("sidebarExpanded", () => { + test("expands on mobile regardless of desktop open state", () => { + expect(sidebarExpanded(true, false)).toBe(true) + }) + + test("follows desktop open state when not mobile", () => { + expect(sidebarExpanded(false, true)).toBe(true) + expect(sidebarExpanded(false, false)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx new file mode 100644 index 000000000..ce96a09d1 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -0,0 +1,109 @@ +import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { type LocalProject } from "@/context/layout" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +export const SidebarContent = (props: { + mobile?: boolean + opened: Accessor<boolean> + aimMove: (event: MouseEvent) => void + projects: Accessor<LocalProject[]> + renderProject: (project: LocalProject) => JSX.Element + handleDragStart: (event: unknown) => void + handleDragEnd: () => void + handleDragOver: (event: DragEvent) => void + openProjectLabel: JSX.Element + openProjectKeybind: Accessor<string | undefined> + onOpenProject: () => void + renderProjectOverlay: () => JSX.Element + settingsLabel: Accessor<string> + settingsKeybind: Accessor<string | undefined> + onOpenSettings: () => void + helpLabel: Accessor<string> + onOpenHelp: () => void + renderPanel: () => JSX.Element +}): JSX.Element => { + const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) + + return ( + <div class="flex h-full w-full overflow-hidden"> + <div + class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" + onMouseMove={props.aimMove} + > + <div class="flex-1 min-h-0 w-full"> + <DragDropProvider + onDragStart={props.handleDragStart} + onDragEnd={props.handleDragEnd} + onDragOver={props.handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragXAxis /> + <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar"> + <SortableProvider ids={props.projects().map((p) => p.worktree)}> + <For each={props.projects()}>{(project) => props.renderProject(project)}</For> + </SortableProvider> + <Tooltip + placement={props.mobile ? "bottom" : "right"} + value={ + <div class="flex items-center gap-2"> + <span>{props.openProjectLabel}</span> + <Show when={!props.mobile && !!props.openProjectKeybind()}> + <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span> + </Show> + </div> + } + > + <IconButton + icon="plus" + variant="ghost" + size="large" + onClick={props.onOpenProject} + aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined} + /> + </Tooltip> + </div> + <DragOverlay>{props.renderProjectOverlay()}</DragOverlay> + </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() ?? ""} + > + <IconButton + icon="settings-gear" + variant="ghost" + size="large" + onClick={props.onOpenSettings} + aria-label={props.settingsLabel()} + /> + </TooltipKeybind> + <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}> + <IconButton + icon="help" + variant="ghost" + size="large" + onClick={props.onOpenHelp} + aria-label={props.helpLabel()} + /> + </Tooltip> + </div> + </div> + + <Show when={expanded()}>{props.renderPanel()}</Show> + </div> + ) +} diff --git a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts new file mode 100644 index 000000000..aa7cb480e --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts @@ -0,0 +1,2 @@ +export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) => + expanded[directory] ?? local diff --git a/packages/app/src/pages/layout/sidebar-workspace.test.ts b/packages/app/src/pages/layout/sidebar-workspace.test.ts new file mode 100644 index 000000000..d71c39fc8 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { workspaceOpenState } from "./sidebar-workspace-helpers" + +describe("workspaceOpenState", () => { + test("defaults to local workspace open", () => { + expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true) + }) + + test("uses persisted expansion state when present", () => { + expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false) + expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx new file mode 100644 index 000000000..11bad84b0 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -0,0 +1,387 @@ +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 { 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> + ) +} + +export const SortableWorkspace = (props: { + ctx: WorkspaceSidebarContext + directory: string + project: LocalProject + mobile?: boolean +}): JSX.Element => { + 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, Date.now())) + 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 loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`)) + + 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 }) + }) + + 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> + <Icon + name={open() ? "chevron-down" : "chevron-right"} + size="small" + class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100" + /> + </div> + ) + + 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 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover" + data-action="workspace-toggle" + data-workspace={base64Encode(props.directory)} + > + {header()} + </Collapsible.Trigger> + } + > + <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</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> + </div> + </div> + </div> + </div> + + <Collapsible.Content> + <nav class="flex flex-col gap-1 px-2"> + <NewSessionItem + slug={slug()} + mobile={props.mobile} + sidebarExpanded={props.ctx.sidebarExpanded} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + setHoverSession={props.ctx.setHoverSession} + /> + <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> + </Collapsible.Content> + </Collapsible> + </div> + ) +} + +export const LocalWorkspace = (props: { + ctx: WorkspaceSidebarContext + project: LocalProject + mobile?: 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, Date.now())) + 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 + 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]" + > + <nav class="flex flex-col gap-1 px-2"> + <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> + </div> + ) +} |
