feat(app): new layout
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { createMemo, createResource, Show } from "solid-js"
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
@@ -57,16 +58,14 @@ export function SessionHeader() {
|
|||||||
navigate(`/${params.dir}/session/${session.id}`)
|
navigate(`/${params.dir}/session/${session.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left"))
|
||||||
|
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
|
<>
|
||||||
<button
|
<Show when={leftMount()}>
|
||||||
type="button"
|
{(mount) => (
|
||||||
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
|
<Portal mount={mount()}>
|
||||||
onClick={layout.mobileSidebar.toggle}
|
|
||||||
>
|
|
||||||
<Icon name="menu" size="small" />
|
|
||||||
</button>
|
|
||||||
<div class="px-4 flex items-center justify-between gap-4 w-full">
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="hidden xl:flex items-center gap-2">
|
<div class="hidden xl:flex items-center gap-2">
|
||||||
@@ -113,7 +112,6 @@ export function SessionHeader() {
|
|||||||
label={(x) => x.title}
|
label={(x) => x.title}
|
||||||
value={(x) => x.id}
|
value={(x) => x.id}
|
||||||
onSelect={(session) => {
|
onSelect={(session) => {
|
||||||
// Only navigate if selecting a different session than current parent
|
|
||||||
const currentParent = parentSession()
|
const currentParent = parentSession()
|
||||||
if (session && currentParent && session.id !== currentParent.id) {
|
if (session && currentParent && session.id !== currentParent.id) {
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
@@ -123,7 +121,6 @@ export function SessionHeader() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
<div class="text-text-weaker">/</div>
|
<div class="text-text-weaker">/</div>
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
|
||||||
<Tooltip value="Back to parent session">
|
<Tooltip value="Back to parent session">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -134,7 +131,6 @@ export function SessionHeader() {
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={currentSession() && !parentSession()}>
|
<Show when={currentSession() && !parentSession()}>
|
||||||
@@ -143,6 +139,12 @@ export function SessionHeader() {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={rightMount()}>
|
||||||
|
{(mount) => (
|
||||||
|
<Portal mount={mount()}>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="hidden md:flex items-center gap-1">
|
<div class="hidden md:flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -203,7 +205,11 @@ export function SessionHeader() {
|
|||||||
title="Toggle terminal"
|
title="Toggle terminal"
|
||||||
keybind={command.keybind("terminal.toggle")}
|
keybind={command.keybind("terminal.toggle")}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/terminal-toggle size-6 p-0"
|
||||||
|
onClick={() => view().terminal.toggle()}
|
||||||
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
<Icon
|
<Icon
|
||||||
size="small"
|
size="small"
|
||||||
@@ -261,7 +267,9 @@ export function SessionHeader() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Portal>
|
||||||
</header>
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,12 +124,19 @@ function createGlobalSync() {
|
|||||||
return globalSDK.client.session
|
return globalSDK.client.session
|
||||||
.list({ directory, roots: true })
|
.list({ directory, roots: true })
|
||||||
.then((x) => {
|
.then((x) => {
|
||||||
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
|
||||||
const nonArchived = (x.data ?? [])
|
const nonArchived = (x.data ?? [])
|
||||||
.filter((s) => !!s?.id)
|
.filter((s) => !!s?.id)
|
||||||
.filter((s) => !s.time?.archived)
|
.filter((s) => !s.time?.archived)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.id.localeCompare(b.id))
|
.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
|
||||||
|
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
|
||||||
|
if (sandboxWorkspace) {
|
||||||
|
setStore("session", reconcile(nonArchived, { key: "id" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
|
||||||
// Include up to the limit, plus any updated in the last 4 hours
|
// Include up to the limit, plus any updated in the last 4 hours
|
||||||
const sessions = nonArchived.filter((s, i) => {
|
const sessions = nonArchived.filter((s, i) => {
|
||||||
if (i < limit) return true
|
if (i < limit) return true
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export type Platform = {
|
|||||||
/** Platform discriminator */
|
/** Platform discriminator */
|
||||||
platform: "web" | "desktop"
|
platform: "web" | "desktop"
|
||||||
|
|
||||||
|
/** Desktop OS (Tauri only) */
|
||||||
|
os?: "macos" | "windows" | "linux"
|
||||||
|
|
||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
untrack,
|
untrack,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { DateTime } from "luxon"
|
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -27,9 +26,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
|||||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
||||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
@@ -53,7 +50,6 @@ import { retry } from "@opencode-ai/util/retry"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
|
||||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||||
@@ -64,16 +60,12 @@ import { useServer } from "@/context/server"
|
|||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
lastSession: {} as { [directory: string]: string },
|
lastSession: {} as { [directory: string]: string },
|
||||||
activeDraggable: undefined as string | undefined,
|
activeProject: undefined as string | undefined,
|
||||||
mobileProjectsExpanded: {} as Record<string, boolean>,
|
activeWorkspace: undefined as string | undefined,
|
||||||
|
workspaceOrder: {} as Record<string, string[]>,
|
||||||
|
workspaceExpanded: {} as Record<string, boolean>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mobileProjects = {
|
|
||||||
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
|
|
||||||
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
|
|
||||||
collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||||
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||||
@@ -275,6 +267,31 @@ export default function Layout(props: ParentProps) {
|
|||||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const project = currentProject()
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
|
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
|
const existing = store.workspaceOrder[project.worktree]
|
||||||
|
if (!existing) {
|
||||||
|
setStore("workspaceOrder", project.worktree, dirs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keep = existing.filter((d) => dirs.includes(d))
|
||||||
|
const missing = dirs.filter((d) => !existing.includes(d))
|
||||||
|
const merged = [...keep, ...missing]
|
||||||
|
|
||||||
|
if (merged.length !== existing.length) {
|
||||||
|
setStore("workspaceOrder", project.worktree, merged)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.some((d, i) => d !== existing[i])) {
|
||||||
|
setStore("workspaceOrder", project.worktree, merged)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function projectSessions(project: LocalProject | undefined) {
|
function projectSessions(project: LocalProject | undefined) {
|
||||||
if (!project) return []
|
if (!project) return []
|
||||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
@@ -325,7 +342,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefetchMessages = (directory: string, sessionID: string, token: number) => {
|
async function prefetchMessages(directory: string, sessionID: string, token: number) {
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
|
|
||||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||||
@@ -704,18 +721,17 @@ export default function Layout(props: ParentProps) {
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
setStore("lastSession", directory, id)
|
setStore("lastSession", directory, id)
|
||||||
notification.session.markViewed(id)
|
notification.session.markViewed(id)
|
||||||
const project = currentProject()
|
untrack(() => setStore("workspaceExpanded", directory, true))
|
||||||
untrack(() => layout.projects.expand(project?.worktree ?? directory))
|
|
||||||
requestAnimationFrame(() => scrollToSession(id))
|
requestAnimationFrame(() => scrollToSession(id))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isLargeViewport()) {
|
if (isLargeViewport()) {
|
||||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64
|
||||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||||
} else {
|
return
|
||||||
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
|
|
||||||
}
|
}
|
||||||
|
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
|
||||||
})
|
})
|
||||||
|
|
||||||
function getDraggableId(event: unknown): string | undefined {
|
function getDraggableId(event: unknown): string | undefined {
|
||||||
@@ -729,7 +745,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
function handleDragStart(event: unknown) {
|
function handleDragStart(event: unknown) {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setStore("activeDraggable", id)
|
setStore("activeProject", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragOver(event: DragEvent) {
|
function handleDragOver(event: DragEvent) {
|
||||||
@@ -745,44 +761,73 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd() {
|
function handleDragEnd() {
|
||||||
setStore("activeDraggable", undefined)
|
setStore("activeProject", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectAvatar = (props: {
|
function workspaceIds(project: LocalProject | undefined) {
|
||||||
project: LocalProject
|
if (!project) return []
|
||||||
class?: string
|
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
expandable?: boolean
|
const existing = store.workspaceOrder[project.worktree]
|
||||||
notify?: boolean
|
if (!existing) return dirs
|
||||||
}): JSX.Element => {
|
|
||||||
|
const keep = existing.filter((d) => dirs.includes(d))
|
||||||
|
const missing = dirs.filter((d) => !existing.includes(d))
|
||||||
|
return [...keep, ...missing]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkspaceDragStart(event: unknown) {
|
||||||
|
const id = getDraggableId(event)
|
||||||
|
if (!id) return
|
||||||
|
setStore("activeWorkspace", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkspaceDragOver(event: DragEvent) {
|
||||||
|
const { draggable, droppable } = event
|
||||||
|
if (!draggable || !droppable) return
|
||||||
|
|
||||||
|
const project = currentProject()
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
|
const ids = workspaceIds(project)
|
||||||
|
const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
|
||||||
|
const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return
|
||||||
|
if (fromIndex === toIndex) return
|
||||||
|
|
||||||
|
const result = ids.slice()
|
||||||
|
const [item] = result.splice(fromIndex, 1)
|
||||||
|
if (!item) return
|
||||||
|
result.splice(toIndex, 0, item)
|
||||||
|
setStore("workspaceOrder", project.worktree, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkspaceDragEnd() {
|
||||||
|
setStore("activeWorkspace", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
||||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||||
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)"
|
||||||
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-5 shrink-0 rounded-sm">
|
<div class={`relative size-10 shrink-0 ${props.class ?? ""}`}>
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={name()}
|
fallback={name()}
|
||||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||||
{...getAvatarColors(props.project.icon?.color)}
|
{...getAvatarColors(props.project.icon?.color)}
|
||||||
class={`size-full ${props.class ?? ""}`}
|
class="size-full rounded-lg"
|
||||||
style={
|
style={
|
||||||
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
|
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Show when={props.expandable}>
|
|
||||||
<Icon
|
|
||||||
name="chevron-right"
|
|
||||||
size="normal"
|
|
||||||
class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={notifications().length > 0 && props.notify}>
|
<Show when={notifications().length > 0 && props.notify}>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
|
"absolute -top-0.5 -right-0.5 size-2 rounded-full": true,
|
||||||
"bg-icon-critical-base": hasError(),
|
"bg-icon-critical-base": hasError(),
|
||||||
"bg-text-interactive-base": !hasError(),
|
"bg-text-interactive-base": !hasError(),
|
||||||
}}
|
}}
|
||||||
@@ -792,47 +837,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
|
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean }): JSX.Element => {
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
|
||||||
const current = createMemo(() => base64Decode(params.dir ?? ""))
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
<Match when={layout.sidebar.opened()}>
|
|
||||||
<Button
|
|
||||||
as={"div"}
|
|
||||||
variant="ghost"
|
|
||||||
data-active
|
|
||||||
class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
|
|
||||||
<ProjectAvatar project={props.project} />
|
|
||||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
|
|
||||||
data-selected={props.project.worktree === current()}
|
|
||||||
onClick={() => navigateToProject(props.project.worktree)}
|
|
||||||
>
|
|
||||||
<ProjectAvatar project={props.project} notify />
|
|
||||||
</Button>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionItem = (props: {
|
|
||||||
session: Session
|
|
||||||
slug: string
|
|
||||||
project: LocalProject
|
|
||||||
mobile?: boolean
|
|
||||||
}): JSX.Element => {
|
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
|
||||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||||
const [sessionStore] = globalSync.child(props.session.directory)
|
const [sessionStore] = globalSync.child(props.session.directory)
|
||||||
@@ -852,8 +858,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
const status = sessionStore.session_status[props.session.id]
|
const status = sessionStore.session_status[props.session.id]
|
||||||
return status?.type === "busy" || status?.type === "retry"
|
return status?.type === "busy" || status?.type === "retry"
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
data-session-id={props.session.id}
|
data-session-id={props.session.id}
|
||||||
class="group/session relative w-full rounded-md cursor-default transition-colors
|
class="group/session relative w-full rounded-md cursor-default transition-colors
|
||||||
@@ -862,11 +868,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
href={`${props.slug}/session/${props.session.id}`}
|
||||||
class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
|
class="flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1 transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"
|
||||||
onMouseEnter={() => prefetchSession(props.session, "high")}
|
onMouseEnter={() => prefetchSession(props.session, "high")}
|
||||||
onFocus={() => prefetchSession(props.session, "high")}
|
onFocus={() => prefetchSession(props.session, "high")}
|
||||||
>
|
>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
|
|
||||||
<span
|
<span
|
||||||
classList={{
|
classList={{
|
||||||
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
|
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
|
||||||
@@ -875,44 +880,23 @@ export default function Layout(props: ParentProps) {
|
|||||||
>
|
>
|
||||||
{props.session.title}
|
{props.session.title}
|
||||||
</span>
|
</span>
|
||||||
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={isWorking()}>
|
<Match when={isWorking()}>
|
||||||
<Spinner class="size-2.5 mr-0.5" />
|
<Spinner class="size-2.5" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={hasPermissions()}>
|
<Match when={hasPermissions()}>
|
||||||
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={hasError()}>
|
<Match when={hasError()}>
|
||||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={notifications().length > 0}>
|
<Match when={notifications().length > 0}>
|
||||||
<div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
|
||||||
{Math.abs(updated().diffNow().as("seconds")) < 60
|
|
||||||
? "Now"
|
|
||||||
: updated()
|
|
||||||
.toRelative({
|
|
||||||
style: "short",
|
|
||||||
unit: ["days", "hours", "minutes"],
|
|
||||||
})
|
|
||||||
?.replace(" ago", "")
|
|
||||||
?.replace(/ days?/, "d")
|
|
||||||
?.replace(" min.", "m")
|
|
||||||
?.replace(" hr.", "h")}
|
|
||||||
</span>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={props.session.summary?.files}>
|
|
||||||
<div class="flex justify-between items-center self-stretch">
|
|
||||||
<span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
|
|
||||||
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
|
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
</A>
|
</A>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
|
||||||
@@ -925,132 +909,135 @@ export default function Layout(props: ParentProps) {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
|
|
||||||
const defaultWorktree = createMemo(() => base64Encode(props.project.worktree))
|
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
const selected = createMemo(() => {
|
||||||
const stores = createMemo(() =>
|
|
||||||
[props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]),
|
|
||||||
)
|
|
||||||
const sessions = createMemo(() =>
|
|
||||||
stores()
|
|
||||||
.flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
|
|
||||||
.toSorted(sortSessions),
|
|
||||||
)
|
|
||||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
|
||||||
const hasMoreSessions = createMemo(() => store.sessionTotal > store.session.length)
|
|
||||||
const loadMoreSessions = async () => {
|
|
||||||
setProjectStore("limit", (limit) => limit + 5)
|
|
||||||
await globalSync.project.loadSessions(props.project.worktree)
|
|
||||||
}
|
|
||||||
const isExpanded = createMemo(() =>
|
|
||||||
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
|
||||||
)
|
|
||||||
const isActive = createMemo(() => {
|
|
||||||
const current = params.dir ? base64Decode(params.dir) : ""
|
const current = params.dir ? base64Decode(params.dir) : ""
|
||||||
return props.project.worktree === current || props.project.sandboxes?.includes(current)
|
return props.project.worktree === current || props.project.sandboxes?.includes(current)
|
||||||
})
|
})
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (props.mobile) {
|
|
||||||
if (open) mobileProjects.expand(props.project.worktree)
|
|
||||||
else mobileProjects.collapse(props.project.worktree)
|
|
||||||
} else {
|
|
||||||
if (open) layout.projects.expand(props.project.worktree)
|
|
||||||
else layout.projects.collapse(props.project.worktree)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
<Switch>
|
<Tooltip placement={props.mobile ? "bottom" : "right"} value={name()}>
|
||||||
<Match when={showExpanded()}>
|
|
||||||
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
|
|
||||||
<Button
|
<Button
|
||||||
as={"div"}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
classList={{
|
size="large"
|
||||||
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
|
class="flex items-center justify-center p-0 size-12 rounded-xl"
|
||||||
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
|
data-selected={selected()}
|
||||||
}}
|
onClick={() => navigateToProject(props.project.worktree)}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
<ProjectIcon project={props.project} notify />
|
||||||
<ProjectAvatar
|
|
||||||
project={props.project}
|
|
||||||
class="group-hover/session:hidden"
|
|
||||||
expandable
|
|
||||||
notify={!isExpanded()}
|
|
||||||
/>
|
|
||||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
|
|
||||||
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
|
|
||||||
<IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Collapsible.Content>
|
|
||||||
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
|
||||||
<For each={rootSessions()}>
|
|
||||||
{(session) => (
|
|
||||||
<SessionItem
|
|
||||||
session={session}
|
|
||||||
slug={base64Encode(session.directory)}
|
|
||||||
project={props.project}
|
|
||||||
mobile={props.mobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={rootSessions().length === 0}>
|
|
||||||
<div
|
|
||||||
class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
|
|
||||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
|
||||||
>
|
|
||||||
<div class="flex items-center self-stretch w-full">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
|
|
||||||
<A
|
|
||||||
href={`${defaultWorktree()}/session`}
|
|
||||||
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
|
|
||||||
>
|
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
|
||||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
|
||||||
New session
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</A>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectDragOverlay = (): JSX.Element => {
|
||||||
|
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
|
||||||
|
return (
|
||||||
|
<Show when={project()}>
|
||||||
|
{(p) => (
|
||||||
|
<div class="bg-background-base rounded-xl p-1">
|
||||||
|
<ProjectIcon project={p()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={hasMoreSessions()}>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceDragOverlay = (): JSX.Element => {
|
||||||
|
const label = createMemo(() => {
|
||||||
|
const project = currentProject()
|
||||||
|
if (!project) return
|
||||||
|
const directory = store.activeWorkspace
|
||||||
|
if (!directory) return
|
||||||
|
|
||||||
|
const [workspaceStore] = globalSync.child(directory)
|
||||||
|
const kind = directory === project.worktree ? "local" : "sandbox"
|
||||||
|
const name = workspaceStore.vcs?.branch ?? getFilename(directory)
|
||||||
|
return `${kind} : ${name}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={label()}>
|
||||||
|
{(value) => (
|
||||||
|
<div class="bg-background-base rounded-md px-2 py-1 text-12-medium text-text-strong">{value()}</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||||
|
const sortable = createSortable(props.directory)
|
||||||
|
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
|
||||||
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
|
const sessions = createMemo(() =>
|
||||||
|
workspaceStore.session
|
||||||
|
.filter((session) => session.directory === workspaceStore.path.directory)
|
||||||
|
.filter((session) => !session.parentID)
|
||||||
|
.toSorted(sortSessions),
|
||||||
|
)
|
||||||
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
|
const title = createMemo(() => {
|
||||||
|
const kind = local() ? "local" : "sandbox"
|
||||||
|
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
|
||||||
|
return `${kind} : ${name}`
|
||||||
|
})
|
||||||
|
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
|
||||||
|
const hasMore = createMemo(() => local() && workspaceStore.session.length >= workspaceStore.limit)
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!local()) return
|
||||||
|
setWorkspaceStore("limit", (limit) => limit + 5)
|
||||||
|
await globalSync.project.loadSessions(props.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// @ts-ignore
|
||||||
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
|
<Collapsible
|
||||||
|
variant="ghost"
|
||||||
|
open={open()}
|
||||||
|
class="gap-1.5 shrink-0"
|
||||||
|
onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)}
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger class="group/trigger flex items-center justify-between w-full px-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<Icon
|
||||||
|
name="chevron-right"
|
||||||
|
size="small"
|
||||||
|
class="text-text-subtle transition-transform duration-50 group-data-[expanded]/trigger:rotate-90"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-12-medium text-text-strong">{title()}</span>
|
||||||
|
</div>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<nav class="flex flex-col gap-1 pl-2">
|
||||||
|
<For each={sessions()}>
|
||||||
|
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
|
||||||
|
</For>
|
||||||
|
<Button
|
||||||
|
as={A}
|
||||||
|
href={`${slug()}/session`}
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
icon="plus-small"
|
||||||
|
class="flex w-full text-left justify-start text-text-base rounded-md px-3"
|
||||||
|
>
|
||||||
|
New session
|
||||||
|
</Button>
|
||||||
|
<Show when={hasMore()}>
|
||||||
<div class="relative w-full py-1">
|
<div class="relative w-full py-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
|
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={loadMoreSessions}
|
onClick={loadMore}
|
||||||
>
|
>
|
||||||
Load more
|
Load more
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1059,87 +1046,55 @@ export default function Layout(props: ParentProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
|
|
||||||
<ProjectVisual project={props.project} />
|
|
||||||
</Tooltip>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectDragOverlay = (): JSX.Element => {
|
|
||||||
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
|
|
||||||
return (
|
|
||||||
<Show when={project()}>
|
|
||||||
{(p) => (
|
|
||||||
<div class="bg-background-base rounded-md">
|
|
||||||
<ProjectVisual project={p()} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
|
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
|
||||||
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
|
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
|
||||||
|
|
||||||
|
const project = createMemo(() => currentProject())
|
||||||
|
const projectName = createMemo(() => {
|
||||||
|
const current = project()
|
||||||
|
if (!current) return ""
|
||||||
|
return current.name || getFilename(current.worktree)
|
||||||
|
})
|
||||||
|
const workspaces = createMemo(() => workspaceIds(project()))
|
||||||
|
|
||||||
|
const errorMessage = (err: unknown) => {
|
||||||
|
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 "Request failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
const createWorkspace = async () => {
|
||||||
|
const current = project()
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
const created = await globalSDK.client.worktree
|
||||||
|
.create({ directory: current.worktree })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: "Failed to create workspace",
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!created?.directory) return
|
||||||
|
|
||||||
|
globalSync.child(created.directory)
|
||||||
|
navigate(`/${base64Encode(created.directory)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
|
<div class="flex h-full w-full overflow-hidden">
|
||||||
<div class="flex flex-col items-start self-stretch gap-4 min-h-0">
|
<div class="w-16 shrink-0 bg-background-base border-r border-border-weak-base flex flex-col items-center overflow-hidden">
|
||||||
<Show when={!sidebarProps.mobile}>
|
<div class="flex-1 min-h-0 w-full">
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
|
|
||||||
"justify-start": expanded(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
|
|
||||||
<Mark class="shrink-0" />
|
|
||||||
</A>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
|
|
||||||
<Show when={!sidebarProps.mobile}>
|
|
||||||
<TooltipKeybind
|
|
||||||
class="shrink-0"
|
|
||||||
placement="right"
|
|
||||||
title="Toggle sidebar"
|
|
||||||
keybind={command.keybind("sidebar.toggle")}
|
|
||||||
inactive={expanded()}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
|
|
||||||
onClick={layout.sidebar.toggle}
|
|
||||||
>
|
|
||||||
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
||||||
<Icon
|
|
||||||
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
|
|
||||||
size="small"
|
|
||||||
class="group-hover/sidebar-toggle:hidden"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
|
|
||||||
size="small"
|
|
||||||
class="hidden group-hover/sidebar-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
|
|
||||||
size="small"
|
|
||||||
class="hidden group-active/sidebar-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={layout.sidebar.opened()}>
|
|
||||||
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
|
|
||||||
Toggle sidebar
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
@@ -1148,61 +1103,14 @@ export default function Layout(props: ParentProps) {
|
|||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
<ConstrainDragXAxis />
|
<ConstrainDragXAxis />
|
||||||
<div
|
<div class="h-full w-full flex flex-col items-center gap-2 px-2 py-3 overflow-y-auto no-scrollbar">
|
||||||
ref={(el) => {
|
|
||||||
if (!sidebarProps.mobile) scrollContainerRef = el
|
|
||||||
}}
|
|
||||||
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
|
||||||
>
|
|
||||||
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
||||||
<For each={layout.projects.list()}>
|
<For each={layout.projects.list()}>
|
||||||
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
|
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
|
||||||
</For>
|
</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
</div>
|
|
||||||
<DragOverlay>
|
|
||||||
<ProjectDragOverlay />
|
|
||||||
</DragOverlay>
|
|
||||||
</DragDropProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
|
||||||
<Switch>
|
|
||||||
<Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
|
|
||||||
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
|
|
||||||
<div class="p-3 flex flex-col gap-2">
|
|
||||||
<div class="text-12-medium text-text-strong">Getting started</div>
|
|
||||||
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
|
|
||||||
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
|
|
||||||
</div>
|
|
||||||
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
|
||||||
<Button
|
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
onClick={connectProvider}
|
|
||||||
>
|
|
||||||
Connect provider
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={providers.all().length > 0}>
|
|
||||||
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
|
|
||||||
<Button
|
|
||||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
|
||||||
variant="ghost"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
onClick={connectProvider}
|
|
||||||
>
|
|
||||||
<Show when={expanded()}>Connect provider</Show>
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="right"
|
placement={sidebarProps.mobile ? "bottom" : "right"}
|
||||||
value={
|
value={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>Open project</span>
|
<span>Open project</span>
|
||||||
@@ -1211,19 +1119,80 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
inactive={expanded()}
|
|
||||||
>
|
>
|
||||||
|
<IconButton icon="plus" variant="ghost" class="size-12 rounded-xl" onClick={chooseProject} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<DragOverlay>
|
||||||
|
<ProjectDragOverlay />
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex flex-col min-h-0 bg-background-base border-r border-border-weak-base": true,
|
||||||
|
"flex-1 min-w-0": sidebarProps.mobile,
|
||||||
|
}}
|
||||||
|
style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||||
|
>
|
||||||
|
<Show when={project()}>
|
||||||
|
{(p) => (
|
||||||
|
<>
|
||||||
|
<div class="shrink-0 h-12 flex items-center justify-between px-3 border-b border-border-weak-base">
|
||||||
|
<div class="min-w-0 truncate text-14-medium text-text-strong">{projectName()}</div>
|
||||||
|
<Button variant="ghost" size="large" icon="plus-small" onClick={createWorkspace}>
|
||||||
|
New workspace
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<DragDropProvider
|
||||||
|
onDragStart={handleWorkspaceDragStart}
|
||||||
|
onDragEnd={handleWorkspaceDragEnd}
|
||||||
|
onDragOver={handleWorkspaceDragOver}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
|
<DragDropSensors />
|
||||||
|
<ConstrainDragXAxis />
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
if (!sidebarProps.mobile) scrollContainerRef = el
|
||||||
|
}}
|
||||||
|
class="h-full w-full flex flex-col gap-2 px-2 py-2 overflow-y-auto no-scrollbar"
|
||||||
|
>
|
||||||
|
<SortableProvider ids={workspaces()}>
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(directory) => (
|
||||||
|
<SortableWorkspace directory={directory} project={p()} mobile={sidebarProps.mobile} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
</div>
|
||||||
|
<DragOverlay>
|
||||||
|
<WorkspaceDragOverlay />
|
||||||
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={!project()}>
|
||||||
|
<div class="p-3 text-12-regular text-text-weak">Open a project to see workspaces.</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={providers.all().length > 0}>
|
||||||
|
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base flex flex-col gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
icon="folder-add-left"
|
icon="plus"
|
||||||
onClick={chooseProject}
|
onClick={connectProvider}
|
||||||
>
|
>
|
||||||
<Show when={expanded()}>Open project</Show>
|
Connect provider
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
|
|
||||||
<Button
|
<Button
|
||||||
as={"a"}
|
as={"a"}
|
||||||
href="https://opencode.ai/desktop-feedback"
|
href="https://opencode.ai/desktop-feedback"
|
||||||
@@ -1233,40 +1202,78 @@ export default function Layout(props: ParentProps) {
|
|||||||
size="large"
|
size="large"
|
||||||
icon="bubble-5"
|
icon="bubble-5"
|
||||||
>
|
>
|
||||||
<Show when={expanded()}>Share feedback</Show>
|
Share feedback
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
|
const reserveWindowButtons = createMemo(
|
||||||
|
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
|
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex items-center">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex items-center w-full min-w-0 pr-2": true,
|
||||||
|
"pl-2": !isMac(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={isMac()}>
|
||||||
|
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
|
||||||
|
</Show>
|
||||||
|
<IconButton
|
||||||
|
icon="menu"
|
||||||
|
variant="ghost"
|
||||||
|
class="xl:hidden size-8 rounded-md"
|
||||||
|
onClick={layout.mobileSidebar.toggle}
|
||||||
|
/>
|
||||||
|
<TooltipKeybind
|
||||||
|
class="hidden xl:flex shrink-0"
|
||||||
|
placement="bottom"
|
||||||
|
title="Toggle sidebar"
|
||||||
|
keybind={command.keybind("sidebar.toggle")}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={layout.sidebar.opened() ? "layout-left" : "layout-right"}
|
||||||
|
variant="ghost"
|
||||||
|
class="size-8 rounded-md"
|
||||||
|
onClick={layout.sidebar.toggle}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
|
<div class="flex-1 h-full" data-tauri-drag-region />
|
||||||
|
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" />
|
||||||
|
<Show when={reserveWindowButtons()}>
|
||||||
|
<div class="w-[120px] h-full shrink-0" data-tauri-drag-region />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<div class="flex-1 min-h-0 flex">
|
<div class="flex-1 min-h-0 flex">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"hidden xl:block": true,
|
"hidden xl:block": true,
|
||||||
"relative shrink-0": true,
|
"relative shrink-0": true,
|
||||||
}}
|
}}
|
||||||
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "64px" }}
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"@container w-full h-full pb-5 bg-background-base": true,
|
|
||||||
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
|
|
||||||
"border-r border-border-weak-base contain-strict": true,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<div class="@container w-full h-full contain-strict">
|
||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</div>
|
</div>
|
||||||
<Show when={layout.sidebar.opened()}>
|
<Show when={layout.sidebar.opened()}>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
size={layout.sidebar.width()}
|
size={layout.sidebar.width()}
|
||||||
min={150}
|
min={214}
|
||||||
max={window.innerWidth * 0.3}
|
max={window.innerWidth * 0.3 + 64}
|
||||||
collapseThreshold={80}
|
collapseThreshold={144}
|
||||||
onResize={layout.sidebar.resize}
|
onResize={layout.sidebar.resize}
|
||||||
onCollapse={layout.sidebar.close}
|
onCollapse={layout.sidebar.close}
|
||||||
/>
|
/>
|
||||||
@@ -1285,21 +1292,12 @@ export default function Layout(props: ParentProps) {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
|
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
|
||||||
"translate-x-0": layout.mobileSidebar.opened(),
|
"translate-x-0": layout.mobileSidebar.opened(),
|
||||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
|
|
||||||
<A
|
|
||||||
href="/"
|
|
||||||
class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
|
|
||||||
onClick={() => layout.mobileSidebar.hide()}
|
|
||||||
>
|
|
||||||
<Mark class="shrink-0" />
|
|
||||||
</A>
|
|
||||||
</div>
|
|
||||||
<SidebarContent mobile />
|
<SidebarContent mobile />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
|
import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
|
||||||
|
|
||||||
import { UPDATER_ENABLED } from "./updater"
|
import { UPDATER_ENABLED } from "./updater"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
@@ -30,6 +30,11 @@ let update: Update | null = null
|
|||||||
|
|
||||||
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||||
platform: "desktop",
|
platform: "desktop",
|
||||||
|
os: (() => {
|
||||||
|
const type = ostype()
|
||||||
|
if (type === "macos" || type === "windows" || type === "linux") return type
|
||||||
|
return undefined
|
||||||
|
})(),
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
|
|
||||||
async openDirectoryPickerDialog(opts) {
|
async openDirectoryPickerDialog(opts) {
|
||||||
@@ -292,18 +297,24 @@ root?.addEventListener("mousewheel", (e) => {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle external links - open in system browser instead of webview
|
render(() => {
|
||||||
document.addEventListener("click", (e) => {
|
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||||
|
const platform = createPlatform(() => serverPassword())
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||||
if (link?.href) {
|
if (link?.href) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
platform.openLink(link.href)
|
platform.openLink(link.href)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
render(() => {
|
onMount(() => {
|
||||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
document.addEventListener("click", handleClick)
|
||||||
const platform = createPlatform(() => serverPassword())
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("click", handleClick)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
|
|||||||
Reference in New Issue
Block a user